@proagentstore/cli 0.4.53 → 0.4.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Every outcome `/browser/complete` can be called with, and what it means locally.
3
+ *
4
+ * Only `cancelled` changed in #636. The rest are the behaviour that was already there, written
5
+ * down: `blocked` stays its own status because the agent stopped and needs the USER (email
6
+ * verification, something it cannot answer truthfully) — needs-attention, not a failure, and
7
+ * the console shows it as an active ticket rather than burying it.
8
+ *
9
+ * `captcha`/`stuck`/`needs_input` reach here only through the workflow's handoff TIMEOUT,
10
+ * which calls this endpoint with `failed` and a "… not resolved in time" detail — so their
11
+ * rows are a backstop for a direct call. They are `failed` because `TaskStatus` has no
12
+ * `escalated`, which is what the cloud's run record calls them; the honest local equivalent
13
+ * would be `needs_human`, but a task in that state is not finished, and every call to this
14
+ * method IS the finish. Left as-is deliberately rather than changed under an unrelated fix.
15
+ */
16
+ export const APPLY_OUTCOME_DISPOSITION = {
17
+ submitted: { status: "completed", event: "task.completed", error: false },
18
+ ready: { status: "completed", event: "task.completed", error: false },
19
+ expired: { status: "completed", event: "task.completed", error: false },
20
+ blocked: { status: "blocked", event: "task.failed", error: true },
21
+ captcha: { status: "failed", event: "task.failed", error: true },
22
+ stuck: { status: "failed", event: "task.failed", error: true },
23
+ needs_input: { status: "failed", event: "task.failed", error: true },
24
+ failed: { status: "failed", event: "task.failed", error: true },
25
+ max_steps: { status: "failed", event: "task.failed", error: true },
26
+ cancelled: { status: "cancelled", event: "task.cancelled", error: false },
27
+ };
28
+ /**
29
+ * The disposition for an outcome off the wire.
30
+ *
31
+ * `outcome` is a string from an HTTP body: a newer cloud can send one this bundled runner has
32
+ * never heard of, since the runner ships inside a CLI the user upgrades on their own schedule.
33
+ * An unknown outcome falls back to `failed` — the same `?? "failed"` the cloud's
34
+ * `browserRunStopReason` uses — because "something ended and I cannot say it went well" is the
35
+ * only honest reading of a word this build does not know.
36
+ */
37
+ export function dispositionForOutcome(outcome) {
38
+ return APPLY_OUTCOME_DISPOSITION[outcome] ?? APPLY_OUTCOME_DISPOSITION.failed;
39
+ }
40
+ /**
41
+ * Write a finished run's outcome onto the task, and name the event to append.
42
+ *
43
+ * Pure apart from the mutation of the task it is handed, so the whole disposition — status,
44
+ * error-or-not, which event — is decided in one tested place instead of inside a method that
45
+ * also detaches CDP sessions and closes pages.
46
+ */
47
+ export function settleTaskOutcome(task, outcome, detail) {
48
+ const disposition = dispositionForOutcome(outcome);
49
+ task.status = disposition.status;
50
+ task.output = { outcome, detail };
51
+ if (disposition.error)
52
+ task.error = detail || outcome;
53
+ task.updatedAt = new Date().toISOString();
54
+ task.completedAt = task.updatedAt;
55
+ return disposition;
56
+ }
57
+ /**
58
+ * The statuses a task cannot be moved out of — what `cancelTask` refuses to overwrite.
59
+ *
60
+ * `blocked` is deliberately NOT here: a blocked task is waiting on the owner, and Stop is the
61
+ * owner answering. `cancelled` IS here, and was the member the old `completed || failed` pair
62
+ * missed (#636) — a second Stop rewrote the timestamps and appended a duplicate
63
+ * `task.cancelled`. `completed` and `failed` stay untouchable in the other direction: a run
64
+ * that genuinely finished must not be relabelled as cancelled, because it was not.
65
+ */
66
+ export const TERMINAL_TASK_STATUSES = ["completed", "failed", "cancelled"];
67
+ /** Has this task already finished, whichever way it finished? */
68
+ export function isTerminalTaskStatus(status) {
69
+ return TERMINAL_TASK_STATUSES.includes(status);
70
+ }
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { classifyCommand, commandFromToolInput, fillTargetFromResult, toolCallOk, toolResultMark } from "./engine-acts.js";
3
3
  import { parseEngineUsage } from "./engine-usage.js";
4
4
  import { turnReportFromExit, turnReportFromResult } from "./engine-turn.js";
5
+ import { renderToolResult, shortInput, stripAnsi } from "./transcript-lines.js";
5
6
  /**
6
7
  * Merge the platform's resolved engine env over the machine's, where an EMPTY value means
7
8
  * REMOVE rather than "set to empty".
@@ -115,6 +116,15 @@ export class HeadlessSession {
115
116
  * damages the record exactly as much as missing a real one.
116
117
  */
117
118
  awaitingResult = new Map();
119
+ /**
120
+ * `tool_use_id` → tool name, for the ONE decision the result event cannot make on its own: how
121
+ * much of it reaches the pane (#700, `toolResultBudget` in `transcript-lines.ts`).
122
+ *
123
+ * Separate from {@link awaitingResult}, which holds only the small minority of calls that
124
+ * classify as consequential acts. Entries are deleted when their result arrives and the whole
125
+ * map is cleared at the turn boundary, where a call that never got one is known never to.
126
+ */
127
+ toolNames = new Map();
118
128
  /** Turn counter — only used to build a fallback id when the CLI's event has no `uuid`. */
119
129
  usageSeq = 0;
120
130
  /**
@@ -597,7 +607,13 @@ export class HeadlessSession {
597
607
  this.push(`[${stamp()}] ${block.text.trim()}`); // timestamped agent reply
598
608
  }
599
609
  else if (block.type === "tool_use") {
600
- this.push(`⚙ ${String(block.name ?? "tool")} ${shortInput(block.input)}`); // ⚙
610
+ const name = String(block.name ?? "tool");
611
+ this.push(`⚙ ${name} ${shortInput(block.input)}`); // ⚙
612
+ // The result arrives in a LATER event carrying only `tool_use_id`, and how much
613
+ // of it reaches the pane depends on which tool it was (#700) — so the name is
614
+ // remembered here and read back in `settleAct`'s sibling branch below.
615
+ if (typeof block.id === "string" && block.id)
616
+ this.toolNames.set(block.id, name);
601
617
  this.noteAct(block);
602
618
  }
603
619
  }
@@ -605,7 +621,12 @@ export class HeadlessSession {
605
621
  case "user": // tool results come back as a synthetic user message
606
622
  for (const block of ev.message?.content ?? []) {
607
623
  if (block.type === "tool_result") {
608
- this.push(` ↳${toolResultMark(block)} ${toolResult(block.content)}`); // ↳✓ / ↳✗ (#597)
624
+ const id = typeof block.tool_use_id === "string" ? block.tool_use_id : "";
625
+ // `""` when the call was not seen (a pane that began mid-turn, a runner restart):
626
+ // an unknown tool takes the conservative budget rather than the generous one.
627
+ const tool = this.toolNames.get(id) ?? "";
628
+ this.toolNames.delete(id);
629
+ this.push(renderToolResult(toolResultMark(block), block.content, tool)); // ↳✓ / ↳✗ (#597)
609
630
  this.settleAct(block);
610
631
  }
611
632
  }
@@ -633,13 +654,16 @@ export class HeadlessSession {
633
654
  // never saw whether it worked" is a materially different claim from silence, and
634
655
  // silence is what a supervisor would read as "it did nothing".
635
656
  this.flushAwaitingActs();
657
+ this.toolNames.clear(); // no further result is coming for anything still named here
636
658
  this.run = "idle"; // the turn is OVER — a fact, not a guess
637
659
  break;
638
660
  }
639
661
  default:
640
662
  break;
641
663
  }
642
- // Keep the in-memory transcript bounded.
664
+ // Keep the in-memory transcript bounded. Counts ENTRIES, and an entry may now be a
665
+ // multi-line block (a result, or a long assistant reply) rather than one line — the
666
+ // character bound that matters is `MAX_PANE` in runtime.ts, applied on the way out.
643
667
  if (this.transcript.length > 4000)
644
668
  this.transcript = this.transcript.slice(-3000);
645
669
  }
@@ -665,8 +689,8 @@ export class HeadlessSession {
665
689
  *
666
690
  * The result carries more than the outcome: `gh pr create --fill` states its PR number nowhere
667
691
  * but its own stdout, so an unnumbered `pr.open`/`pr.merge` takes it from here (#417). It is read
668
- * from the RAW `block.content`, never from `toolResult()`'s display line — that truncates to 240
669
- * characters for the transcript and would cut the URL off a verbose result.
692
+ * from the RAW `block.content`, never from `renderToolResult()`'s display lines — those are cut to
693
+ * the pane's budget (`transcript-lines.ts`) and would drop the URL off a verbose result.
670
694
  *
671
695
  * This path (and `noteAct`) is reachable ONLY from the structured stream-json handling above
672
696
  * (`assistant` → `tool_use`, `user` → `tool_result`). A Codex/Grok session is a raw spawn with no
@@ -820,36 +844,6 @@ export function buildClaudeArgs(userArgs, resumeId) {
820
844
  args.push("--resume", resumeId);
821
845
  return args;
822
846
  }
823
- /** Strip ANSI/VT escape sequences so a raw CLI's coloured output reads as plain text. */
824
- function stripAnsi(s) {
825
- // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping terminal escape/control codes from terminal output.
826
- return s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b[()][AB0-2]/g, "").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "");
827
- }
828
- /** Compact a tool_use input object to a single readable line. */
829
- function shortInput(input) {
830
- if (input == null)
831
- return "";
832
- try {
833
- const s = typeof input === "string" ? input : JSON.stringify(input);
834
- return s.length > 160 ? `${s.slice(0, 160)}…` : s;
835
- }
836
- catch {
837
- return "";
838
- }
839
- }
840
- /** Render a tool_result's content (string, or array of {type:text,text}) to text. */
841
- function toolResult(content) {
842
- let text = "";
843
- if (typeof content === "string")
844
- text = content;
845
- else if (Array.isArray(content)) {
846
- text = content
847
- .map((b) => (b && typeof b === "object" && "text" in b ? String(b.text) : ""))
848
- .join(" ");
849
- }
850
- text = text.replace(/\s+/g, " ").trim();
851
- return text.length > 240 ? `${text.slice(0, 240)}…` : text;
852
- }
853
847
  function loadFile(path) {
854
848
  if (!path || !existsSync(path))
855
849
  return {};
@@ -0,0 +1,223 @@
1
+ /**
2
+ * How an engine event becomes a line in the pane — the runner's half of #700.
3
+ *
4
+ * ── The defect this file exists to remove
5
+ *
6
+ * `headless.ts` rendered every `tool_result` through a four-line helper that did two things before
7
+ * the pane existed: it collapsed `\s+` to a single space, and it cut the text to 240 characters.
8
+ * Both losses were total and neither was disclosed beyond a trailing ellipsis.
9
+ *
10
+ * The consequence is not "results are short". It is that **the shape of a result no longer depends
11
+ * on the command that produced it.** `cat`, `sed -n '1,50p'`, `cat -n | head -60` and "print every
12
+ * character" all arrive at the same size, on one line, with the indentation gone. A Pilot that
13
+ * cannot see a file therefore has no move: every rephrasing it can reach is the same request with
14
+ * the same bound. One live run spent twelve of its sixteen decisions searching that empty space and
15
+ * concluded — reasonably, on the evidence in front of it — that "the CLI is summarizing file
16
+ * contents". It was not; the evidence had been destroyed upstream.
17
+ *
18
+ * Measured across 20 sessions of one instance: **150 of 251 stored tool-result lines (59.8%) hit
19
+ * the 240-character cap** — 102 `Bash`, 43 `Read` — with a maximum line length of 246. The cap was
20
+ * the common case, and it fired hardest on `Read`, the tool whose entire job is returning file
21
+ * contents.
22
+ *
23
+ * ── What replaces it, and what it costs
24
+ *
25
+ * Three changes, and the reason each is separate:
26
+ *
27
+ * 1. **Line structure survives.** Newlines are kept and continuation lines carry
28
+ * {@link RESULT_CONT_PREFIX}. This is the half that matters most and it costs almost nothing:
29
+ * `cat -n` output is unreadable as one space-joined line at ANY length, so the collapse made
30
+ * the cap worse than its size alone implies.
31
+ * 2. **The cap is per tool** ({@link toolResultBudget}), not one number. A `Read` result is the
32
+ * case that needs room; an `Edit`'s "has been updated successfully" is a status string that was
33
+ * never near 240 anyway. Raising one number for everything would buy file text by spending the
34
+ * pane on results that carry none.
35
+ * 3. **A cut states its own size** (`…[cut: 1,500 of 18,432 chars]`), so the reader learns there
36
+ * is more AND how much — which is what makes "ask for a slice that fits" a strategy rather than
37
+ * a guess. Same disclosure discipline as `repo_read_file`'s window header and `repo_git`'s
38
+ * TRUNCATED note on the cloud side.
39
+ *
40
+ * **The cost is real and it is the pane.** A `terminal` row stores the last 8,000 characters
41
+ * (`TERMINAL_SNAPSHOT_CHARS`) and one Pilot decision reads the last 6,000 (`PILOT_PANE_CHARS`), so
42
+ * every character bought for file text is bought by evicting history — including the `⚙`/`↳`
43
+ * framing `engine-tool-calls.ts` parses back out and the engine-error lines #580 exists to keep
44
+ * visible. {@link RESULT_CAP_CONTENT_CHARS} is therefore derived from that window rather than
45
+ * chosen from it — see {@link RESULT_CAP_CONTENT_CHARS} and {@link RESULT_RENDERED_MAX}, which
46
+ * state the content budget and what it actually costs the pane once framing is paid for.
47
+ *
48
+ * ── Old runners, and why nothing gates on a version
49
+ *
50
+ * A machine that has not upgraded keeps the 240-char single-line behaviour, so every cloud reader
51
+ * must go on tolerating short, structureless results — `engine-tool-calls.ts` already models that
52
+ * (`outputCut`) and continues to. Continuation lines are read by their PREFIX, not by a version
53
+ * probe: a pane is judged by what it contains, the way the `↳✓` outcome marker is.
54
+ */
55
+ /**
56
+ * The prefix every line of a result after the first carries.
57
+ *
58
+ * It exists for two reasons and the second is the load-bearing one:
59
+ *
60
+ * - the pane stays readable as a transcript rather than as an unindented dump, and
61
+ * - a result can no longer FORGE the framing. `parseEngineToolCalls` decides what a line is from
62
+ * its first characters, so a file containing a line that begins `⚙ ` or `↳` would, once
63
+ * newlines survived, parse as a tool call the engine never made. Every continuation line begins
64
+ * with this prefix instead, so a result's own text can never start a line that the parser reads
65
+ * as anything else.
66
+ *
67
+ * `│` (U+2502), not `|`: a markdown table in an engine's reply uses the ASCII pipe, and the point
68
+ * of the character is that transcript prose does not use it.
69
+ */
70
+ export const RESULT_CONT_PREFIX = " │ ";
71
+ /**
72
+ * How much of a CONTENT tool's result — its own characters, framing excluded — reaches the pane.
73
+ *
74
+ * Derived, not picked: the Pilot reads the last 6,000 characters of the pane
75
+ * (`PILOT_PANE_CHARS`, `workers/api/src/lib/coding-repetition.ts`), and no single result may take
76
+ * more than a QUARTER of that in content, which is ~35 lines of ordinary source — what a bounded
77
+ * slice request actually asks for. What that costs the pane in full is {@link RESULT_RENDERED_MAX},
78
+ * and it is the larger number, because framing is not free.
79
+ *
80
+ * The two constants are separate declarations in separate packages for the reason `RESULT_ARROW` is
81
+ * (a Worker must not import the runner's Node package); the derivation is stated here so that
82
+ * moving one without the other is at least a visible mistake.
83
+ *
84
+ * `used` and `total` in the cut notice are BOTH content characters, deliberately: the number is
85
+ * only actionable if the model can compare it against the file it is asking for.
86
+ */
87
+ export const RESULT_CAP_CONTENT_CHARS = 1500;
88
+ /**
89
+ * A ceiling on LINES as well as characters, because the prefix is charged per line.
90
+ *
91
+ * A result of 1,500 one-character lines would pay {@link RESULT_CONT_PREFIX} 1,500 times — 6,000
92
+ * characters of framing for 1,500 of content, i.e. the whole of the Pilot's window spent on
93
+ * indentation. 60 lines bounds that overhead at 240 characters.
94
+ */
95
+ export const RESULT_CAP_CONTENT_LINES = 60;
96
+ /** Every other tool keeps the historical 240 characters — see {@link CONTENT_TOOLS}. */
97
+ export const RESULT_CAP_DEFAULT_CHARS = 240;
98
+ /** …and at most six lines of it, so a status string cannot spend six prefixes to say nothing. */
99
+ export const RESULT_CAP_DEFAULT_LINES = 6;
100
+ /** The longest cut notice the renderer can emit — `…[cut: 999,999,999 of 999,999,999 chars]`. */
101
+ const CUT_NOTICE_MAX = 45;
102
+ /**
103
+ * What the WHOLE result block can cost the pane, framing and disclosure included.
104
+ *
105
+ * Stated because the content budget alone is not the honest figure and pretending otherwise is how
106
+ * a cap gets quietly exceeded: the arrow prefix, one `RESULT_CONT_PREFIX` and one newline per
107
+ * continuation line, and the cut notice are all pane characters too. At the current numbers that is
108
+ * ~1,840 — under a THIRD of the Pilot's 6,000-character window, so three consecutive content
109
+ * results still coexist with the narrative and the `⚙`/`↳` framing rather than evicting them.
110
+ *
111
+ * If someone widens {@link RESULT_CAP_CONTENT_CHARS}, this is the number that says what it costs.
112
+ */
113
+ export const RESULT_RENDERED_MAX = " ↳✓ ".length + RESULT_CAP_CONTENT_CHARS + (RESULT_CAP_CONTENT_LINES - 1) * (RESULT_CONT_PREFIX.length + 1) + CUT_NOTICE_MAX;
114
+ /**
115
+ * The tools whose RESULT is the answer, rather than a receipt for an action.
116
+ *
117
+ * `Read`/`Bash` are the two the measurement names (43 and 102 of the 150 truncated lines).
118
+ * `Grep`/`Glob`/`BashOutput`/`NotebookRead` are here because their results are line-structured
119
+ * listings — the same content, arriving through a different tool name — and a Pilot that asked for
120
+ * a grep instead of a cat should not be punished for it.
121
+ *
122
+ * `Edit`, `Write`, `MultiEdit` and `TodoWrite` are deliberately absent: their results are status
123
+ * strings ("has been updated successfully…", 176-214 characters in the sampled sessions) that the
124
+ * old cap barely touched, so widening them would spend the pane and buy nothing.
125
+ */
126
+ export const CONTENT_TOOLS = new Set(["Read", "Bash", "BashOutput", "Grep", "Glob", "NotebookRead"]);
127
+ /** The budget for one tool's result. An unknown or unnamed tool gets the conservative one. */
128
+ export function toolResultBudget(tool) {
129
+ return CONTENT_TOOLS.has(tool)
130
+ ? { chars: RESULT_CAP_CONTENT_CHARS, lines: RESULT_CAP_CONTENT_LINES }
131
+ : { chars: RESULT_CAP_DEFAULT_CHARS, lines: RESULT_CAP_DEFAULT_LINES };
132
+ }
133
+ /** Strip ANSI/VT escape sequences so a raw CLI's coloured output reads as plain text. */
134
+ export function stripAnsi(s) {
135
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping terminal escape/control codes from terminal output.
136
+ return s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b[()][AB0-2]/g, "").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "");
137
+ }
138
+ /** Compact a tool_use input object to a single readable line. */
139
+ export function shortInput(input) {
140
+ if (input == null)
141
+ return "";
142
+ try {
143
+ const s = typeof input === "string" ? input : JSON.stringify(input);
144
+ return s.length > 160 ? `${s.slice(0, 160)}…` : s;
145
+ }
146
+ catch {
147
+ return "";
148
+ }
149
+ }
150
+ /** Render a tool_result's content (string, or array of `{type:text,text}`) to raw text. */
151
+ export function toolResultText(content) {
152
+ if (typeof content === "string")
153
+ return content;
154
+ if (!Array.isArray(content))
155
+ return "";
156
+ // Joined with a NEWLINE, not a space: separate content blocks are separate pieces of output,
157
+ // and the whole point of this module is that the difference is preserved.
158
+ return content
159
+ .map((b) => (b && typeof b === "object" && "text" in b ? String(b.text) : ""))
160
+ .filter((t) => t !== "")
161
+ .join("\n");
162
+ }
163
+ /**
164
+ * Normalise a result's text to display lines: no CR, no escape codes, no trailing whitespace, and
165
+ * no runs of blank lines. Every one of those is pane budget spent on nothing.
166
+ */
167
+ function tidyLines(text) {
168
+ const out = [];
169
+ for (const raw of stripAnsi(text.replace(/\r\n?/g, "\n")).split("\n")) {
170
+ const line = raw.trimEnd();
171
+ // Drop a leading blank, and collapse a run of blanks to one.
172
+ if (!line && (out.length === 0 || out[out.length - 1] === ""))
173
+ continue;
174
+ out.push(line);
175
+ }
176
+ while (out.length && out[out.length - 1] === "")
177
+ out.pop();
178
+ return out;
179
+ }
180
+ /**
181
+ * The full transcript entry for one `tool_result` — the arrow line plus any continuation lines.
182
+ *
183
+ * The head is cut rather than the tail, unchanged from the old helper: a file, a listing and a
184
+ * grep all answer from their beginning, and a test run's tail is recoverable from the engine's own
185
+ * reply text, which is never truncated. Changing that is a separate decision and is not made here.
186
+ */
187
+ export function renderToolResult(mark, content, tool) {
188
+ const lines = tidyLines(toolResultText(content));
189
+ const budget = toolResultBudget(tool);
190
+ const total = lines.join("\n").length;
191
+ const kept = [];
192
+ let used = 0;
193
+ let cut = false;
194
+ for (const line of lines) {
195
+ if (kept.length >= budget.lines) {
196
+ cut = true;
197
+ break;
198
+ }
199
+ const room = budget.chars - used;
200
+ if (room <= 0) {
201
+ cut = true;
202
+ break;
203
+ }
204
+ if (line.length > room) {
205
+ kept.push(line.slice(0, room));
206
+ used += room;
207
+ cut = true;
208
+ break;
209
+ }
210
+ kept.push(line);
211
+ used += line.length;
212
+ }
213
+ if (cut && kept.length) {
214
+ // The figures, not just the ellipsis: "there was more" tells the reader to give up, "1,500
215
+ // of 18,432" tells it how narrow a slice would arrive whole. The ellipsis stays because it
216
+ // is what every runner ever written has used to mark a cut, and readers key on it.
217
+ kept[kept.length - 1] = `${kept[kept.length - 1]}…[cut: ${used.toLocaleString("en-US")} of ${total.toLocaleString("en-US")} chars]`;
218
+ }
219
+ const head = ` ↳${mark} ${kept[0] ?? ""}`.trimEnd();
220
+ if (kept.length < 2)
221
+ return head;
222
+ return [head, ...kept.slice(1).map((l) => `${RESULT_CONT_PREFIX}${l}`)].join("\n");
223
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The commit guard AT THE ACT BOUNDARY — the runner half of #627 / #629.
3
+ *
4
+ * ── Why it has to be here
5
+ *
6
+ * `dryRun` and `readOnly` were enforced entirely in the cloud, on `action.name`: a string the MODEL
7
+ * wrote ABOUT the element. This process is the one that actually clicks, and it clicks by `ref`
8
+ * ({@link https://github.com/microsoft/playwright-mcp} `browser_click({ element, target })` — only
9
+ * `target` locates; `element` is a human-readable description). So the guard tested a story about
10
+ * the act while the act went somewhere else entirely, and the runner — the only party holding the
11
+ * DOM, and therefore the only one that can KNOW whether a control submits — had never been told
12
+ * that a run was a rehearsal at all: `grep -rn dryRun packages/browser-runner/src` returned eight
13
+ * hits and every one of them was the orphaned-browser reaper's unrelated `--dry-run` flag.
14
+ *
15
+ * A nameless click had already submitted a real application during a run the owner asked to be a
16
+ * test. The fix at the time made an EMPTY name a refusal, which left a WRONG name — a paraphrase,
17
+ * an `aria-label` the model rewrote, a page in French — behaving exactly as before.
18
+ *
19
+ * ── What is a fact here and what is still a guess
20
+ *
21
+ * FACT (`read_only`): whether the targeted control submits a form, and whether that form is a POST.
22
+ * Language-independent, label-independent, and not something the brain can talk its way past. A
23
+ * GET form is a search — the read-only prompt explicitly allows finding things — so only POST is
24
+ * refused. Enter and Space are checked against the FOCUSED element the same way.
25
+ *
26
+ * GUESS (both rehearsal modes): which of several POST submits on a multi-page ATS is the FINAL one.
27
+ * Nothing in the DOM says so — "Save and Continue" on page 3 and "Submit application" on page 6 are
28
+ * the same kind of control — and a rehearsal must be able to walk the whole form. So a rehearsal
29
+ * still decides on a LABEL. What changed is whose label: the element's own accessible name, read
30
+ * back out of the live DOM, instead of the model's claim about it, and a vocabulary that is not
31
+ * English-only. That residual is recorded on the issue rather than papered over.
32
+ */
33
+ /**
34
+ * The FLOOR vocabulary, used only when the cloud sent none. It is deliberately the read-only
35
+ * (widest) set: a runner that has been told "read_only" but not told the words must not fail open.
36
+ * The authoritative list is `workers/api/src/lib/commit-guard.ts`, and `commit-guard.test.ts` there
37
+ * asserts this file still parses as a regex, so a copy that rots is a red build rather than a
38
+ * silent downgrade.
39
+ */
40
+ export const FALLBACK_COMMIT_RE = /\b(confirm|accept|submit|send|post|publish|delete|remove|pay|purchase|buy|apply|approve|agree|save)\b|(?<![\p{L}\p{N}])(envoyer|soumettre|valider|absenden|abschicken|senden|einreichen|enviar|invia|inviare|verstuur|versturen|verzenden|indienen|skicka|wy[śs]lij|g[öo]nder|kirim|отправить)(?![\p{L}\p{N}])|提交|送出|确认|確認|送信|提出|제출|보내기|إرسال|تقديم/iu;
41
+ /** Compile the policy the cloud sent, falling back to the floor above. A malformed pattern must
42
+ * NOT disarm the guard — it falls back rather than throwing, because the caller is about to act. */
43
+ export function commitLabelRe(spec) {
44
+ if (!spec?.labels)
45
+ return FALLBACK_COMMIT_RE;
46
+ try {
47
+ return new RegExp(spec.labels, spec.flags || "iu");
48
+ }
49
+ catch {
50
+ return FALLBACK_COMMIT_RE;
51
+ }
52
+ }
53
+ /** Keys that submit a focused form. `NumpadEnter` submits identically and was matched by nothing. */
54
+ export const SUBMIT_KEY_RE = /^(enter|return|numpadenter)$/i;
55
+ /** Space activates the focused control, including a submit button. */
56
+ export const ACTIVATE_KEY_RE = /^(space|spacebar|\s)$/i;
57
+ /** The probe evaluated ON the targeted element. Kept as source text because it crosses into the
58
+ * page through the standard `browser_evaluate` tool, which takes a function expression. */
59
+ export const ELEMENT_PROBE_FN = `el => {
60
+ var btn = (el.closest && el.closest('button,input,a,[role=button]')) || el;
61
+ var tag = (btn.tagName || '').toLowerCase();
62
+ var type = ((btn.getAttribute && btn.getAttribute('type')) || '').toLowerCase();
63
+ var form = btn.form || (btn.closest && btn.closest('form')) || null;
64
+ var submits = !!form && ((tag === 'button' && type !== 'button' && type !== 'reset') || (tag === 'input' && (type === 'submit' || type === 'image')));
65
+ var name = (btn.getAttribute && btn.getAttribute('aria-label')) || btn.value || btn.innerText || btn.textContent || '';
66
+ return { submits: submits, method: form ? ((form.getAttribute('method') || 'get').toLowerCase()) : '', tag: tag, type: type, name: String(name).replace(/\\s+/g, ' ').trim().slice(0, 160) };
67
+ }`;
68
+ /** The probe for a keypress: there is no ref, so it reads whatever has focus. */
69
+ export const FOCUS_PROBE_FN = `(() => {
70
+ var el = document.activeElement;
71
+ if (!el) return { inForm: false, method: '', tag: '', type: '', name: '' };
72
+ var form = el.form || (el.closest && el.closest('form')) || null;
73
+ var name = (el.getAttribute && el.getAttribute('aria-label')) || el.value || el.innerText || el.textContent || '';
74
+ return { inForm: !!form, method: form ? ((form.getAttribute('method') || 'get').toLowerCase()) : '', tag: (el.tagName || '').toLowerCase(), type: ((el.getAttribute && el.getAttribute('type')) || '').toLowerCase(), name: String(name).replace(/\\s+/g, ' ').trim().slice(0, 160) };
75
+ })()`;
76
+ /**
77
+ * May this click reach the page? Returns the refusal to hand back to the brain, or null.
78
+ *
79
+ * `facts` is null when the element could not be probed (an evaluate that failed, a ref the page no
80
+ * longer has). That is not treated as permission: in read-only it is refused outright, and in a
81
+ * rehearsal it falls back to the claimed name, which is the behaviour that shipped.
82
+ */
83
+ export function refuseClick(spec, facts, claimedName, re) {
84
+ const claimed = (claimedName ?? "").trim();
85
+ const real = (facts?.name ?? "").trim();
86
+ if (spec.mode === "read_only") {
87
+ if (!facts) {
88
+ return "BLOCKED by the runner — this agent is READ-ONLY and that element could not be read from the page, so the click cannot be shown to be safe. Re-read the snapshot and target an element from it.";
89
+ }
90
+ if (facts.submits && facts.method === "post") {
91
+ return `BLOCKED by the runner — this agent is READ-ONLY and "${real || claimed || facts.tag}" submits a form (POST) on this page. That is a change, whatever the control is called. Report what you can already see with finish.`;
92
+ }
93
+ }
94
+ const hit = [real, claimed].find((n) => n && re.test(n));
95
+ if (!hit)
96
+ return null;
97
+ return spec.mode === "read_only"
98
+ ? `BLOCKED by the runner — this agent is READ-ONLY and can never perform "${hit}". Report what you can already see with finish.`
99
+ : `BLOCKED by the runner — this is a REHEARSAL and "${hit}" commits. The page never received the click. Call finish now instead of retrying.`;
100
+ }
101
+ /**
102
+ * May this keypress reach the page?
103
+ *
104
+ * Read-only only. A rehearsal's Enter is decided by the cloud loop, which holds the one piece of
105
+ * state that makes the call — an Enter immediately after an arrow key is an autocomplete ACCEPT,
106
+ * not a submit — and this process cannot tell a JS listbox that will swallow the key from a form
107
+ * that will take it.
108
+ */
109
+ export function refuseKey(spec, key, focus) {
110
+ if (spec.mode !== "read_only")
111
+ return null;
112
+ const k = (key ?? "").trim();
113
+ const submitKey = SUBMIT_KEY_RE.test(k);
114
+ const activateKey = ACTIVATE_KEY_RE.test(k);
115
+ if (!submitKey && !activateKey)
116
+ return null;
117
+ if (!focus) {
118
+ return `BLOCKED by the runner — this agent is READ-ONLY and the focused element could not be read, so pressing ${k || "Enter"} cannot be shown to be safe.`;
119
+ }
120
+ if (submitKey && focus.inForm && focus.method === "post") {
121
+ return `BLOCKED by the runner — this agent is READ-ONLY and pressing ${k} submits the form this field belongs to (POST). A search or filter that only reads (GET) is fine; this one writes. Report what you can already see with finish.`;
122
+ }
123
+ if (activateKey && (focus.tag === "button" || focus.type === "submit") && focus.method === "post") {
124
+ return `BLOCKED by the runner — this agent is READ-ONLY and Space activates "${focus.name || "the focused button"}", which submits a form (POST).`;
125
+ }
126
+ return null;
127
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * What a handoff-status poll can answer WITHOUT looking at the live page (#641).
3
+ *
4
+ * A handoff is the runner asking a human for something the brain cannot do: solve a
5
+ * challenge, perform one widget step it failed at, or supply a value it must not invent.
6
+ * The remote Workflow polls `browserHandoffStatus` until it is told the human acted, and
7
+ * `solved: true` is a licence to resume the application — so the only safe answers are the
8
+ * ones the runner can actually stand behind.
9
+ *
10
+ * ── The rule
11
+ *
12
+ * A handoff whose PAGE IS GONE is LOST, not resolved. It is the same category as a handoff
13
+ * whose SESSION is gone (the runner restarted, the takeover expired), which the caller
14
+ * already answers `solved: false` under a comment stating the rule outright: *a status poll
15
+ * must never relaunch the browser or claim a lost handoff is done.* Three lines below that
16
+ * comment sat `if (page.isClosed()) return { solved: true }`, ahead of all three per-reason
17
+ * branches, so it overrode every one of them:
18
+ *
19
+ * - `challenge` — the brain resumed with the CAPTCHA unsolved, and the claim was acted
20
+ * on rather than re-checked: the workflow records `solvedChallengeUrl`, and the apply
21
+ * loop then suppresses captcha re-detection on that page for the rest of the round. A
22
+ * false "solved" muted the detector that would have caught it.
23
+ * - `stuck` — resumed without the human doing the single step the handoff exists for;
24
+ * `humanDone` was never consulted.
25
+ * - `needs_input` — resumed with `value: undefined`, so the workflow skipped the save and
26
+ * the brain re-asked on the next round for a value it had been given.
27
+ *
28
+ * There is nothing to resume ONTO in any of those cases: the page the human was working in
29
+ * no longer exists, and the brain's next action would land on a freshly created blank tab.
30
+ * Staying unsolved lets the wait time out into "not resolved in time", which the cloud maps
31
+ * to `escalated` → the board's "Needs you" column — visible, and retryable by the owner.
32
+ *
33
+ * ── The one exception, and why it is not one
34
+ *
35
+ * `needs_input` is answered BEFORE page liveness is considered, because its answer never
36
+ * involved the page: the value arrives out of band through `browserSubmitInput`, which
37
+ * writes it onto the session. A value the owner typed is a real answer whether or not the
38
+ * tab survived, and discarding it would re-ask for something already in hand.
39
+ */
40
+ /**
41
+ * The answer to a handoff-status poll, or `null` when only the live DOM can give one — a
42
+ * `challenge` on a page that is still open, whose token clears in the page itself.
43
+ */
44
+ export function resolveHandoffStatus(facts) {
45
+ // Out of band, and therefore unaffected by a page that has gone.
46
+ if (facts.reason === "needs_input")
47
+ return { solved: !!facts.inputValue, challenge: null, value: facts.inputValue };
48
+ // Lost, not done. See the note above — this line is the fix for #641.
49
+ if (facts.pageClosed)
50
+ return { solved: false, challenge: null };
51
+ // A stuck handoff resumes only when the human explicitly clicks Resume — there's
52
+ // nothing to auto-detect.
53
+ if (facts.reason === "stuck")
54
+ return { solved: !!facts.humanDone, challenge: null };
55
+ return null;
56
+ }
@@ -5,8 +5,11 @@ import { basename, dirname, join, resolve } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
  import { captureScreenshotDataUrl, challengeSolved, detectHumanChallenge } from "./challenge.js";
7
7
  import { resolveRealChromeProfileDir, seedProfileCopy } from "./browser-profile.js";
8
+ import { isTerminalTaskStatus, settleTaskOutcome } from "./apply-outcome.js";
8
9
  import { McpRuntime } from "./mcp-runtime.js";
10
+ import { commitLabelRe, ELEMENT_PROBE_FN, FOCUS_PROBE_FN, refuseClick, refuseKey } from "./commit-guard.js";
9
11
  import { HumanHandoffError, RunnerInputError } from "./errors.js";
12
+ import { resolveHandoffStatus } from "./handoff-status.js";
10
13
  import { RunnerStore } from "./store.js";
11
14
  import { CodingRuntime } from "./coding/runtime.js";
12
15
  import { WORKFLOW_DRIVEN_TASKS } from "./task-types.js";
@@ -169,7 +172,11 @@ export class LocalRunner {
169
172
  }
170
173
  cancelTask(id) {
171
174
  const task = this.requireTask(id);
172
- if (task.status === "completed" || task.status === "failed")
175
+ // Already finished, whichever way — the old `completed || failed` pair missed `cancelled`, so
176
+ // a second Stop rewrote the timestamps and appended a duplicate event (#636). A run that
177
+ // genuinely completed or failed is NOT relabelled: it ended before the Stop arrived. Off THIS
178
+ // return the cloud mirrored `task.cancelled` carrying `{status:"failed"}` — fixed in the cloud.
179
+ if (isTerminalTaskStatus(task.status))
173
180
  return task;
174
181
  task.status = "cancelled";
175
182
  task.updatedAt = new Date().toISOString();
@@ -837,6 +844,46 @@ export class LocalRunner {
837
844
  return false;
838
845
  return null;
839
846
  }
847
+ /** Evaluate a function ON an element (by snapshot ref) and parse its JSON result. Never throws
848
+ * — a null return means "the page could not be asked", which callers must not read as a yes. */
849
+ async evalJson(mcp, ref, label, fn) {
850
+ const res = await mcp.callTool("browser_evaluate", { element: label, target: ref, function: fn }).catch(() => null);
851
+ if (!res || res.isError)
852
+ return null;
853
+ const txt = mcp.textOf(res);
854
+ const i = txt.indexOf("### Result");
855
+ if (i < 0)
856
+ return null;
857
+ const after = txt.slice(i + "### Result".length).trim();
858
+ const end = after.indexOf("\n###");
859
+ const block = (end >= 0 ? after.slice(0, end) : after).trim();
860
+ try {
861
+ return JSON.parse(block);
862
+ }
863
+ catch {
864
+ return null;
865
+ }
866
+ }
867
+ /**
868
+ * The commit guard, enforced HERE because this is the process that clicks (#627, #629).
869
+ *
870
+ * The cloud's pre-filter reads a label the model wrote; this one reads the element. For a
871
+ * read-only agent that is a DOM fact — does this control submit a POST form — which no label,
872
+ * in any language, can talk its way past. Returns the refusal to hand back to the brain.
873
+ */
874
+ async commitRefusal(mcp, page, action, guard) {
875
+ const re = commitLabelRe(guard);
876
+ if (action.action === "click") {
877
+ const ref = (action.ref || "").trim();
878
+ const facts = ref ? await this.evalJson(mcp, ref, action.name || action.role || "control", ELEMENT_PROBE_FN) : null;
879
+ return refuseClick(guard, facts, action.name, re);
880
+ }
881
+ if (action.action === "key") {
882
+ const focus = (await page.evaluate(FOCUS_PROBE_FN).catch(() => null));
883
+ return refuseKey(guard, action.key, focus);
884
+ }
885
+ return null;
886
+ }
840
887
  /** The snapshot ref the brain must target the element by (standard-tool `target`). */
841
888
  refOf(action) {
842
889
  const ref = (action.ref || "").trim();
@@ -943,7 +990,7 @@ export class LocalRunner {
943
990
  * element by its snapshot ref. A tool-level failure is thrown so the workflow
944
991
  * surfaces it to the brain as an `error` (which drives its self-correction).
945
992
  */
946
- async browserAct(action, resumePath) {
993
+ async browserAct(action, resumePath, guard) {
947
994
  const page = await this.getActivePage();
948
995
  // Arm résumé auto-attach so a file chooser never blocks the flow (see method).
949
996
  // resumePath may be a signed URL (remote runner) or a local path — resolve to
@@ -970,9 +1017,19 @@ export class LocalRunner {
970
1017
  title: await active.title().catch(() => ""),
971
1018
  challenge: await detectHumanChallenge(active),
972
1019
  screenshot: await this.shot(active),
1020
+ commitGuard: { supported: true, mode: guard?.mode },
973
1021
  };
974
1022
  }
975
1023
  const mcp = await this.getMcp();
1024
+ // BEFORE the tool call, never after: this is the last point at which the action is still
1025
+ // only a proposal. `commitGuard.supported` on every reply is how the cloud learns that this
1026
+ // enforcement exists here at all — measured from the runner's own answer, not guessed from
1027
+ // a version number, because the published CLI upgrades on the field's schedule.
1028
+ if (guard) {
1029
+ const refusal = await this.commitRefusal(mcp, page, action, guard);
1030
+ if (refusal)
1031
+ throw new RunnerInputError(refusal);
1032
+ }
976
1033
  const res = await this.callBrowserTool(mcp, action);
977
1034
  const text = mcp.textOf(res).trim();
978
1035
  // A native page dialog (alert/confirm/beforeunload) puts the standard server
@@ -994,6 +1051,7 @@ export class LocalRunner {
994
1051
  challenge: await detectHumanChallenge(settled),
995
1052
  feedback: dialogMsg ? `a native dialog was accepted: "${dialogMsg}"` : "a native dialog was accepted",
996
1053
  screenshot: await this.shot(settled),
1054
+ commitGuard: { supported: true, mode: guard?.mode },
997
1055
  };
998
1056
  }
999
1057
  if (res.isError)
@@ -1027,6 +1085,7 @@ export class LocalRunner {
1027
1085
  challenge: await detectHumanChallenge(active),
1028
1086
  feedback: feedback || undefined,
1029
1087
  screenshot: await this.shot(active),
1088
+ commitGuard: { supported: true, mode: guard?.mode },
1030
1089
  };
1031
1090
  }
1032
1091
  // ── Agent-driven application lifecycle (called by the remote Workflow brain) ──
@@ -1091,15 +1150,12 @@ export class LocalRunner {
1091
1150
  if (!session)
1092
1151
  return { solved: false, challenge: null };
1093
1152
  const page = session.page;
1094
- if (page.isClosed())
1095
- return { solved: true, challenge: null };
1096
- // A needs_input handoff resumes once the user supplies the value.
1097
- if (session.reason === "needs_input")
1098
- return { solved: !!session.inputValue, challenge: null, value: session.inputValue };
1099
- // A stuck handoff resumes only when the human explicitly clicks Resume —
1100
- // there's nothing to auto-detect.
1101
- if (session.reason === "stuck")
1102
- return { solved: !!session.humanDone, challenge: null };
1153
+ // Every answer that needs no live DOM — including a GONE page, which used to report
1154
+ // solved:true and resume past an unsolved challenge (#641) — comes from
1155
+ // handoff-status.ts; null means only an open challenge's own DOM can answer.
1156
+ const offline = resolveHandoffStatus({ reason: session.reason, pageClosed: page.isClosed(), inputValue: session.inputValue, humanDone: session.humanDone });
1157
+ if (offline)
1158
+ return offline;
1103
1159
  // A challenge resumes when the token/widget clears OR the human clicks Done —
1104
1160
  // custom captchas (e.g. PageUp's "not a robot") have no detectable token, so
1105
1161
  // the human's explicit Done is the authority; never strand them.
@@ -1144,23 +1200,17 @@ export class LocalRunner {
1144
1200
  }
1145
1201
  return { ok: true };
1146
1202
  }
1147
- /** Finalize an agent-driven application (submitted/expired → completed; else failed). */
1203
+ /** Finalize an agent-driven application — the outcome→status table lives in apply-outcome.ts. */
1148
1204
  async browserComplete(taskId, outcome, detail) {
1149
1205
  await this.endTakeover(taskId).catch(() => undefined);
1150
1206
  const task = this.store.getTask(taskId);
1151
1207
  if (task) {
1152
- const success = outcome === "submitted" || outcome === "ready" || outcome === "expired";
1153
- // 'blocked' = the agent stopped and needs the USER (email verification, can't
1154
- // proceed truthfully) — it's needs-attention, not a failure, so keep it a
1155
- // distinct status (the console shows it as an active ticket, not buried).
1156
- task.status = outcome === "blocked" ? "blocked" : success ? "completed" : "failed";
1157
- task.output = { outcome, detail };
1158
- if (!success)
1159
- task.error = detail || outcome;
1160
- task.updatedAt = new Date().toISOString();
1161
- task.completedAt = task.updatedAt;
1208
+ // A TOTAL table, not a chain of string comparisons: `cancelled` matched none of
1209
+ // them and fell through to `failed`, so a run the owner stopped was filed as an
1210
+ // error with a Retry button on it (#636). See apply-outcome.ts.
1211
+ const { event } = settleTaskOutcome(task, outcome, detail);
1162
1212
  this.store.putTask(task);
1163
- this.addTaskEvent(task, success ? "task.completed" : "task.failed", detail || `Application ${outcome}`, { outcome, detail });
1213
+ this.addTaskEvent(task, event, detail || `Application ${outcome}`, { outcome, detail });
1164
1214
  }
1165
1215
  await this.closeStalePages().catch(() => undefined);
1166
1216
  return { ok: true };
@@ -90,8 +90,12 @@ async function route(runner, req, res) {
90
90
  return json(res, 200, await runner.browserSnapshot(b.taskId));
91
91
  }
92
92
  if (req.method === "POST" && path === "/browser/act") {
93
+ // `guard` is the commit policy for THIS run (#627, #629) — a rehearsal or a read-only
94
+ // agent. It travels with every action rather than being registered once, because the
95
+ // runner serves many instances at once and a per-connection mode would be a second piece
96
+ // of state to get wrong.
93
97
  const body = await readJson(req);
94
- return json(res, 200, await runner.browserAct(body, body.resumePath));
98
+ return json(res, 200, await runner.browserAct(body, body.resumePath, body.guard));
95
99
  }
96
100
  if (req.method === "POST" && path === "/browser/event") {
97
101
  const b = await readJson(req);
@@ -1,9 +1,10 @@
1
1
  import { createServer, } from "node:http";
2
2
  export async function startTestJobServer(port = 0) {
3
3
  const submissions = [];
4
+ const searches = [];
4
5
  const server = createServer(async (req, res) => {
5
6
  try {
6
- await route(req, res, submissions);
7
+ await route(req, res, submissions, searches);
7
8
  }
8
9
  catch (error) {
9
10
  html(res, 500, `<h1>Server Error</h1><pre>${escapeHtml(String(error))}</pre>`);
@@ -17,7 +18,10 @@ export async function startTestJobServer(port = 0) {
17
18
  return {
18
19
  url,
19
20
  jobUrl: `${url}/jobs/software-engineer`,
21
+ quickApplyUrl: `${url}/jobs/quick-apply`,
22
+ searchUrl: `${url}/search`,
20
23
  submissions,
24
+ searches,
21
25
  async close() {
22
26
  await new Promise((resolve, reject) => {
23
27
  server.close((error) => (error ? reject(error) : resolve()));
@@ -36,7 +40,7 @@ export async function startTestJobServer(port = 0) {
36
40
  },
37
41
  };
38
42
  }
39
- async function route(req, res, submissions) {
43
+ async function route(req, res, submissions, searches) {
40
44
  const url = new URL(req.url || "/", "http://127.0.0.1");
41
45
  if (req.method === "GET" && url.pathname === "/") {
42
46
  redirect(res, "/jobs/software-engineer");
@@ -51,6 +55,17 @@ async function route(req, res, submissions) {
51
55
  }));
52
56
  return;
53
57
  }
58
+ if (req.method === "GET" && url.pathname === "/jobs/quick-apply") {
59
+ html(res, 200, quickApplyPage());
60
+ return;
61
+ }
62
+ if (req.method === "GET" && url.pathname === "/search") {
63
+ const q = url.searchParams.get("q");
64
+ if (q !== null)
65
+ searches.push(q);
66
+ html(res, 200, searchPage(q));
67
+ return;
68
+ }
54
69
  if (req.method === "POST" && url.pathname === "/apply") {
55
70
  const submission = await parseApplication(req);
56
71
  submissions.unshift(submission);
@@ -125,6 +140,33 @@ function jobPage(opts = {}) {
125
140
  </main>
126
141
  `);
127
142
  }
143
+ /** One click = one real application. No `required` attribute anywhere, so nothing but the guard
144
+ * can stop the POST — a fixture where HTML validation did the stopping would prove nothing. */
145
+ function quickApplyPage() {
146
+ return page("Postuler en 1 clic", `
147
+ <main>
148
+ <section class="job"><h1>Ingénieur logiciel</h1><p class="company">Fixture Labs</p></section>
149
+ <form action="/apply" method="post" enctype="multipart/form-data" class="application-form">
150
+ <input type="hidden" name="fullName" value="Sergey Ivochkin" />
151
+ <input type="hidden" name="email" value="sergey@example.com" />
152
+ <label>Recherche <input name="coverNote" aria-label="Recherche" /></label>
153
+ <button type="submit">Envoyer ma candidature</button>
154
+ </form>
155
+ </main>
156
+ `);
157
+ }
158
+ /** A GET search form: submitting it only READS. */
159
+ function searchPage(q) {
160
+ return page("Recherche", `
161
+ <main>
162
+ <form action="/search" method="get">
163
+ <label>Search <input name="q" aria-label="Search" /></label>
164
+ <button type="submit">Search</button>
165
+ </form>
166
+ <p id="result">${q === null ? "no query" : `searched: ${escapeHtml(q)}`}</p>
167
+ </main>
168
+ `);
169
+ }
128
170
  function successPage(submission) {
129
171
  return page("Application Received", `
130
172
  <main>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.53",
3
+ "version": "0.4.55",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",