agentus 0.12.3 → 0.13.1

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.
package/bin/cli.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  * cross-session `send`. Dispatched by `bin/agentus.mjs`, which is also what the cockpit hands agents
5
5
  * as `AGENTUS_COCKPIT_BIN`, so there is exactly one command and one spelling everywhere.
6
6
  *
7
- * SHAPE: noun, then verb — `agentus session list|show|messages|plan|send`
7
+ * SHAPE: noun, then verb — `agentus session list|show|messages|plan|send|wake|create|stop`
8
8
  * The five things an agent needs are all about ONE object (a session), so the noun is not decoration:
9
9
  * it keeps `list` from meaning "list what?" and leaves the top level free for the two commands that
10
10
  * are NOT about a session — `capabilities` (this CLI's contract) and `skill` (its manual). The
@@ -54,10 +54,16 @@ another one
54
54
  agentus session messages <id> [--tail N]
55
55
  the last N messages, each cut to the documented size
56
56
  agentus session plan <id> the plan object the operator's card renders
57
- agentus session send <id> <text…> [--wake]
57
+ agentus session send <id> <text…> [--wake] [--interrupt] [--reply-on-done]
58
58
  deliver a message into another session (stdin if no text);
59
- --wake also starts the target when it is cold
59
+ --wake also starts the target when it is cold;
60
+ --interrupt cancels the turn it is running first (explicit)
60
61
  agentus session wake <id> start a cold session's process (same switch as send)
62
+ agentus session create <cwd> [--backend <id>] [--title <text>]
63
+ open a NEW session in that directory and answer with its row
64
+ (same switch as send — it costs one agent process)
65
+ agentus session stop <id> [--close] interrupt the turn it is running; --close stops the PROCESS
66
+ (the slot goes cold, the transcript stays)
61
67
 
62
68
  agentus capabilities the contract: commands, fields, truncation, scope, send state
63
69
  agentus skill [--path] this CLI's manual, as shipped in the package
@@ -295,7 +301,15 @@ if (cmd === "capabilities") {
295
301
  // The text is the plain words after the id — an agent writing a sentence should not have to
296
302
  // build JSON to say it. With no words, stdin is accepted: either a JSON `{"message": "…"}` (what
297
303
  // older callers sent) or the raw text.
298
- let message = args.slice(1).filter((a) => a !== "--wake").join(" ").trim();
304
+ const wantWake = args.includes("--wake");
305
+ const wantInterrupt = args.includes("--interrupt");
306
+ // #59:`--reply-on-done` 不是"抢话头",是"别让我在这儿等它跑完" —— 调用立即返回,目标回合
307
+ // 自然结束时服务端往本会话投一条【完成回推】。派活给一个要跑十分钟的会话时才用得上。
308
+ const wantReply = args.includes("--reply-on-done");
309
+ const FLAGS = new Set(["--wake", "--interrupt", "--reply-on-done"]);
310
+ const straySend = args.slice(1).filter((a) => a.startsWith("--") && !FLAGS.has(a));
311
+ if (straySend.length) fail("bad_request", `unknown flag ${straySend[0]} (usage: agentus session send <id> <text…> [--wake] [--interrupt] [--reply-on-done])`);
312
+ let message = args.slice(1).filter((a) => !FLAGS.has(a)).join(" ").trim();
299
313
  if (!message) {
300
314
  const raw = await readStdin();
301
315
  if (raw) {
@@ -313,7 +327,7 @@ if (cmd === "capabilities") {
313
327
  res = await fetch(`${EP}/api/cockpit/send`, {
314
328
  method: "POST",
315
329
  headers: headers(true),
316
- body: JSON.stringify({ to: id, message, ...(args.includes("--wake") ? { wake: true } : {}) }),
330
+ body: JSON.stringify({ to: id, message, ...(wantWake ? { wake: true } : {}), ...(wantInterrupt ? { interrupt: true } : {}), ...(wantReply ? { replyOnDone: true } : {}) }),
317
331
  });
318
332
  } catch (e) {
319
333
  fail("unreachable", `the cockpit did not answer: ${e?.message ?? e}`);
@@ -333,9 +347,17 @@ if (cmd === "capabilities") {
333
347
  data: body,
334
348
  meta: {
335
349
  schema_version: 1,
336
- note: body?.delivered_after_wake
337
- ? "the target was COLD: this call woke it (a process now exists) and then delivered. delivered means it received the message and started a turn — not that it read or agreed."
338
- : "delivered means the target agent received it and started a turn — it does NOT mean it read or agreed. Only the operator sees the reply.",
350
+ note: [
351
+ body?.delivered_after_wake
352
+ ? "the target was COLD: this call woke it (a process now exists) and then delivered."
353
+ : null,
354
+ body?.interrupted
355
+ ? "the target was BUSY: its turn was interrupted (cancelled, then the session was waited to really stop) before yours started — the words it had already produced stay in its transcript."
356
+ : null,
357
+ body?.reply_on_done
358
+ ? "reply-on-done: this call returned as soon as the target's turn STARTED. When that turn ends, the cockpit posts a 【完成回推】 notice into this session and wakes you; read the actual result with `session messages <target>`."
359
+ : "delivered means the target agent received it and started a turn — it does NOT mean it read or agreed. Only the operator sees the reply.",
360
+ ].filter(Boolean).join(" "),
339
361
  },
340
362
  });
341
363
  } else if (verb === "wake") {
@@ -364,6 +386,84 @@ if (cmd === "capabilities") {
364
386
  : "the target was already live: nothing was started.",
365
387
  },
366
388
  });
389
+ } else if (verb === "create") {
390
+ // The FIRST positional is the directory, not an id: this is the one verb that makes an object
391
+ // instead of acting on one. `~` is expanded HERE as well as server-side so the error a caller
392
+ // reads names the path it actually meant.
393
+ // Walk argv once, skipping each flag's VALUE — otherwise `create --backend hermes /tmp/x`
394
+ // reads "hermes" as the directory and then rejects the real path as an extra argument.
395
+ const VALUE_FLAGS = new Set(["--backend", "--title", "--profile", "--model"]);
396
+ let cwdArg;
397
+ for (let i = 0; i < args.length; i++) {
398
+ const a = args[i];
399
+ if (a.startsWith("--")) { if (VALUE_FLAGS.has(a)) i++; continue; }
400
+ cwdArg = a;
401
+ break;
402
+ }
403
+ if (!cwdArg) fail("bad_request", "usage: agentus session create <cwd> [--backend <id>] [--title <text>]");
404
+ const flags = { backend: undefined, title: undefined };
405
+ for (let i = 0; i < args.length; i++) {
406
+ const a = args[i];
407
+ if (a === cwdArg) continue;
408
+ if (a === "--backend" || a === "--title") {
409
+ const v = args[++i];
410
+ if (v === undefined) fail("bad_request", `${a} needs a value`);
411
+ flags[a.slice(2)] = v;
412
+ } else fail("bad_request", `unexpected argument "${a}" (usage: agentus session create <cwd> [--backend <id>] [--title <text>])`);
413
+ }
414
+ let res;
415
+ try {
416
+ res = await fetch(`${EP}/api/cockpit/create`, {
417
+ method: "POST",
418
+ headers: headers(true),
419
+ body: JSON.stringify({ cwd: cwdArg, ...flags }),
420
+ });
421
+ } catch (e) {
422
+ fail("unreachable", `the cockpit did not answer: ${e?.message ?? e}`);
423
+ }
424
+ const body = await res.json().catch(() => null);
425
+ if (!res.ok) {
426
+ // An unknown backend is only actionable if the caller is told WHICH ones exist — the same reason
427
+ // `target_cold` names `--wake` instead of being a bare prohibition.
428
+ fail(String(body?.error ?? `http_${res.status}`),
429
+ String(body?.message ?? body?.error ?? `the cockpit answered HTTP ${res.status}`),
430
+ Array.isArray(body?.backends) ? { details: { backends: body.backends } } : {});
431
+ }
432
+ out({
433
+ success: true,
434
+ data: body,
435
+ meta: {
436
+ schema_version: 1,
437
+ note: "the session exists and is starting. Read its id from data.session and send into it at once; a slot with no process yet answers cold_target unless you pass --wake.",
438
+ },
439
+ });
440
+ } else if (verb === "stop") {
441
+ if (!id) fail("bad_request", "usage: agentus session stop <id> [--close]");
442
+ const close = args.includes("--close");
443
+ const leftovers = args.slice(1).filter((a) => a !== "--close");
444
+ if (leftovers.length) fail("bad_request", `unexpected argument "${leftovers[0]}" (usage: agentus session stop <id> [--close])`);
445
+ let res;
446
+ try {
447
+ res = await fetch(`${EP}/api/cockpit/stop`, {
448
+ method: "POST",
449
+ headers: headers(true),
450
+ body: JSON.stringify(close ? { id, close: true } : { id }),
451
+ });
452
+ } catch (e) {
453
+ fail("unreachable", `the cockpit did not answer: ${e?.message ?? e}`);
454
+ }
455
+ const body = await res.json().catch(() => null);
456
+ if (!res.ok) fail(String(body?.error ?? `http_${res.status}`), String(body?.message ?? body?.error ?? `the cockpit answered HTTP ${res.status}`));
457
+ out({
458
+ success: true,
459
+ data: body,
460
+ meta: {
461
+ schema_version: 1,
462
+ note: close
463
+ ? (body?.closed ? "its agent process is stopped and the slot is cold; the transcript is untouched." : "nothing was running: the slot was already cold.")
464
+ : (body?.stopped ? "the turn was cancelled; the words it had already produced stay in the transcript." : "no turn was running — nothing was interrupted."),
465
+ },
466
+ });
367
467
  } else {
368
468
  process.stderr.write(USAGE);
369
469
  fail("bad_request", `unknown session command: ${verb ?? "(none)"}`);
@@ -99,9 +99,11 @@ cp -p packages/server/.data/agentus.sqlite \
99
99
  ```
100
100
 
101
101
  The one migration path in the project that rewrites rows in place — the legacy transcript
102
- fold — takes its own backup first with `VACUUM INTO <db>.pre-fold.bak` and gates itself on
103
- `pragma user_version`, so it runs once and is idempotent. If you see a `*.pre-fold.bak`
104
- beside the store, that fold ran; the original is inside it.
102
+ fold — takes its own backup first with `VACUUM INTO <db>.pre-fold-v<ver>-<timestamp>.bak` and
103
+ records itself in the `repairs` table, so it runs once per version and is idempotent. Each pass
104
+ version gets its own file and the newest three per pass version are kept (older ones are pruned), so
105
+ "a `.pre-fold.bak` exists" no longer means anything: read `repairs` for what ran, and pick the
106
+ backup by timestamp.
105
107
 
106
108
  Two cautions that come from real incidents:
107
109
 
@@ -112,9 +112,19 @@ accepted rather than guessed away.
112
112
  2. **The row is the truth on reads; `delta` exists only so a live turn does not re-send the whole text
113
113
  every frame.** Anything that reads the transcript (`hotwords.ts` context, the phone's notifications)
114
114
  reads rows, so it sees accumulated text for free — it used to see 40 chunks of 3.8 characters.
115
+ 2b. **Two frames are one stream only if they are close in time.** The rule is: the gap from the
116
+ previous frame is at most 2 s, the turn id (when both rows carry one) agrees, and a sentence end
117
+ only counts as a boundary when that gap is over 750 ms. The accepted cost is written down rather
118
+ than hidden: two id-less messages written less than 750 ms apart, the first ending in a full stop,
119
+ are indistinguishable from one stream and get merged — but only when both are short fragments (≤24
120
+ characters) carrying no `messageId` and no `block_key`, which is the only shape this rule applies to;
121
+ a longer second message stays its own row even at a 40 ms gap. Time is the only witness a store without
122
+ message ids has, and the alternative — the rule that shipped in v3 — cut every reply at its first
123
+ full stop.
115
124
  3. **Never fold on a guess.** Same id, or whitespace-insensitive equality, or ≥60% coverage. Everything
116
125
  else stays as it is, and the migration is logged (`[store] 历史分片已折叠:3804 行 → …`) with a
117
- one-time `VACUUM INTO` backup beside the DB (`agentus.sqlite.pre-fold.bak`).
126
+ one-time `VACUUM INTO` backup beside the DB (`agentus.sqlite.pre-fold-v<ver>-<timestamp>.bak`,
127
+ newest three kept per pass version; what actually ran is in the `repairs` table).
118
128
  4. **A page boundary must not be able to cut a message.** Page sizes are row- and byte-bounded, and the
119
129
  store guarantees a message is one row; if either changes, re-run the checks below.
120
130
 
@@ -137,3 +147,13 @@ curl -s -H "Authorization: Bearer $(cat "${AGENTUS_DATA:-$HOME/.agentus}/auth.to
137
147
  Result for `hello`: **3804 rows → 70** (10 agent messages, 15 thought blocks, 37 tool cards, 8 prompts),
138
148
  `hasOlder=false`, both markdown tables reported complete (the 2623-character reply holds its 8-row table
139
149
  in one block), and one duplicate left — the 12-character re-send described above.
150
+
151
+ ## Splitting rows an older build glued together
152
+
153
+ A re-walk cannot undo a merge: the second half's row is gone, so there is nothing to fold back out.
154
+ `scripts/repair-glued-rows.py <db> <older-backup.bak>` finds the pairs that can be **proved** glued —
155
+ both halves must appear, in that order, as message texts in an older copy of the same database, and
156
+ both must be ≤24 characters — and prints them; `--apply` rewrites them. It is a dry run unless you
157
+ pass `--apply`, and it puts the second half 5 s after the first so the corrected rule never joins
158
+ them again.
159
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentus",
3
- "version": "0.12.3",
3
+ "version": "0.13.1",
4
4
  "description": "Multi-session web cockpit for ACP-speaking coding agents (Qoder CLI / Hermes). UI has no intelligence — the agent lives in spawned CLI subprocesses.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -75,6 +75,7 @@
75
75
  "apk": "bash scripts/build-apk.sh",
76
76
  "transcript-fold": "tsx scripts/qa/transcript-fold.mts",
77
77
  "store-fold": "tsx scripts/qa/store-fold.mts",
78
+ "context-window": "tsx scripts/qa/context-window.mts",
78
79
  "live-stream": "tsx scripts/qa/live-stream.mts",
79
80
  "open-target": "tsx scripts/qa/open-target.mts",
80
81
  "rail-recent": "tsx scripts/qa/rail-recent.mts",
@@ -86,7 +87,12 @@
86
87
  "replay-window": "node scripts/qa/replay-window.mjs",
87
88
  "think-cadence": "node scripts/qa/think-cadence.mjs",
88
89
  "vad": "tsx scripts/qa/vad.mts",
89
- "vad-silero": "tsx scripts/qa/vad-silero.mts"
90
+ "vad-silero": "tsx scripts/qa/vad-silero.mts",
91
+ "tus-mood": "tsx scripts/qa/tus-mood.mts",
92
+ "tus-mode-sweep": "node scripts/qa/tus-mode-sweep.mjs",
93
+ "usage-row-sweep": "node scripts/qa/usage-row-sweep.mjs",
94
+ "call-brief-sweep": "node scripts/qa/call-brief-sweep.mjs",
95
+ "reply-on-done": "node scripts/qa/reply-on-done.mjs"
90
96
  },
91
97
  "engines": {
92
98
  "node": ">=22.5"
@@ -643,9 +643,13 @@ const agent = () => ({
643
643
  // A turn that does real work: N tool calls with a reasoning burst before each — the
644
644
  // transcript-flood case (ten calls + ten bursts). `[tools:10]` drives it with no permission
645
645
  // prompts: the point is the SHAPE of the transcript, not the approval.
646
- const many = /\[tools:(\d+)\]/.exec(text);
646
+ // `[tools:N]` (25 ms in-progress window, the real shape) or `[tools:N:ms]` for QA: a 25 ms
647
+ // window is honest but a page that misses the re-render inside it can only be blamed on the
648
+ // machine's load, so a test that needs to OBSERVE the running state widens it explicitly.
649
+ const many = /\[tools:(\d+)(?::(\d+))?\]/.exec(text);
647
650
  if (many) {
648
651
  const n = Math.min(20, Math.max(1, Number(many[1]) || 1));
652
+ const windowMs = Math.min(3000, Math.max(10, Number(many[2]) || 25));
649
653
  for (let i = 1; i <= n; i++) {
650
654
  const burst = (`step ${i}: reading the file, checking the surrounding code, then writing `
651
655
  + "the change back. This sentence is deliberately long so the reasoning block is taller "
@@ -669,7 +673,7 @@ const agent = () => ({
669
673
  status: "in_progress",
670
674
  rawInput: { step: i },
671
675
  });
672
- await sleep(25);
676
+ await sleep(windowMs);
673
677
  await send(agent._conn, sessionId, {
674
678
  sessionUpdate: "tool_call_update",
675
679
  toolCallId: id,
@@ -846,6 +850,23 @@ const agent = () => ({
846
850
  // One id per logical message, exactly like the real backend: the cockpit (and the store) fold the
847
851
  // chunks of a message by this id, so a mock that omits it would test a path nobody runs.
848
852
  const messageId = randomUUID();
853
+ // 表情通道的探针(糯米模式):提示里带 `[emo-probe]`(只有 QA sweep 会发)**并且**同一轮
854
+ // 真的收到了 `[emo:x]` 提示句,才在回复开头吐一个标签。于是「服务端那段提示词到底有没有
855
+ // 到达 agent」变成一个能观测的事实,而不是靠读服务端代码相信;别的 sweep 不发这个探针,
856
+ // 行为零变化(也不需要给子进程多开一个 env 白名单出口)。
857
+ // 提示句是**另一个内容块**(ACP resource),不在 `text` 里 —— 只看 text 会得出「服务端没注入」
858
+ // 的错误结论(这次就是这么踩的)。所以探针看的是整条 prompt 的所有块。
859
+ const allBlocks = prompt.map((p) => p.text || p.resource?.text || "").join(" ");
860
+ const probe = allBlocks.match(/\[emo-probe(?::([a-z]+))?\]/i);
861
+ if (probe && /\[emo:[a-z]+\]/.test(allBlocks)) {
862
+ // 带后缀可指定要回哪张脸([emo-probe:calm]);不带后缀保持 happy,老 sweep 不用改。
863
+ const mood = (probe[1] || "happy").toLowerCase();
864
+ await send(agent._conn, sessionId, {
865
+ sessionUpdate: "agent_message_chunk",
866
+ messageId,
867
+ content: { type: "text", text: `[emo:${mood}] ` },
868
+ });
869
+ }
849
870
  const words = `Mock echo (${effort} effort) to: "${text}". `.repeat(2).split(" ");
850
871
  for (const w of words) {
851
872
  if (s.cancelled) return { stopReason: "cancelled" };
@@ -865,6 +886,22 @@ const agent = () => ({
865
886
  messageId,
866
887
  content: { type: "text", text: MOCK_MARKDOWN },
867
888
  });
889
+ // `[think-late:N]` — 回答已经开始之后才到的思考块。真实后端会这样交错(思考 → 回答 →
890
+ // 又一段思考),而这正是「耳朵里在念回答、转录里还写着思考中」的来源。QA only:
891
+ // 没有这个探针的轮次行为零变化。
892
+ const lateThink = /\[think-late(?::(\d+))?\]/.exec(text);
893
+ if (lateThink) {
894
+ const thinkId = `th-${sessionId}-late`;
895
+ const n = Math.min(Math.max(Number(lateThink[1] ?? 2) || 2, 1), 12);
896
+ for (let i = 0; i < n; i++) {
897
+ await send(agent._conn, sessionId, {
898
+ sessionUpdate: "agent_thought_chunk",
899
+ messageId: thinkId,
900
+ content: { type: "text", text: `after the answer started, thought ${i + 1}. ` },
901
+ });
902
+ await sleep(SLOW);
903
+ }
904
+ }
868
905
  // Context gauge data, shaped like a real agent's usage_update (AionUi F-DISPLAY-07).
869
906
  // `/compress` is the agent's own command for this: honour it by actually lowering the
870
907
  // reported context, so "the button did something" is a measurable claim.
@@ -24,6 +24,28 @@ export interface HomeIsolation {
24
24
  homeDefault: string;
25
25
  }
26
26
 
27
+ /**
28
+ * Hand a backend its declared context window as argv, when that is the only way it can hear it.
29
+ *
30
+ * Qoder reports no `usage_update` and advertises no context option over ACP (issue #57), but its CLI
31
+ * takes a global `--context-window <n>`; its own log line (`context_window.restore_skipped
32
+ * reason=cli_context_window_override`) says the flag wins over the window restored from a resumed
33
+ * session. Every other backend keeps the ACP route (Hermes has `context_budget`), so this does
34
+ * nothing for them — and an arg the operator already put in the row is never duplicated.
35
+ */
36
+ export function applyContextWindow(
37
+ id: string,
38
+ spec: { kind?: string },
39
+ args: string[],
40
+ contextLimit?: number | null,
41
+ ): string[] {
42
+ const declared = typeof contextLimit === "number" && contextLimit > 0 ? Math.round(contextLimit) : null;
43
+ if (!declared) return args;
44
+ if (id !== "qoder" && spec.kind !== "qoder") return args;
45
+ if (args.some((a) => a === "--context-window" || a.startsWith("--context-window="))) return args;
46
+ return [...args, "--context-window", String(declared)];
47
+ }
48
+
27
49
  export interface BackendSpec {
28
50
  /** registry row id (M6); the builtin seeds carry theirs, spawn does not need it */
29
51
  id?: string;