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.
@@ -14,7 +14,7 @@ import type {
14
14
  RequestPermissionRequest,
15
15
  RequestPermissionResponse,
16
16
  } from "@agentclientprotocol/sdk";
17
- import { BACKENDS, buildSpawnEnv, nativePlanSourceOf, type BackendSpec } from "./backends.js";
17
+ import { applyContextWindow, BACKENDS, buildSpawnEnv, nativePlanSourceOf, type BackendSpec } from "./backends.js";
18
18
  import { forgetAgentOwner, recordAgentOwner, verifyAgentOwner } from "./agent-owner.js";
19
19
  import { thinkingOptionOf, voiceAppliesToPrompt, voiceModeBlock } from "./voice-mode.js";
20
20
  import {
@@ -186,6 +186,25 @@ export class SessionUnavailable extends Error {
186
186
  }
187
187
  }
188
188
 
189
+ /** 一条「目标回合结束后回我一声」的登记(#59)。origin 是 send 的发起方会话。 */
190
+ export interface TurnReply {
191
+ origin: string;
192
+ originTitle: string | null;
193
+ at: number;
194
+ /** The turn this registration is about. `#settleTurnReplies` only settles entries whose turn is
195
+ * this one or older, so a second `--interrupt` dispatch cannot be swallowed by the turn it killed. */
196
+ turnSeq: number | null;
197
+ }
198
+
199
+ /** A queued "wake this session with a notice" item. Two kinds share the queue but not the rules:
200
+ * `background` is the operator's 「后台任务完成唤醒」 noise, `reply` is the receipt another session
201
+ * is actively waiting for. */
202
+ interface PendingCompletion {
203
+ text: string;
204
+ kind: "background" | "reply";
205
+ origin?: { sessionId: string; title: string | null };
206
+ }
207
+
189
208
  interface LiveSession {
190
209
  info: SessionInfo;
191
210
  child?: ReturnType<typeof spawn>;
@@ -271,7 +290,7 @@ interface LiveSession {
271
290
  * dropped: the agent is the intended reader, and a completion lost to bad timing means work the
272
291
  * operator has to notice and redo by hand. Drained one at a time at turn-end — each opens its own
273
292
  * turn, and two at once is a race the agent cannot mediate. */
274
- pendingCompletions: string[];
293
+ pendingCompletions: PendingCompletion[];
275
294
  }
276
295
 
277
296
  type Emitter = (evt: ServerEvent) => void;
@@ -289,6 +308,17 @@ export class SessionManager {
289
308
  * to hibernate. Default ON — this is the CLI/gateway behaviour, and the whole point is that the
290
309
  * agent takes its own result instead of the operator noticing a stale screen. */
291
310
  #completionWake: () => boolean = () => true;
311
+ /** 跨会话 send 的「它跑完回我一声」登记(#59)。
312
+ *
313
+ * 内存而已,刻意不落库:这是一次性通知,不是数据 —— 服务端重启后那条回合早就结束了,
314
+ * 回一条「几分钟前跑完了」既没用又会把调用方凭空叫起来。目标 id → 登记项。
315
+ * `origin` 是发起方会话(send 的调用者),投递走的是 #39 那条「唤醒某会话」的链子。 */
316
+ #turnReplies = new Map<string, Map<string, TurnReply>>();
317
+ #replySeq = 0;
318
+ /** The newest turn a session has already finished. A registration that arrives with an older (or
319
+ * equal) number is describing a turn that is over: it is answered at once instead of being stored,
320
+ * because the settle that should have delivered it has already run. */
321
+ #lastSettledTurn = new Map<string, number>();
292
322
  /** The data directory this server owns, and the id of THIS run. Both come from the instance lock
293
323
  * in index.ts; together they are the only evidence that a recorded pid is an agent OF THIS STORE
294
324
  * from a PREVIOUS run — see `agent-owner.ts` and the 2026-10-22 incident. */
@@ -383,6 +413,48 @@ export class SessionManager {
383
413
  return this.#sessions.get(id)?.busy ?? false;
384
414
  }
385
415
 
416
+ /** 登记一条「目标回合自然结束后,通知这个发起方」(#59)。返回 id,用于事后撤销
417
+ * (投递本身失败、或操作者把它打断成了另一件事)。
418
+ *
419
+ * Returns the registration id; `dropTurnReply` cancels it. */
420
+ expectTurnReply(target: string, r: { origin: string; originTitle?: string | null; turnSeq?: number | null }): string {
421
+ const id = `reply-${++this.#replySeq}`;
422
+ const entry: TurnReply = {
423
+ origin: r.origin, originTitle: r.originTitle ?? null, at: Date.now(), turnSeq: r.turnSeq ?? null,
424
+ };
425
+ const settled = this.#lastSettledTurn.get(target);
426
+ if (r.turnSeq != null && settled != null && r.turnSeq <= settled) {
427
+ // That turn is already over (a prompt that threw after it started, a settle that ran before
428
+ // this bookkeeping landed). Storing it would deliver it one turn late, describing the wrong
429
+ // turn — so answer now, honestly.
430
+ // NOT a failure: the turn may have succeeded. It simply cannot be reported by this receipt.
431
+ void this.#deliverTurnReplies(target, [entry], { note: "这一轮在你登记之前就结束了" });
432
+ return id;
433
+ }
434
+ const rows = this.#turnReplies.get(target) ?? new Map<string, TurnReply>();
435
+ rows.set(id, entry);
436
+ this.#turnReplies.set(target, rows);
437
+ return id;
438
+ }
439
+
440
+ dropTurnReply(target: string, id: string): void {
441
+ const rows = this.#turnReplies.get(target);
442
+ if (!rows) return;
443
+ rows.delete(id);
444
+ if (!rows.size) this.#turnReplies.delete(target);
445
+ }
446
+
447
+ /** send 本身送不进去(抢话头没抢到、目标进程死了…):登记项不能就那么悬着,否则调用方
448
+ * 永远不知道消息根本没落地。把它当成一次「失败回推」投出去。 */
449
+ failTurnReply(target: string, id: string, why: string): void {
450
+ const rows = this.#turnReplies.get(target);
451
+ const entry = rows?.get(id);
452
+ if (!entry) return;
453
+ rows!.delete(id);
454
+ if (!rows!.size) this.#turnReplies.delete(target);
455
+ void this.#deliverTurnReplies(target, [entry], { failed: why });
456
+ }
457
+
386
458
  /** Reclaim orphans from a previous server run (AC5): kill recorded pids — but only the ones that are
387
459
  * provably agents of THIS data dir (see `agent-owner.ts`).
388
460
  *
@@ -668,6 +740,14 @@ export class SessionManager {
668
740
 
669
741
  /** Graceful shutdown: SIGTERM every live child before server exit. */
670
742
  async shutdown(): Promise<void> {
743
+ // No time to deliver these properly (we are going down), but the registrations must not outlive
744
+ // the process as a promise nobody keeps: log one line per waiter so the trace exists.
745
+ for (const [target, rows] of this.#turnReplies) {
746
+ for (const r of rows.values()) {
747
+ console.log(`[agentus] turn reply dropped on shutdown: ${target} -> ${r.origin}`);
748
+ }
749
+ }
750
+ this.#turnReplies.clear();
671
751
  const kills = [...this.#sessions.values()].map(async (s) => {
672
752
  this.#dropPending(s);
673
753
  s.child?.kill("SIGTERM");
@@ -681,11 +761,17 @@ export class SessionManager {
681
761
  * row was touched never breaks. argv comes from registry.effectiveArgs — that is where the
682
762
  * hermes profile flag (`-p <profile>`) is composed.
683
763
  */
684
- #spawnShape(id: string): { spec: BackendSpec; args: string[] } | null {
764
+ #spawnShape(id: string, contextLimit?: number | null): { spec: BackendSpec; args: string[] } | null {
685
765
  const row = this.#store.getBackend(id);
686
- if (row) return { spec: rowToSpec(row), args: effectiveArgs(row) };
687
- const builtin = BACKENDS[id];
688
- return builtin ? { spec: builtin, args: builtin.args } : null;
766
+ const shape = row
767
+ ? { spec: rowToSpec(row), args: effectiveArgs(row) }
768
+ : BACKENDS[id] ? { spec: BACKENDS[id], args: [...BACKENDS[id].args] } : null;
769
+ if (!shape) return null;
770
+ // The declared window rides argv only where that is the only channel the backend has (qoder) —
771
+ // see applyContextWindow. Deliberately not passed on create: a brand-new session has nothing
772
+ // declared yet, and resume is where a declaration first exists.
773
+ shape.args = applyContextWindow(id, shape.spec, shape.args, contextLimit);
774
+ return shape;
689
775
  }
690
776
 
691
777
  /**
@@ -784,6 +870,10 @@ export class SessionManager {
784
870
  child, busy: false, pendingPermissions: new Map(), alwaysAllow: new Set(),
785
871
  approvalSeq: new Map(), skillNoticeSeq: new Map(), stderrBuf: [],
786
872
  background: [], pendingCompletions: [],
873
+ // A rebuilt LiveSession restarts its turn counter at 0, so the "which turn has this session
874
+ // already finished" watermark no longer describes the same numbering — keeping it would make
875
+ // every registration of the new run look older than the last settled turn and be answered with
876
+ // a false 「已经结束了」.
787
877
  caps: { fork: false, load: false, image: false }, turnSeq: 0, collectors: new Map(), planReminderPending: true,
788
878
  planToken: randomUUID(),
789
879
  cockpitToken,
@@ -798,6 +888,7 @@ export class SessionManager {
798
888
  replaying: true,
799
889
  };
800
890
  this.#sessions.set(id, live);
891
+ this.#lastSettledTurn.delete(id); // the counter just went back to 0 — see the comment above
801
892
  live.info.lastAt = this.#store.lastActivityAt().get(id) ?? live.info.createdAt;
802
893
  // the home is recorded with the row: it is the thing a later resume has to be checked against
803
894
  this.#store.upsertSession(sessionRow(live.info, plan.home));
@@ -903,7 +994,19 @@ export class SessionManager {
903
994
  const row = this.#store.getSession(id);
904
995
  if (!row) throw new Error(`unknown session: ${id}`);
905
996
  if (!row.acpSessionId) throw new Error(`session ${id} was never handed to an agent`);
906
- const shape = this.#spawnShape(row.backend);
997
+ // The declared window is a DISPLAY number the operator typed; handing it to the agent is a real
998
+ // decision (Qoder's `--context-window` REPLACES the window it would restore from the resumed
999
+ // session). Too small silently truncates the history the agent keeps; above the model's real
1000
+ // window every later request fails. So it is clamped to the window remembered for this model,
1001
+ // and the effective value is logged — the panel says "takes effect on the next start", and this
1002
+ // is the line that says what actually took effect.
1003
+ const modelLimit = this.#store.getModelLimit((row.models as { currentModelId?: string | null } | null)?.currentModelId ?? null);
1004
+ const declared = row.contextLimit && row.contextLimit > 0 ? Math.round(row.contextLimit) : null;
1005
+ const effective = declared && modelLimit && modelLimit > 0 ? Math.min(declared, Math.round(modelLimit)) : declared;
1006
+ if (declared && effective !== declared) {
1007
+ console.log(`[agentus] context window for ${id} clamped ${declared} -> ${effective} (model remembers ${modelLimit})`);
1008
+ }
1009
+ const shape = this.#spawnShape(row.backend, effective);
907
1010
  if (!shape) throw new Error(`unknown backend: ${row.backend}`);
908
1011
  const { spec, args } = shape;
909
1012
 
@@ -972,6 +1075,10 @@ export class SessionManager {
972
1075
  child, busy: false, pendingPermissions: new Map(), alwaysAllow: new Set(),
973
1076
  approvalSeq: new Map(), skillNoticeSeq: new Map(), stderrBuf: [],
974
1077
  background: [], pendingCompletions: [],
1078
+ // A rebuilt LiveSession restarts its turn counter at 0, so the "which turn has this session
1079
+ // already finished" watermark no longer describes the same numbering — keeping it would make
1080
+ // every registration of the new run look older than the last settled turn and be answered with
1081
+ // a false 「已经结束了」.
975
1082
  caps: { fork: false, load: false, image: false }, turnSeq: 0, collectors: new Map(), planReminderPending: true,
976
1083
  planToken: randomUUID(),
977
1084
  cockpitToken,
@@ -986,6 +1093,7 @@ export class SessionManager {
986
1093
  replaying: true,
987
1094
  };
988
1095
  this.#sessions.set(id, live);
1096
+ this.#lastSettledTurn.delete(id);
989
1097
  live.info.lastAt = this.#store.lastActivityAt().get(id) ?? live.info.createdAt;
990
1098
  this.#store.upsertSession(sessionRow(live.info));
991
1099
 
@@ -1155,6 +1263,7 @@ export class SessionManager {
1155
1263
  // operator watched their slot flicker into the archive for a second — measured on :8901).
1156
1264
  // Mark the swap BEFORE the signal, so the exit we are causing is not read as a dead agent.
1157
1265
  live.replacing = true;
1266
+ this.#settleTurnReplies(id, live.turnSeq, { failed: "目标会话正在重启", failureKind: "turn" });
1158
1267
  this.#dropPending(live);
1159
1268
  live.busy = false;
1160
1269
  // show the swap honestly: "starting" with no pid, not a pid that is already gone
@@ -1285,6 +1394,13 @@ export class SessionManager {
1285
1394
  * something. Opaque to the server: it comes back unchanged, it is never stored.
1286
1395
  */
1287
1396
  pid?: string;
1397
+ /**
1398
+ * Called the instant this prompt takes the floor, with the turn number it owns — before the
1399
+ * agent is asked, so a caller can bind a one-shot notification to THIS turn. Registering on the
1400
+ * session instead means the notification describes whichever turn ends next: with
1401
+ * `--interrupt` that is the turn this call just killed.
1402
+ */
1403
+ onTurnStarted?: (turnSeq: number) => void;
1288
1404
  } = {},
1289
1405
  ): Promise<void> {
1290
1406
  const s = this.#need(sessionId);
@@ -1349,6 +1465,11 @@ export class SessionManager {
1349
1465
  const settleVoice = this.#applyVoiceMode(s, blocks, { operator: voiceAppliesToPrompt(opts) });
1350
1466
  s.turnSeq += 1;
1351
1467
  s.busy = true;
1468
+ // Read ONCE, here. A `--interrupt` prompt leaves `busy` before this turn ends, so `s.turnSeq` at
1469
+ // settle time can already be the NEXT turn's number — settling "up to" that number would mark a
1470
+ // turn that has only just started as finished (and reject its own registration).
1471
+ const mine = s.turnSeq;
1472
+ try { opts.onTurnStarted?.(mine); } catch { /* a bookkeeping callback must never break a turn */ }
1352
1473
  // The operator has spoken: from here anything the agent says is an ANSWER, not its own history
1353
1474
  // being replayed (see the replay window in #onSessionUpdate).
1354
1475
  s.replaying = false;
@@ -1392,10 +1513,13 @@ export class SessionManager {
1392
1513
  // #touch.
1393
1514
  this.#touch(s);
1394
1515
  this.#emit({ t: "turn-end", sessionId, stopReason: res?.stopReason, durationMs: Date.now() - turnStartedAt });
1516
+ // #59: 谁在等这一轮结束(跨会话 send --reply-on-done)
1517
+ this.#settleTurnReplies(sessionId, mine, { stopReason: res?.stopReason ?? null, durationMs: Date.now() - turnStartedAt });
1395
1518
  } catch (err) {
1396
1519
  this.#finalizePlan(sessionId, "failed");
1397
1520
  this.#touch(s);
1398
1521
  this.#emit({ t: "turn-end", sessionId, error: errMessage(err), durationMs: Date.now() - turnStartedAt });
1522
+ this.#settleTurnReplies(sessionId, mine, { failed: errMessage(err), failureKind: "turn", durationMs: Date.now() - turnStartedAt });
1399
1523
  } finally {
1400
1524
  s.busy = false;
1401
1525
  if (s.info.status === "running") {
@@ -1417,32 +1541,103 @@ export class SessionManager {
1417
1541
  * with the operator's switch off we say so in the log and drop it (that is the switch's meaning), and
1418
1542
  * while a turn is running we hold it, because a prompt would otherwise interrupt work the operator
1419
1543
  * started. */
1420
- #queueOrWakeCompletion(s: LiveSession, text: string): void {
1421
- if (!text) return;
1422
- if (!this.#completionWake()) {
1544
+ #queueOrWakeCompletion(s: LiveSession, item: PendingCompletion): void {
1545
+ if (!item.text) return;
1546
+ // 开关关掉的是「后台任务跑完了把我叫醒」,那是噪音;跨会话派活的回推是调用方正在等的回执,
1547
+ // 一并关掉就是让它在另一头永远等下去(而它已经收到 delivered: true 了)。
1548
+ if (item.kind === "background" && !this.#completionWake()) {
1423
1549
  console.log(`[agentus] background completion dropped for ${s.info.id}: 唤醒关闭`);
1424
1550
  return;
1425
1551
  }
1426
1552
  if (s.busy) {
1427
- s.pendingCompletions.push(text);
1553
+ s.pendingCompletions.push(item);
1428
1554
  return;
1429
1555
  }
1430
- void this.#wakeWithCompletion(s, text);
1556
+ void this.#wakeWithCompletion(s, item);
1431
1557
  }
1432
1558
 
1433
- async #wakeWithCompletion(s: LiveSession, text: string): Promise<void> {
1559
+ async #wakeWithCompletion(s: LiveSession, item: PendingCompletion): Promise<void> {
1434
1560
  try {
1435
- // `origin` so the page labels the bubble as a background notice instead of presenting it as
1436
- // something the operator typed — the same mechanism the cockpit's cross-session `send` uses.
1437
- await this.prompt(s.info.id, text, [], { origin: { sessionId: "background", title: "后台任务完成" } });
1561
+ // `origin` so the page labels the bubble as a notice instead of presenting it as something the
1562
+ // operator typed — the same mechanism the cockpit's cross-session `send` uses. A turn reply
1563
+ // names the session that finished rather than "background", so the receiving agent can tell a
1564
+ // peer's receipt from a log tail.
1565
+ await this.prompt(s.info.id, item.text, [], {
1566
+ origin: item.origin ?? { sessionId: "background", title: "后台任务完成" },
1567
+ });
1438
1568
  } catch (err) {
1439
1569
  // Lost a race for the floor (an operator prompt landed first): keep it for the next turn end
1440
1570
  // instead of throwing away the agent's own result.
1441
- s.pendingCompletions.unshift(text);
1571
+ s.pendingCompletions.unshift(item);
1442
1572
  console.log(`[agentus] completion held for ${s.info.id}: ${errMessage(err)}`);
1443
1573
  }
1444
1574
  }
1445
1575
 
1576
+ /** 把「目标跑完了」投给发起方(#59)。走的是 #39 同一条链:目标冷着就 resume,忙就挂到
1577
+ * 它下一回合结束时再投 —— 回推是**一条新消息**,agent 因此被叫醒,而不是我们自己伪造一帧。
1578
+ *
1579
+ * `failed` 存在时说明消息根本没送进目标(而不是目标跑完了)。 */
1580
+ async #deliverTurnReplies(
1581
+ target: string,
1582
+ entries: (TurnReply & { id?: string })[],
1583
+ outcome: { failed?: string; failureKind?: "send" | "turn"; note?: string; durationMs?: number; stopReason?: string | null } = {},
1584
+ ): Promise<void> {
1585
+ if (!entries.length) return;
1586
+ const title = this.#store.getSession(target)?.title ?? target.slice(0, 8);
1587
+ const secs = outcome.durationMs ? Math.round(outcome.durationMs / 1000) : null;
1588
+ for (const e of entries) {
1589
+ const text = outcome.note
1590
+ ? `【完成回推】「${title}」${outcome.note}。结果以 session messages ${target} 为准。`
1591
+ : outcome.failed
1592
+ ? outcome.failureKind === "turn"
1593
+ ? `【完成回推】「${title}」这一轮跑失败了:${outcome.failed}。消息送到了,别重发。`
1594
+ : `【完成回推】给「${title}」的消息没送进去:${outcome.failed}。它这一轮不会跑,别等了。`
1595
+ : outcome.stopReason === "cancelled"
1596
+ ? `【完成回推】「${title}」这一轮被取消了(${secs ?? "?"} 秒)。结果以 session messages 为准。`
1597
+ : `【完成回推】「${title}」这一轮跑完了${secs != null ? `(${secs} 秒)` : ""}。`
1598
+ + `要看内容用 session messages ${target};这条只是通知,正文不在这里。`;
1599
+ // 自己发给自己的那种派活不回推:那就是一次普通发言,回一条「你跑完了」会变成乒乓。
1600
+ if (e.origin === target) continue;
1601
+ const live = this.#sessions.get(e.origin);
1602
+ if (live) {
1603
+ this.#queueOrWakeCompletion(live, { text, kind: "reply", origin: { sessionId: target, title } });
1604
+ continue;
1605
+ }
1606
+ // 发起方已经冷了:和 `--wake` 同一个动作(起进程),起来以后再投。
1607
+ try {
1608
+ await this.resume(e.origin);
1609
+ const back = this.#sessions.get(e.origin);
1610
+ if (back) this.#queueOrWakeCompletion(back, { text, kind: "reply", origin: { sessionId: target, title } });
1611
+ } catch (err) {
1612
+ console.log(`[agentus] turn reply dropped for ${e.origin}: ${errMessage(err)}`);
1613
+ }
1614
+ }
1615
+ }
1616
+
1617
+ /** 一个回合结束了 —— 有登记就投。只在这里汇总,散落在三处 `busy = false` 的写法会互相漏。 */
1618
+ #settleTurnReplies(
1619
+ target: string,
1620
+ upToTurn: number | null,
1621
+ outcome: { failed?: string; failureKind?: "send" | "turn"; durationMs?: number; stopReason?: string | null },
1622
+ ): void {
1623
+ if (upToTurn !== null) {
1624
+ const before = this.#lastSettledTurn.get(target);
1625
+ if (before === undefined || upToTurn > before) this.#lastSettledTurn.set(target, upToTurn);
1626
+ }
1627
+ const rows = this.#turnReplies.get(target);
1628
+ if (!rows || !rows.size) return;
1629
+ const entries: (TurnReply & { id?: string })[] = [];
1630
+ for (const [id, r] of [...rows.entries()]) {
1631
+ // 只结算「这一回合及更早」的登记。后来的登记属于后面的回合 —— 把它一起吞掉,调用方就再也
1632
+ // 收不到回执(这正是 --interrupt 派活时会发生的事:被取消的那一轮结束时把人家的登记吃掉了)。
1633
+ if (upToTurn !== null && r.turnSeq !== null && r.turnSeq > upToTurn) continue;
1634
+ rows.delete(id);
1635
+ entries.push({ id, ...r });
1636
+ }
1637
+ if (!rows.size) this.#turnReplies.delete(target);
1638
+ void this.#deliverTurnReplies(target, entries, outcome);
1639
+ }
1640
+
1446
1641
  #drainHeldCompletion(s: LiveSession): void {
1447
1642
  if (!s.pendingCompletions.length || s.busy) return;
1448
1643
  const next = s.pendingCompletions.shift();
@@ -1450,9 +1645,13 @@ export class SessionManager {
1450
1645
  setTimeout(() => void this.#wakeWithCompletion(s, next), 0).unref?.();
1451
1646
  }
1452
1647
 
1453
- async cancel(sessionId: string): Promise<void> {
1454
- const s = this.#need(sessionId);
1455
- if (!s.conn || !s.info.acpSessionId) return;
1648
+ /** @returns whether a turn was actually interrupted — `false` for an idle/absent slot, so a caller
1649
+ * (the cockpit CLI's `session stop`) can tell the operator "nothing was running" instead of
1650
+ * reporting a stop that never happened. */
1651
+ async cancel(sessionId: string): Promise<boolean> {
1652
+ const s = this.#sessions.get(sessionId);
1653
+ if (!s) return false;
1654
+ if (!s.conn || !s.info.acpSessionId) return false;
1456
1655
  // An idle session must NOT be cancelled. On the agent side this is not a no-op: Hermes' ACP
1457
1656
  // `session/cancel` sets the hard-interrupt flag unconditionally (`request_hard_interrupt`) and
1458
1657
  // clears it only at a turn boundary, so the NEXT turn aborts before it starts — at its
@@ -1463,10 +1662,11 @@ export class SessionManager {
1463
1662
  // barge-in's cancel landed on an idle session.
1464
1663
  if (!s.busy) {
1465
1664
  console.log(`[agentus] cancel ignored for ${sessionId}: no turn is running`);
1466
- return;
1665
+ return false;
1467
1666
  }
1468
1667
  console.log(`[agentus] cancel sent for ${sessionId}`);
1469
1668
  await s.conn.cancel({ sessionId: s.info.acpSessionId }).catch(() => {});
1669
+ return true;
1470
1670
  }
1471
1671
 
1472
1672
  /**
@@ -1964,6 +2164,10 @@ export class SessionManager {
1964
2164
  // lands and `error` (with `lastError`) when it fails — and a handshake that failed on its own has a
1965
2165
  // better answer than 「closed」, so it keeps it.
1966
2166
  if (live.info.status !== "error") live.info.status = why === "reaped" ? "reaped" : "closed";
2167
+ // Anyone waiting for this session's turn gets an answer: the child is about to be signalled and
2168
+ // #onChildExit will bail out early (the slot is deleted below), so this is the last moment that
2169
+ // can tell a dispatcher "it is not coming" instead of leaving it waiting forever.
2170
+ this.#settleTurnReplies(live.info.id, live.turnSeq, { failed: "目标会话被关闭了", failureKind: "turn" });
1967
2171
  live.info.pid = null;
1968
2172
  live.child?.kill("SIGTERM");
1969
2173
  const row = this.#store.getSession(live.info.id);
@@ -2240,7 +2444,7 @@ export class SessionManager {
2240
2444
  }
2241
2445
  const finishedText = completionTextOf(u);
2242
2446
  if (finishedText) {
2243
- this.#queueOrWakeCompletion(live, finishedText);
2447
+ this.#queueOrWakeCompletion(live, { text: finishedText, kind: "background" });
2244
2448
  return;
2245
2449
  }
2246
2450
  // Project skills the agent is NOT loading (fork: `_meta.hermes = {kind:"project_skills"}`).
@@ -2407,7 +2611,7 @@ export class SessionManager {
2407
2611
  // is ONE row, not 1259. The client gets the row plus the part that is genuinely new, so a live
2408
2612
  // turn stays a small delta on the wire while the store stays the source of truth.
2409
2613
  if (msg.kind === "agent" || msg.kind === "thought") {
2410
- const r = this.#store.appendTextChunk({ ...msg, kind: msg.kind });
2614
+ const r = this.#store.appendTextChunk({ ...msg, kind: msg.kind }, live.turnSeq);
2411
2615
  if (r.delta || r.isNew) {
2412
2616
  // A row that did NOT move is not activity. Neither is one that DID move — a long reply arrives
2413
2617
  // as dozens of chunks, and touching per chunk re-sorted the rail on every token (issue #50:
@@ -2501,6 +2705,8 @@ export class SessionManager {
2501
2705
  this.#emit({ t: "background", sessionId: live.info.id, tasks: [] });
2502
2706
  }
2503
2707
  live.pendingCompletions = [];
2708
+ // #59:等这一轮的调用方不能永远等下去 —— agent 死了,那一轮不会有结尾
2709
+ this.#settleTurnReplies(live.info.id, live.turnSeq, { failed: "agent 进程退出了", failureKind: "turn" });
2504
2710
  live.info.status = "error";
2505
2711
  live.info.lastError = `agent process exited (code=${code} sig=${sig})` + stderrTail(live);
2506
2712
  const row = this.#store.getSession(live.info.id);
@@ -6,6 +6,9 @@
6
6
  //
7
7
  // · the three-state lifecycle (enter → constraint every turn → exit) as a pure decision, so the
8
8
  // trickiest part — WHICH block is owed on THIS prompt — is testable without an agent;
9
+ // · the emotion tag (`[emo:x]`): the face on the call screen can only follow the agent if the
10
+ // agent knows the channel exists. The vocabulary lives in shared/ so the prompt and the parser
11
+ // can never disagree about which words are real;
9
12
  // · the thinking-depth lookup, because a call is full-duplex and a 20-second first token is
10
13
  // indistinguishable from a hang-up.
11
14
  //
@@ -15,6 +18,8 @@
15
18
  /** The `_meta` key that marks a block as OURS — never the operator's words. */
16
19
  export const VOICE_META_KEY = "agentus/voice-mode";
17
20
 
21
+ import { emoTagHint } from "../../../shared/src/index.js";
22
+
18
23
  export type VoiceBoundary = "enter" | "exit";
19
24
 
20
25
  /** What the store remembers about one session's call state. */
@@ -31,11 +36,12 @@ export const VOICE_ENTER_TEXT = [
31
36
  "现在开始语音通话:你接下来的回复会被 TTS 朗读出来,用户是用耳朵听的。",
32
37
  "请用口语化的短句回答(几句话以内),不要表格、代码块、列表符号、链接和 Markdown 标记;",
33
38
  "需要列举时用「第一/第二」这样的口语说法。数字、专有名词、路径要念得出来。",
39
+ emoTagHint(),
34
40
  ].join("");
35
41
 
36
42
  /** 通话中每一轮都带:单轮指令会被后续对话冲淡,也会被压缩摘要吃掉。 */
37
43
  export const VOICE_STEADY_TEXT =
38
- "(仍在语音通话中:继续用口语短句回答,不要表格、代码块、列表符号。)";
44
+ "(仍在语音通话中:继续用口语短句回答,不要表格、代码块、列表符号。)" + emoTagHint();
39
45
 
40
46
  /** 退出通话:不只说「结束了」,还要解释刚才那段的性质 —— 否则模型会接着用通话腔回文字提问。 */
41
47
  export function voiceExitText(turns: number): string {
@@ -101,8 +101,10 @@ export function capabilities(sendEnabled: boolean): Record<string, unknown> {
101
101
  { name: "session show", args: ["<id>"], returns: "one session in detail, including any approval it is waiting on" },
102
102
  { name: "session messages", args: ["<id>", "--tail N"], returns: `the last N messages (default ${MESSAGES_TAIL_DEFAULT}, max ${MESSAGES_TAIL_MAX}), each cut to ${MESSAGE_CHARS} chars` },
103
103
  { name: "session plan", args: ["<id>"], returns: "the plan object the operator's card renders (title/items/explanation/terminal)" },
104
- { name: "session send", args: ["<id>", "<text…>", "--wake"], returns: "deliver a message into another session (text after the id, or on stdin). A COLD target answers cold_target unless you pass --wake" },
104
+ { name: "session send", args: ["<id>", "<text…>", "--wake", "--interrupt", "--reply-on-done"], returns: "deliver a message into another session (text after the id, or on stdin). A COLD target answers cold_target unless you pass --wake. A BUSY target answers target_busy unless you pass --interrupt, which cancels the running turn, waits for it to really stop and then starts yours — the answer then carries interrupted:true. Interrupting is never implicit: it takes a turn away from whoever started it. --reply-on-done answers AT ONCE (reply_on_done:true) instead of waiting for the target's turn to finish, and when that turn ends naturally the cockpit drops a 【完成回推】 notice into YOUR session and wakes you — so you can hand out work and stop polling session messages; the notice carries the duration and the target id, NOT the answer body, which you still read with session messages <id>. An interrupted or failed target turn reports that too. Combine with --interrupt to take the floor AND get told when it is done" },
105
105
  { name: "session wake", args: ["<id>"], returns: "start a cold session's agent process — the same thing the operator's rail does when they open it; the session then accepts send. Gated by the same 驾驶舱互发消息 switch as send" },
106
+ { name: "session create", args: ["<cwd>", "--backend <id>", "--title <text>"], returns: "create a session in <cwd> and answer with its row (id included), so the caller can send into it at once. --backend defaults to the first registered backend; ~ is expanded and a path that is not a directory answers bad_cwd. Gated by the same 驾驶舱互发消息 switch as send — a create costs one agent process, so do it for work you actually need" },
107
+ { name: "session stop", args: ["<id>", "--close"], returns: "interrupt the turn the target is running (default: the session stays alive and keeps its transcript) — answers stopped:true, or stopped:false when nothing was running. --close stops the agent PROCESS instead (the slot becomes cold, the transcript stays); a cold target answers closed:false. Gated by the same 驾驶舱互发消息 switch as send" },
106
108
  ],
107
109
  truncation: {
108
110
  messages: `each message's text is cut to ${MESSAGE_CHARS} characters; a cut one ends with … and reports truncated:true`,
@@ -822,20 +822,144 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
822
822
  return send(res, 409, { error: COCKPIT_ERRORS.wakeFailed, message: String((e as Error)?.message ?? e) });
823
823
  }
824
824
  }
825
- // Deliberately NOT `interrupt`: a cross-session message must never take the floor from a turn
826
- // the OPERATOR started. A busy target answers `target_busy` and the caller reports it as such.
825
+ // NOT interrupt by DEFAULT: a cross-session message must never take the floor from a turn the
826
+ // OPERATOR started — a busy target answers `target_busy` and the caller reports it as such.
827
+ // #55: the caller may ASK for the floor (`--interrupt`), which reuses prompt()'s existing
828
+ // cancel → wait-until-really-stopped → new-turn path. The抢话头 is therefore always explicit
829
+ // and always visible in the command that did it; nothing on this channel interrupts silently.
830
+ const wantInterrupt = body.interrupt === true;
831
+ // #59:`replyOnDone` = 「它跑完回我一声」。这是一条**异步派活**:调用方不该被目标回合绑架
832
+ // (普通 send 今天会一直等到回合结束才返回,几十秒到几十分钟),所以带这个标志时我们
833
+ // 只等「消息进了目标的回合队列」,然后把回合结束那一刻的通知登记下来,立即回执。
834
+ // 之后目标回合自然结束 → 服务端往调用方会话里投一条【完成回推】并把它唤醒(#39 的链子)。
835
+ const wantReply = body.replyOnDone === true;
836
+ if (wantReply && !caller) {
837
+ return send(res, 400, {
838
+ error: COCKPIT_ERRORS.badRequest,
839
+ message: "reply-on-done 只能由另一个会话发起(要有一个可以回推的 origin);操作者模式下没人接这条通知",
840
+ });
841
+ }
842
+ // 忙的目标照旧拒收:带 --reply-on-done 不等于可以插队,抢话头仍然只有 --interrupt 一条路。
843
+ if (wantReply && mgr.isBusy(to) && !wantInterrupt) {
844
+ return send(res, 409, { error: COCKPIT_ERRORS.targetBusy });
845
+ }
846
+ if (wantReply && caller === to) {
847
+ return send(res, 400, {
848
+ error: COCKPIT_ERRORS.badRequest,
849
+ message: "reply-on-done 不能发给自己:那是一次普通发言,回一条「你跑完了」只会变成乒乓",
850
+ });
851
+ }
852
+ // 登记必须绑在**这一轮**上,而不是绑在会话上:先登记再 prompt,被 --interrupt 干掉的那一轮
853
+ // 结束时会把后来的登记一起吞掉,调用方就永远收不到回执(review 实测的形状)。所以登记在
854
+ // 「prompt 真的拿到话筒」那一刻发生,并带上它自己那一轮的编号。
855
+ let turnStartedSeq: number | null = null;
856
+ let onTurnStarted: ((seq: number) => void) | null = null;
857
+ const turnStarted = new Promise<void>((resolve) => { onTurnStarted = (seq) => { turnStartedSeq = seq; resolve(); }; });
858
+ let promptErr: unknown = null;
827
859
  try {
828
860
  // `origin` is not decoration: without it the target's page shows this as a message the
829
861
  // OPERATOR typed. With it the bubble says which session it came from.
830
- await mgr.prompt(to, message, [], {
831
- interrupt: false,
862
+ const turn = mgr.prompt(to, message, [], {
863
+ interrupt: wantInterrupt,
832
864
  origin: caller ? { sessionId: caller, title: rows.find((s) => s.id === caller)?.title ?? null } : undefined,
865
+ ...(wantReply ? { onTurnStarted: (seq: number) => onTurnStarted?.(seq) } : {}),
833
866
  });
867
+ if (wantReply) {
868
+ // 只等到「回合真的开始了」——那正是这个标志要消灭的那段长等待之外的全部代价。
869
+ // 回合启动前的失败(没抢到话筒、目标进程死了、空 prompt)必须报失败,不能回 delivered。
870
+ const started = await Promise.race([
871
+ turnStarted.then(() => true),
872
+ turn.then(() => false, (e) => { promptErr = e; return false; }),
873
+ ]);
874
+ if (!started) {
875
+ const why = String((promptErr as Error)?.message ?? promptErr ?? "turn did not start");
876
+ return send(res, 409, { error: why.includes("turn already running") ? COCKPIT_ERRORS.targetBusy : why });
877
+ }
878
+ const replyId = mgr.expectTurnReply(to, {
879
+ origin: caller!,
880
+ originTitle: rows.find((s) => s.id === caller)?.title ?? null,
881
+ turnSeq: turnStartedSeq,
882
+ });
883
+ // 回合结束会由 #settleTurnReplies 投递。这里兜的是「回合开过之后 prompt 才抛错」——
884
+ // 那条路径的 settle 可能早于本次登记(登记是微任务之后才落地的),所以不能靠它:
885
+ // 明确投一条失败回推,否则调用方等一个永远不会来的回执。
886
+ void turn.catch((e) => mgr.failTurnReply(to, replyId, String((e as Error)?.message ?? e)));
887
+ return send(res, 200, {
888
+ delivered: true, to, code: "delivered", reply_on_done: true,
889
+ ...(woke ? { delivered_after_wake: true } : {}),
890
+ ...(wantInterrupt ? { interrupted: true } : {}),
891
+ });
892
+ }
893
+ await turn;
834
894
  } catch (e) {
835
895
  const why = String((e as Error)?.message ?? e);
836
896
  return send(res, 409, { error: why.includes("turn already running") ? COCKPIT_ERRORS.targetBusy : why });
837
897
  }
838
- return send(res, 200, { delivered: true, to, code: "delivered", ...(woke ? { delivered_after_wake: true } : {}) });
898
+ return send(res, 200, {
899
+ delivered: true, to, code: "delivered",
900
+ ...(woke ? { delivered_after_wake: true } : {}),
901
+ ...(wantInterrupt ? { interrupted: true } : {}),
902
+ });
903
+ }
904
+ if (url.pathname === "/api/cockpit/create" && req.method === "POST") {
905
+ const body = await readJson(req);
906
+ if (!sendOk) return send(res, 403, { error: COCKPIT_ERRORS.sendsDisabled });
907
+ const backendRaw = String(body.backend ?? "").trim();
908
+ // Same resolution as the operator's own "+" (POST /api/sessions): a registry row wins, a
909
+ // builtin id still resolves. An empty backend means "the one this cockpit would default to",
910
+ // which is what a caller that does not care should get instead of an error.
911
+ const beRow = backendRaw ? store.getBackend(backendRaw) : null;
912
+ const backend = backendRaw
913
+ ? (beRow || Object.prototype.hasOwnProperty.call(BACKENDS, backendRaw) ? (backendRaw as BackendId) : null)
914
+ : (store.listBackends()[0]?.id as BackendId | undefined) ?? null;
915
+ if (!backend) {
916
+ return send(res, 400, {
917
+ error: "unknown backend",
918
+ message: `unknown backend: ${backendRaw}`,
919
+ backends: store.listBackends().map((b) => b.id),
920
+ });
921
+ }
922
+ let cwd = String(body.cwd ?? "").trim();
923
+ if (!cwd || cwd === "~") cwd = homedir();
924
+ else if (cwd.startsWith("~/")) cwd = path.join(homedir(), cwd.slice(2));
925
+ if (!cwd) return send(res, 400, { error: "bad_request", message: "cwd is required: which directory should the new session work in?" });
926
+ if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) {
927
+ return send(res, 400, { error: "bad_cwd", message: `cwd not a directory: ${cwd}` });
928
+ }
929
+ try {
930
+ const info = await mgr.create(backend, cwd, body.title ? String(body.title) : undefined);
931
+ emit(sessionsEvent());
932
+ // The same row shape `session show` answers with, so a caller can use the new id at once.
933
+ return send(res, 201, { schema_version: COCKPIT_SCHEMA_VERSION, session: summarize(info) });
934
+ } catch (e) {
935
+ return send(res, 409, { error: "create_failed", message: String((e as Error)?.message ?? e) });
936
+ }
937
+ }
938
+ if (url.pathname === "/api/cockpit/stop" && req.method === "POST") {
939
+ const body = await readJson(req);
940
+ const id = String(body.id ?? "");
941
+ if (!id) return send(res, 400, { error: COCKPIT_ERRORS.badRequest });
942
+ // Stopping is as costly as starting (it throws away a turn in flight), so it answers to the
943
+ // same switch. `close` is the only command in this channel that ends a PROCESS; when the
944
+ // target is already cold there is nothing to end and the answer says so rather than
945
+ // pretending. Deliberately NOT DELETE /api/sessions/:id: for a cold row that route PURGES the
946
+ // transcript, and no cross-session caller gets to delete the operator's history.
947
+ if (!sendOk) return send(res, 403, { error: COCKPIT_ERRORS.sendsDisabled });
948
+ const target = rowOf(id);
949
+ if (!target) return send(res, 404, { error: COCKPIT_ERRORS.outOfScope });
950
+ if (body.close === true) {
951
+ if (target.cold) return send(res, 200, { schema_version: COCKPIT_SCHEMA_VERSION, id, closed: false, already_cold: true });
952
+ await mgr.closeSession(id);
953
+ emit(sessionsEvent());
954
+ return send(res, 200, { schema_version: COCKPIT_SCHEMA_VERSION, id, closed: true });
955
+ }
956
+ const stopped = await mgr.cancel(id);
957
+ return send(res, 200, {
958
+ schema_version: COCKPIT_SCHEMA_VERSION,
959
+ id,
960
+ stopped,
961
+ ...(stopped ? {} : { message: "no turn was running — nothing to interrupt" }),
962
+ });
839
963
  }
840
964
  if (url.pathname === "/api/cockpit/wake" && req.method === "POST") {
841
965
  const body = await readJson(req);