agentus 0.11.2 → 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-0556`, 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-0556`,基座 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-0556`** — 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-0556`** —— 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.2",
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
@@ -498,6 +501,7 @@ export class SessionManager {
498
501
  }
499
502
 
500
503
  async #endCall(id: string): Promise<void> {
504
+ this.#bumpCallGen(id);
501
505
  const v = this.#store.getVoiceMode(id);
502
506
  // Nothing was actually said (no prompt ever rode this call): owe nothing. An exit block here
503
507
  // would tell the agent "the conversation above was spoken", which is false — and it is cheap to
@@ -532,13 +536,27 @@ export class SessionManager {
532
536
  // leave it alone (the new call will restore it on ITS hang-up).
533
537
  if (this.#store.getVoiceMode(id).inCall) return false;
534
538
  // `setConfig` reports a rejected value by emitting, not by throwing: verify against what the
535
- // agent actually advertises before declaring the level restored (review round 2, A-m1).
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-③).
536
543
  const now = thinkingOptionOf(s.info.configOptions);
537
- if (now && now.current && now.current !== v.thoughtBefore) return false;
544
+ if (!now || now.id !== choice.id || !now.current) return false;
545
+ if (now.current !== v.thoughtBefore) return false;
538
546
  this.#store.upsertVoiceMode(id, { thoughtBefore: null, thoughtTouched: false });
539
547
  return true;
540
548
  }
541
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
+
542
560
  /** Run a restore on the call-state chain instead of behind its back. */
543
561
  #queueRestore(id: string): void {
544
562
  this.#callSync = this.#callSync
@@ -549,9 +567,9 @@ export class SessionManager {
549
567
  /** Attach this prompt's voice block, and settle what it settles. Synchronous on purpose: it runs
550
568
  * inside the send path, and a slow store write must not delay the operator's words.
551
569
  *
552
- * Returns a settle() to call once the turn is actually delivered: a prompt that threw before the
553
- * agent saw it must not consume a boundary or count as a spoken turn (review m1). */
554
- #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 {
555
573
  // A background-completion wake is not a spoken turn: letting it consume the boundary injected
556
574
  // "the call is over" into a synthetic turn nobody reads, so the operator's next real message
557
575
  // never learned it, and each wake inflated the turn count (review M1).
@@ -581,17 +599,16 @@ export class SessionManager {
581
599
  // said or it owes no exit boundary (review round 2, A-M1). A prompt that never reaches the agent
582
600
  // rolls the count back.
583
601
  if (counted) this.#store.upsertVoiceMode(s.info.id, { turns: v.turns + 1 });
584
- return (delivered: boolean) => {
585
- if (!delivered) {
586
- if (counted) {
587
- const now = this.#store.getVoiceMode(s.info.id);
588
- if (now.inCall && now.turns > 0) this.#store.upsertVoiceMode(s.info.id, { turns: now.turns - 1 });
589
- }
590
- return;
591
- }
592
- // Clear only the boundary this turn actually carried: a hang-up during the turn may have
593
- // replaced it with an exit boundary the operator still needs to hear (review r2 A-M1).
594
- if (seenBoundary) {
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) {
595
612
  const cur = this.#store.getVoiceMode(s.info.id);
596
613
  if (cur.boundary === seenBoundary) this.#store.upsertVoiceMode(s.info.id, { boundary: null });
597
614
  }
@@ -1367,9 +1384,8 @@ export class SessionManager {
1367
1384
  prompt: blocks as any,
1368
1385
  });
1369
1386
  const terminalState = res?.stopReason === "cancelled" ? "interrupted" : "ended";
1370
- // The agent has the block (a cancelled turn still received it): NOW the boundary is spent and
1371
- // the turn counts.
1372
- settleVoice(true);
1387
+ // The agent has the block (a cancelled turn still received it): NOW the boundary is spent.
1388
+ settleVoice();
1373
1389
  this.#finalizePlan(sessionId, terminalState);
1374
1390
  this.#emit({ t: "turn-end", sessionId, stopReason: res?.stopReason, durationMs: Date.now() - turnStartedAt });
1375
1391
  } catch (err) {