1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
use serde::{Deserialize, Serialize};
pub(super) const MAX_GOAL_LENGTH: usize = 2000;
/// Reject goal text carrying C0/C1 control characters other than ordinary
/// `\n`/`\r`/`\t` whitespace — NUL and ANSI escape sequences have no place
/// in a natural-language goal and are a log-poisoning/injection vector (see
/// `.claude/rules/security.md`). Shared by every handler that persists a
/// goal string (`handlers::validate_goal`, `ScheduleBody::validate`,
/// `MaxxBody::validate`, `ScheduleChainBody::validate`'s per-step check) so
/// a scheduled/MAXX/chain path can't reopen the vector `POST /api/tasks`
/// closes.
pub(super) fn reject_control_chars(goal: &str) -> Result<(), String> {
if let Some(c) = goal
.chars()
.find(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t'))
{
return Err(format!(
"goal contains a disallowed control character (U+{:04X})",
c as u32
));
}
Ok(())
}
/// Request body for `POST /api/tasks`.
#[derive(Debug, Deserialize)]
pub struct CreateTaskRequest {
/// Natural-language goal for the agent (max 2 000 chars).
pub goal: String,
/// Path to the git repository the agent should work in.
pub repo: Option<String>,
/// Task priority: `"low"`, `"normal"` (default), `"high"`, or `"critical"`.
#[serde(default)]
pub priority: Option<String>,
/// Additional constraints appended to the agent's system prompt.
#[serde(default)]
pub constraints: Option<Vec<String>>,
/// Directories the agent is permitted to read and write.
#[serde(default)]
pub allowed_dirs: Option<Vec<String>>,
/// Directories the agent must not touch.
#[serde(default)]
pub forbidden_dirs: Option<Vec<String>>,
/// Maximum retry attempts before the task is marked failed.
#[serde(default)]
pub max_retries: Option<u8>,
/// Phase 11 — require human approval of the plan before implementation.
#[serde(default)]
pub require_plan_approval: Option<bool>,
/// Verifier as Explicit Gate — force the Konjo Verifier second-score pass
/// for this task, independent of `autonomy_level`. Mirrors
/// [`lopi_core::Task::verifier_required`].
#[serde(default)]
pub verifier_required: Option<bool>,
/// Explicit verifier model override, e.g. `"claude-opus-4-7"`. Mirrors
/// [`lopi_core::Task::verifier_model`].
#[serde(default)]
pub verifier_model: Option<String>,
/// Reasoning-effort hint for the verifier's grading pass. Mirrors
/// [`lopi_core::Task::verifier_effort`].
#[serde(default)]
pub verifier_effort: Option<String>,
/// Report on Finish channel name (e.g. `"telegram"`). Validated via
/// [`lopi_core::ReportChannel::parse`] at request time — an unknown or
/// currently-unreachable channel (`"whatsapp"`) is rejected with a 422,
/// never silently dropped. Mirrors [`lopi_core::Task::report`].
#[serde(default)]
pub report: Option<String>,
/// Per-task override of the hard iteration ceiling, taking precedence
/// over the repo's `.lopi/loop.toml`. `0` is the infinite-loop sentinel.
/// Mirrors [`lopi_core::Task::max_iterations`].
#[serde(default)]
pub max_iterations: Option<u8>,
/// Explicit worker-model override. Mirrors [`lopi_core::Task::model`].
#[serde(default)]
pub model: Option<String>,
/// Reasoning-effort level for the worker session, passed to the CLI as
/// `--effort` (`low`/`medium`/`high`/`xhigh`/`max`). Mirrors
/// [`lopi_core::Task::effort`].
#[serde(default)]
pub effort: Option<String>,
/// How much the `claude -p` worker session may act on tool calls without
/// a human answering a prompt (`"bypassPermissions"` / `"auto"` /
/// `"acceptEdits"` / `"dontAsk"`), passed to the CLI as
/// `--permission-mode`. Validated via
/// [`lopi_core::PermissionMode::parse`] at request time — an unrecognized
/// value is rejected with a 422, never silently dropped or coerced.
/// Unlike `autonomy` on the web wire type, this one is wired end to end:
/// it reaches a real `--permission-mode` subprocess arg, not just
/// client-side state. Mirrors [`lopi_core::Task::permission_mode`].
#[serde(default)]
pub permission_mode: Option<String>,
/// Goal-intent override for zero-diff success handling: `"file_changes"`
/// (a zero-diff attempt fails and retries) or `"review_only"` (zero diff
/// is a valid success). `None` infers it from the goal text. Mirrors
/// [`lopi_core::Task::deliverable`].
#[serde(default)]
pub deliverable: Option<lopi_core::Deliverable>,
/// Guardrail precondition — a shell command that must exit `0` before
/// the loop's first iteration. Mirrors [`lopi_core::Task::gate`].
#[serde(default)]
pub gate: Option<String>,
/// Guardrail exit-condition — a shell command checked after each
/// iteration; exiting `0` ends the loop early as a success. Mirrors
/// [`lopi_core::Task::until`].
#[serde(default)]
pub until: Option<String>,
/// On-fail policy override (`"stop"` / `"continue"` / `"backoff"`).
/// Mirrors [`lopi_core::Task::on_fail`].
#[serde(default)]
pub on_fail: Option<lopi_core::loop_config::OnFail>,
/// Backend-1 — opaque caller-supplied identity (e.g. a loop-stack card
/// id), persisted and echoed back verbatim. Mirrors
/// [`lopi_core::Task::client_ref`].
#[serde(default)]
pub client_ref: Option<String>,
/// Eval-Execution-1 (A1) — the machine-checkable success condition the
/// tiered eval executor scores this loop against. Compiled UI-side from a
/// card's `evals` checklist. Mirrors [`lopi_core::Task::acceptance`].
#[serde(default)]
pub acceptance: Option<lopi_core::acceptance::Acceptance>,
/// Eval-Execution-1 (A1) — operator opt-out of the fail-closed verifier.
/// `false` / omitted keeps the safe default (an error blocks finalize).
/// Mirrors [`lopi_core::Task::verifier_fail_open`].
#[serde(default)]
pub verifier_fail_open: Option<bool>,
/// Progress-Gating (A3) — per-task token budget ceiling the loop meters
/// against, stopping with `StopReason::Budget` on exceed. `0`/omitted
/// inherits the repo/global budget. Mirrors [`lopi_core::Task::budget_tokens`].
#[serde(default)]
pub budget_tokens: Option<u64>,
/// Per-card budget override, taking precedence over the target repo's
/// `.lopi/loop.toml` `[budget]`. A `preset` of `"quick"`/`"standard"`
/// caps spend and **denies the `Workflow`/`Task`/`Agent` fan-out tools**
/// (so sub-agents can't run up cost), while `"deep"`/`"unlimited"` allow
/// fan-out; `usd`/`tokens` cap spend directly. This is the card-level
/// lever to stop a cheap-model card from fanning out into pricier
/// sub-agents. Mirrors [`lopi_core::Task::budget_override`].
#[serde(default)]
pub budget_override: Option<lopi_core::BudgetOverride>,
}
/// Response body for `POST /api/tasks`.
#[derive(Debug, Serialize)]
pub struct CreateTaskResponse {
/// UUID of the created (or existing) task. When `duplicate_of` is set,
/// this is the id generated for *this* request, which was never
/// actually queued — the task genuinely running is `duplicate_of`.
pub id: String,
/// The goal string as stored.
pub goal: String,
/// `true` if the task was newly queued; `false` if it was a duplicate.
pub queued: bool,
/// If this was a duplicate, the ID of the existing task actually
/// running — callers that need "the real task id" must prefer this
/// over `id` when it's set.
pub duplicate_of: Option<String>,
/// Echoes `CreateTaskRequest::client_ref` verbatim, so a caller that
/// fired several requests concurrently can still line up which
/// response belongs to which request without relying on ordering.
pub client_ref: Option<String>,
}