agentus 0.11.1 → 0.11.3

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/README.md CHANGED
@@ -57,7 +57,7 @@ background process comes back as a turn of its own), an **ACP session that write
57
57
  cockpit-side failure is diagnosable afterwards), and a **DeepSeek web backend** (search +
58
58
  extract on the model key) — come from the fork's integration build instead. That is a five-line install:
59
59
  [§ Which Hermes to run](docs/installation.md#which-hermes-to-run) — currently the tag
60
- `agentus/v0.22.0/merge-20261023-0540`, on Hermes `v0.22.0`.
60
+ `agentus/v0.22.0/merge-20261023-0607`, on Hermes `v0.22.0`.
61
61
 
62
62
  The first slot you start will use the Hermes home **your own agent already uses**
63
63
  (`~/.hermes`) unless the backend row names another one — see "HERMES_HOME" below.
package/README.zh-CN.md CHANGED
@@ -51,7 +51,7 @@ web bundle、配套 APK —— 然后直接退出,不启动任何东西)。
51
51
  以及 **DeepSeek 的 web 后端**(搜索 + 正文抓取,共用模型 key)—— 来自 fork 的集成构建(tag)。
52
52
  装法就五行:
53
53
  [§ 该跑哪份 Hermes](docs/installation.zh-CN.md#该跑哪份-hermes) —— 当前是 tag
54
- `agentus/v0.22.0/merge-20261023-0540`,基座 Hermes `v0.22.0`。
54
+ `agentus/v0.22.0/merge-20261023-0607`,基座 Hermes `v0.22.0`。
55
55
 
56
56
  你启动的第一个 slot 会使用**你自己的 agent 已经在用的**那个 Hermes home(`~/.hermes`),除非后端那一行
57
57
  指定了另一个 —— 见下面的「HERMES_HOME」。
@@ -60,7 +60,7 @@ ref and gets rebuilt, while a tag never moves — so pin the tag. The `agentus/`
60
60
  ours apart from upstream's own tags (`v0.22.0`, `v0.22.0+canary.*`), and the timestamp in the
61
61
  last segment sorts, so "newest" is a `sort -V`.
62
62
 
63
- **Current integration build: `agentus/v0.22.0/merge-20261023-0540`** — the newest tag on the fork,
63
+ **Current integration build: `agentus/v0.22.0/merge-20261023-0607`** — the newest tag on the fork,
64
64
  built on Hermes `v0.22.0`. This line is for orientation only, not the source of truth: the
65
65
  `TAG=$(…)` step below resolves the newest one on every run, so read it there if this sentence
66
66
  ever looks stale.
@@ -52,7 +52,7 @@ agentus/<hermes 内部版本>/merge-<YYYYMMDD-HHMM>
52
52
  tag。`agentus/` 这个前缀也把它和上游自己的 tag(`v0.22.0`、`v0.22.0+canary.*`)分开;最后一段是时间戳,
53
53
  可排序,所以「最新」就是一次 `sort -V`。
54
54
 
55
- **当前集成构建:`agentus/v0.22.0/merge-20261023-0540`** —— fork 上最新的那个 tag,基座 Hermes
55
+ **当前集成构建:`agentus/v0.22.0/merge-20261023-0607`** —— fork 上最新的那个 tag,基座 Hermes
56
56
  `v0.22.0`。这行只为一眼看个大概,不是权威:下面的 `TAG=$(…)` 每次都会自己解析出最新,觉得这行过期就
57
57
  以它为准。
58
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentus",
3
- "version": "0.11.1",
3
+ "version": "0.11.3",
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": {
@@ -320,6 +320,8 @@ export class SessionManager {
320
320
  #callLive: () => string | null = () => null;
321
321
  /** Serialises call-state transitions (see syncCallState). */
322
322
  #callSync: Promise<void> = Promise.resolve();
323
+ /** Per-session call generation (see #callGen). */
324
+ #callGens = new Map<string, number>();
323
325
  setCallLiveSource(read: () => string | null): void {
324
326
  this.#callLive = read;
325
327
  }
@@ -470,6 +472,7 @@ export class SessionManager {
470
472
  }
471
473
 
472
474
  async #beginCall(id: string): Promise<void> {
475
+ this.#bumpCallGen(id);
473
476
  const prev = this.#store.getVoiceMode(id);
474
477
  if (prev.inCall) return; // idempotent: a second presence beat for the same call changes nothing
475
478
  if (!this.#store.getSession(id)) return; // a call with a session this store has never seen
@@ -482,7 +485,10 @@ export class SessionManager {
482
485
  this.#store.upsertVoiceMode(id, {
483
486
  inCall: true, boundary: "enter", turns: 0,
484
487
  thoughtBefore: before,
485
- thoughtTouched: prev.thoughtBefore ? prev.thoughtTouched : false,
488
+ // A new call starts untouched even if the PREVIOUS call ended with the operator's own pick
489
+ // still recorded: that pick was already honoured (by not restoring it), and inheriting the
490
+ // flag here would skip the lowering for this whole call (review round 2, A-m2).
491
+ thoughtTouched: false,
486
492
  });
487
493
  // A call is full-duplex: waiting 20 s for a first token reads as a dead line to the operator.
488
494
  // Lower the dial for the call and put the operator's own choice back on hang-up (see #restoreThought).
@@ -495,6 +501,7 @@ export class SessionManager {
495
501
  }
496
502
 
497
503
  async #endCall(id: string): Promise<void> {
504
+ this.#bumpCallGen(id);
498
505
  const v = this.#store.getVoiceMode(id);
499
506
  // Nothing was actually said (no prompt ever rode this call): owe nothing. An exit block here
500
507
  // would tell the agent "the conversation above was spoken", which is false — and it is cheap to
@@ -525,16 +532,44 @@ export class SessionManager {
525
532
  } catch {
526
533
  return false;
527
534
  }
535
+ // A new call may have started while this round-trip was in flight: it needs the snapshot, so
536
+ // leave it alone (the new call will restore it on ITS hang-up).
537
+ if (this.#store.getVoiceMode(id).inCall) return false;
538
+ // `setConfig` reports a rejected value by emitting, not by throwing: verify against what the
539
+ // agent actually advertises before declaring the level restored (review round 2, A-m1). The
540
+ // knob must still be the SAME one (a model/mode switch changes the list) and an answer without
541
+ // `currentValue` proves nothing — in both cases keep the snapshot rather than silently staying
542
+ // on the call level (review r3 A-③).
543
+ const now = thinkingOptionOf(s.info.configOptions);
544
+ if (!now || now.id !== choice.id || !now.current) return false;
545
+ if (now.current !== v.thoughtBefore) return false;
528
546
  this.#store.upsertVoiceMode(id, { thoughtBefore: null, thoughtTouched: false });
529
547
  return true;
530
548
  }
531
549
 
550
+ /** Monotonic per-session call generation: every begin/end is a new one. Used to make sure a
551
+ * settle() only touches the call it was created in (review r3 A-②). */
552
+ #callGen(id: string): number {
553
+ return this.#callGens.get(id) ?? 0;
554
+ }
555
+
556
+ #bumpCallGen(id: string): void {
557
+ this.#callGens.set(id, (this.#callGens.get(id) ?? 0) + 1);
558
+ }
559
+
560
+ /** Run a restore on the call-state chain instead of behind its back. */
561
+ #queueRestore(id: string): void {
562
+ this.#callSync = this.#callSync
563
+ .then(() => this.#restoreThought(id))
564
+ .then(() => undefined, () => undefined);
565
+ }
566
+
532
567
  /** Attach this prompt's voice block, and settle what it settles. Synchronous on purpose: it runs
533
568
  * inside the send path, and a slow store write must not delay the operator's words.
534
569
  *
535
- * Returns a settle() to call once the turn is actually delivered: a prompt that threw before the
536
- * agent saw it must not consume a boundary or count as a spoken turn (review m1). */
537
- #applyVoiceMode(s: LiveSession, blocks: unknown[], opts: { operator: boolean }): (delivered: boolean) => void {
570
+ * Returns a settle() to call once the turn has been dispatched: before that point the boundary is
571
+ * still owed, so a failed send does not burn it (review m1). */
572
+ #applyVoiceMode(s: LiveSession, blocks: unknown[], opts: { operator: boolean }): () => void {
538
573
  // A background-completion wake is not a spoken turn: letting it consume the boundary injected
539
574
  // "the call is over" into a synthetic turn nobody reads, so the operator's next real message
540
575
  // never learned it, and each wake inflated the turn count (review M1).
@@ -547,19 +582,36 @@ export class SessionManager {
547
582
  if (v.inCall && live !== s.info.id) {
548
583
  const owed = v.turns > 0;
549
584
  v = this.#store.upsertVoiceMode(s.info.id, { inCall: false, boundary: owed ? "exit" : null, ...(owed ? {} : { turns: 0 }) });
550
- void this.#restoreThought(s.info.id);
585
+ // On the SAME chain as every other transition: a fire-and-forget restore could finish after a
586
+ // new call had already pressed the level down, clear the snapshot, and strand the session on
587
+ // the call level (review round 2, A-M2).
588
+ this.#queueRestore(s.info.id);
551
589
  } else if (!v.inCall && live === s.info.id) {
552
590
  v = this.#store.upsertVoiceMode(s.info.id, { inCall: true, boundary: v.boundary ?? "enter", turns: 0 });
553
591
  }
554
592
  const block = voiceModeBlock(s.info.id, { inCall: v.inCall, boundary: v.boundary, turns: v.turns });
555
593
  if (block) blocks.push(block);
556
- const hadBoundary = Boolean(v.boundary);
594
+ const seenBoundary = v.boundary;
557
595
  const counted = v.inCall;
558
- if (!hadBoundary && !counted) return () => { /* steady state, nothing to settle */ };
559
- return (delivered: boolean) => {
560
- if (!delivered) return;
561
- if (hadBoundary) this.#store.upsertVoiceMode(s.info.id, { boundary: null });
562
- if (counted) this.#store.upsertVoiceMode(s.info.id, { turns: this.#store.getVoiceMode(s.info.id).turns + 1 });
596
+ if (!seenBoundary && !counted) return () => { /* steady state, nothing to settle */ };
597
+ // The turn is counted NOW, not when the prompt returns: hanging up while the agent is still
598
+ // answering is the ordinary way a call ends, and `#endCall` must already see that something was
599
+ // said or it owes no exit boundary (review round 2, A-M1). A prompt that never reaches the agent
600
+ // rolls the count back.
601
+ if (counted) this.#store.upsertVoiceMode(s.info.id, { turns: v.turns + 1 });
602
+ // No rollback on a thrown prompt, deliberately: "the frame went out and the answer never came
603
+ // back" cannot be told apart from "the frame never went out", and the two mistakes are not
604
+ // symmetric — over-counting costs one extra sentence in the exit block, under-counting means the
605
+ // exit boundary is never owed at all and the agent keeps answering text in spoken-word style
606
+ // (review r3 A-①).
607
+ // The generation guards against a same-shaped boundary that arrived AFTER this turn started:
608
+ // consume the enter boundary, hang up and dial again → the new call's enter must survive.
609
+ const gen = this.#callGen(s.info.id);
610
+ return () => {
611
+ if (seenBoundary && this.#callGen(s.info.id) === gen) {
612
+ const cur = this.#store.getVoiceMode(s.info.id);
613
+ if (cur.boundary === seenBoundary) this.#store.upsertVoiceMode(s.info.id, { boundary: null });
614
+ }
563
615
  };
564
616
  }
565
617
 
@@ -1332,9 +1384,8 @@ export class SessionManager {
1332
1384
  prompt: blocks as any,
1333
1385
  });
1334
1386
  const terminalState = res?.stopReason === "cancelled" ? "interrupted" : "ended";
1335
- // The agent has the block (a cancelled turn still received it): NOW the boundary is spent and
1336
- // the turn counts.
1337
- settleVoice(true);
1387
+ // The agent has the block (a cancelled turn still received it): NOW the boundary is spent.
1388
+ settleVoice();
1338
1389
  this.#finalizePlan(sessionId, terminalState);
1339
1390
  this.#emit({ t: "turn-end", sessionId, stopReason: res?.stopReason, durationMs: Date.now() - turnStartedAt });
1340
1391
  } catch (err) {