switchroom 0.13.27 → 0.13.29

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.
@@ -47436,8 +47436,8 @@ var {
47436
47436
  } = import__.default;
47437
47437
 
47438
47438
  // src/build-info.ts
47439
- var VERSION = "0.13.27";
47440
- var COMMIT_SHA = "a158e029";
47439
+ var VERSION = "0.13.29";
47440
+ var COMMIT_SHA = "927abe08";
47441
47441
 
47442
47442
  // src/cli/agent.ts
47443
47443
  init_source();
@@ -61793,30 +61793,44 @@ Push passphrase to broker for future requests? [Y/n]: `);
61793
61793
  vault.command("list").description("List all secret key names in the vault").action(async () => {
61794
61794
  try {
61795
61795
  const parentOpts = program3.opts();
61796
- if (isSandboxContext()) {
61797
- let brokerSocket;
61798
- try {
61799
- const config = loadConfig(parentOpts.config);
61800
- brokerSocket = resolveBrokerSocketPath({
61801
- vaultBrokerSocket: config.vault?.broker?.socket ? resolvePath(config.vault.broker.socket) : undefined
61802
- });
61803
- } catch {
61804
- brokerSocket = resolveBrokerSocketPath();
61805
- }
61806
- const { listViaBroker: listViaBroker2 } = await Promise.resolve().then(() => (init_client(), exports_client));
61807
- const keys2 = await listViaBroker2({ socket: brokerSocket });
61808
- if (keys2 === null) {
61809
- process.stderr.write(`VAULT-BROKER-UNREACHABLE: cannot reach vault broker; 'switchroom vault list' from a sandbox requires a live broker.
61796
+ const inSandbox = isSandboxContext();
61797
+ let brokerSocket;
61798
+ try {
61799
+ const config = loadConfig(parentOpts.config);
61800
+ brokerSocket = resolveBrokerSocketPath({
61801
+ vaultBrokerSocket: config.vault?.broker?.socket ? resolvePath(config.vault.broker.socket) : undefined
61802
+ });
61803
+ } catch {
61804
+ brokerSocket = resolveBrokerSocketPath();
61805
+ }
61806
+ const brokerOpts = { socket: brokerSocket };
61807
+ const status = inSandbox ? null : await statusViaBroker(brokerOpts);
61808
+ if (inSandbox || status !== null) {
61809
+ if (status !== null && !status.unlocked) {
61810
+ if (inSandbox) {
61811
+ process.stderr.write(`VAULT-BROKER-DENIED: broker locked.
61812
+ ` + `${recoveryHint("locked")}
61810
61813
  `);
61811
- process.exit(VAULT_EXIT_BROKER_UNREACHABLE);
61812
- }
61813
- if (keys2.length === 0) {
61814
- console.log(source_default.dim("No secrets in vault"));
61815
- } else {
61816
- for (const key of keys2)
61817
- console.log(key);
61814
+ process.exit(3);
61815
+ }
61816
+ } else if (status !== null || inSandbox) {
61817
+ const keys2 = await listViaBroker(brokerOpts);
61818
+ if (keys2 === null) {
61819
+ if (inSandbox) {
61820
+ process.stderr.write(`VAULT-BROKER-UNREACHABLE: cannot reach vault broker; ` + `'switchroom vault list' from a sandbox requires a live broker.
61821
+ `);
61822
+ process.exit(VAULT_EXIT_BROKER_UNREACHABLE);
61823
+ }
61824
+ } else {
61825
+ if (keys2.length === 0) {
61826
+ console.log(source_default.dim("No secrets in vault"));
61827
+ } else {
61828
+ for (const key of keys2)
61829
+ console.log(key);
61830
+ }
61831
+ return;
61832
+ }
61818
61833
  }
61819
- return;
61820
61834
  }
61821
61835
  const vaultPath = getVaultPath4(parentOpts.config);
61822
61836
  const passphrase = await getPassphrase();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.13.27",
3
+ "version": "0.13.29",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48464,10 +48464,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
48464
48464
  }
48465
48465
 
48466
48466
  // ../src/build-info.ts
48467
- var VERSION = "0.13.27";
48468
- var COMMIT_SHA = "a158e029";
48469
- var COMMIT_DATE = "2026-05-24T09:47:16Z";
48470
- var LATEST_PR = 1727;
48467
+ var VERSION = "0.13.29";
48468
+ var COMMIT_SHA = "927abe08";
48469
+ var COMMIT_DATE = "2026-05-24T12:14:05Z";
48470
+ var LATEST_PR = 1732;
48471
48471
  var COMMITS_AHEAD_OF_TAG = 0;
48472
48472
 
48473
48473
  // gateway/boot-version.ts
@@ -51326,6 +51326,7 @@ ${url}`;
51326
51326
  noteSignal(statusKey(chat_id, threadId), Date.now());
51327
51327
  if (turn != null && isFinalAnswerReply({ text: rawText, disableNotification })) {
51328
51328
  turn.finalAnswerDelivered = true;
51329
+ finalizeStatusReaction(chat_id, threadId, "done");
51329
51330
  }
51330
51331
  }
51331
51332
  process.stderr.write(`telegram channel: reply: finalized chatId=${chat_id} messageIds=[${sentIds.join(",")}] chunks=${chunks.length}
@@ -4931,26 +4931,49 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4931
4931
  } catch { /* best-effort signal */ }
4932
4932
  // #203: fresh sendMessage from reply tool is a user-visible signal.
4933
4933
  signalTracker.noteSignal(statusKey(chat_id, threadId), Date.now())
4934
- // #1713: the reply tool is a NON-EVENT for the status reaction.
4935
- // The reaction reflects current turn activity, not delivery state —
4936
- // only the `turn_end` IPC handler finalizes (👍). A plain `reply`
4937
- // mid-turn or as the final answer does not change the emoji on the
4938
- // user's inbound message; the next turn_end does. This is a
4939
- // deliberate revert of the PR #602 follow-up that wired delivery-
4940
- // confirmation into the terminal path (see #1713 issue body for
4941
- // the rationale: "delivery confirmation turn end").
4942
- // #1664 mark the turn's final answer as delivered when this reply
4943
- // looks like the real answer rather than an interim ack. The
4944
- // classification (notification-bearing OR substantive length) lives
4945
- // in `isFinalAnswerReply`. Without this, a turn that ack'd then ended
4946
- // with the real answer as plain transcript text (#1664) would look
4947
- // "delivered" because replyCalled is true — and the silent-end
4948
- // re-prompt would never engage. `rawText` is the model's own answer
4949
- // text, measured before HTML conversion / Telegraph-link
4950
- // substitution. Writes `turn` (pinned at executeReply entry) so the
4951
- // flag always lands on the turn this reply belongs to.
4934
+ // #1713: the reply tool is a NON-EVENT for the status reaction
4935
+ // WHEN IT'S AN INTERIM ACK. The reaction reflects current turn
4936
+ // activity, not delivery state interim acks must not collapse
4937
+ // the working-state ladder to 👍.
4938
+ //
4939
+ // #1728 carve-out (2026-05-24): when this reply IS the final
4940
+ // answer (`isFinalAnswerReply` returns true same classifier
4941
+ // #1664 uses for silent-end re-prompt gating), it IS effectively
4942
+ // turn-end and we MUST finalize here. Rationale: Claude Code's
4943
+ // `turn_duration` system event is unreliable for the trivial-
4944
+ // prompt happy path (driver sends "what's 2+2", model replies
4945
+ // "4", no `turn_duration` ever lands in the JSONL session tail).
4946
+ // Pre-#1718 this wedge was masked by the legacy
4947
+ // `endStatusReaction` shim running unconditionally on every
4948
+ // reply (outcome='done'); #1718 removed that call site
4949
+ // intending `turn_end` to be the sole terminal trigger. The
4950
+ // contract was right in spirit but `turn_end` doesn't fire 100%
4951
+ // of the time, so the buffer gate (activeTurnStartedAt) stays
4952
+ // set forever and every subsequent inbound gets `held mid-turn`
4953
+ // and never delivered. v0.13.27 shipped + reverted on this
4954
+ // failure mode (#1728).
4955
+ //
4956
+ // Net contract:
4957
+ // - interim ack reply (isFinalAnswerReply === false)
4958
+ // → non-event, no reaction finalize, buffer gate stays
4959
+ // - final-answer reply (isFinalAnswerReply === true)
4960
+ // → finalize reaction (debounced 👍) + release buffer
4961
+ // gate via purgeReactionTracking (called inside
4962
+ // finalizeStatusReaction). currentTurn stays alive so
4963
+ // a subsequent `turn_end` still cleans up its share
4964
+ // idempotently.
4965
+ //
4966
+ // #1664 — `turn.finalAnswerDelivered = true` keeps the silent-
4967
+ // end re-prompt from spuriously firing on a delivered final.
4952
4968
  if (turn != null && isFinalAnswerReply({ text: rawText, disableNotification })) {
4953
4969
  turn.finalAnswerDelivered = true
4970
+ // #1728: release the buffer gate + emit terminal 👍. Mid-turn
4971
+ // acks bypass this branch and remain non-events for the
4972
+ // reaction (preserves #1713). The full turn-state teardown
4973
+ // (nulling `currentTurn`, the per-turn cleanup) still runs in
4974
+ // the `turn_end` handler when it lands; this only fires the
4975
+ // observable side effects that #1718 deferred unconditionally.
4976
+ finalizeStatusReaction(chat_id, threadId, 'done')
4954
4977
  }
4955
4978
  }
4956
4979
 
@@ -1,39 +1,44 @@
1
1
  /**
2
- * #1713 — plain `reply` tool is a NON-EVENT for the status reaction.
2
+ * #1713 + #1728 interim-ack `reply` tool is a non-event for the
3
+ * status reaction; final-answer `reply` finalizes.
3
4
  *
4
- * History. PR #602 follow-up wired `executeReply` to fire the terminal
5
- * 👍 after at least one chunk landed, mirroring the (now-also-removed)
6
- * stream_reply Bug Z behaviour. #1713 reverts both: the status reaction
7
- * reflects current turn activity, not delivery state. Only the
8
- * gateway's `turn_end` IPC handler finalizes the reaction. Mid-turn
9
- * replies ack or final must not change the emoji.
5
+ * History.
6
+ * - PR #602 follow-up wired `executeReply` to fire the terminal 👍
7
+ * after at least one chunk landed.
8
+ * - #1713 (#1718) reverted that: reaction reflects current turn
9
+ * activity, not delivery state. Made `turn_end` the sole terminal
10
+ * trigger so mid-turn ACK replies don't collapse the working-state
11
+ * ladder to 👍.
12
+ * - #1728 (this fix) carve-out: Claude Code's `turn_duration` system
13
+ * event is unreliable for the trivial-prompt happy path, leaving
14
+ * `activeTurnStartedAt` set forever and every subsequent inbound
15
+ * stuck "held mid-turn" (the v0.13.27 wedge). When the reply IS
16
+ * the final answer (`isFinalAnswerReply` returns true), executeReply
17
+ * calls `finalizeStatusReaction` to release the buffer gate and
18
+ * emit the (debounced 3500ms) terminal 👍. Interim acks bypass this
19
+ * branch and remain non-events for the reaction.
10
20
  *
11
- * This file pins the new invariant: there is no `endStatusReaction`
12
- * call inside the executeReply post-send block. The post-send block
13
- * now records signal-tracker / outbound-dedup / final-answer state
14
- * only reaction state is owned by turn_end.
21
+ * Net contract pinned here:
22
+ * - executeReply post-send block must NOT call the legacy
23
+ * `endStatusReaction('done')` (the pre-#1713 bug class).
24
+ * - executeReply MUST call `finalizeStatusReaction` gated on
25
+ * `isFinalAnswerReply` so the buffer gate releases on final answer.
15
26
  *
16
27
  * The gateway IIFE / executeReply body are too entangled to import
17
- * directly, so we model the post-#1713 contract here. If executeReply
18
- * regresses (re-adds a terminal-reaction call), the inline review
19
- * comment guarding `if (sentIds.length > 0)` and this test should both
20
- * catch it.
28
+ * directly, so we do source-level assertions.
21
29
  */
22
30
  import { describe, it, expect, vi } from 'vitest'
23
31
 
24
- describe('#1713 — plain reply tool is a non-event for the reaction', () => {
25
- it('executeReply post-send block does NOT call endStatusReaction', () => {
26
- // Read the source to assert the contract — there should be no
27
- // `endStatusReaction(... 'done')` call inside the post-send
28
- // `if (sentIds.length > 0)` block in executeReply.
32
+ describe('#1713 + #1728 — reply tool reaction contract', () => {
33
+ it('executeReply post-send block does NOT call legacy endStatusReaction', () => {
34
+ // The pre-#1713 bug class was `endStatusReaction(chat_id, threadId,
35
+ // 'done')` firing 👍 unconditionally on every reply (including
36
+ // mid-turn acks). That call must stay removed.
29
37
  //
30
38
  // We do a coarse-grained source-level check rather than a unit
31
39
  // test of a copied helper. If/when the executeReply body is
32
40
  // extracted into its own function this can become a proper unit
33
41
  // test; until then the source-level guard is the safest pin.
34
- //
35
- // The intent: a future commit that re-adds the call (regressing
36
- // #1713) will trip this assertion.
37
42
  // eslint-disable-next-line @typescript-eslint/no-require-imports
38
43
  const fs = require('node:fs') as typeof import('node:fs')
39
44
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -53,6 +58,44 @@ describe('#1713 — plain reply tool is a non-event for the reaction', () => {
53
58
  expect(slice).not.toMatch(/endStatusReaction\([^)]*'done'\)/)
54
59
  })
55
60
 
61
+ it('executeReply post-send block DOES call finalizeStatusReaction gated on isFinalAnswerReply (#1728 wedge fix)', () => {
62
+ // #1728 — the v0.13.27 wedge: `turn_duration` system events from
63
+ // Claude Code don't reliably land for trivial-prompt turns, so
64
+ // activeTurnStartedAt never clears and every subsequent inbound
65
+ // gets held mid-turn. The fix: when executeReply detects a final-
66
+ // answer reply (the same `isFinalAnswerReply` classifier #1664
67
+ // uses for silent-end re-prompt gating), trigger
68
+ // `finalizeStatusReaction` to release the buffer gate. Interim
69
+ // acks (isFinalAnswerReply === false) MUST bypass this branch and
70
+ // remain non-events for the reaction (preserves #1713).
71
+ //
72
+ // If a future commit removes the finalizeStatusReaction call or
73
+ // un-gates it from isFinalAnswerReply, the wedge returns.
74
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
75
+ const fs = require('node:fs') as typeof import('node:fs')
76
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
77
+ const path = require('node:path') as typeof import('node:path')
78
+ const src = fs.readFileSync(
79
+ path.resolve(__dirname, '../gateway/gateway.ts'),
80
+ 'utf8',
81
+ )
82
+ const anchor = src.indexOf("fresh sendMessage from reply tool is a user-visible")
83
+ expect(anchor).toBeGreaterThan(-1)
84
+ const slice = src.slice(anchor, anchor + 3000)
85
+ // The finalize MUST appear in the post-send block.
86
+ expect(slice).toMatch(/finalizeStatusReaction\(/)
87
+ // It MUST be gated by isFinalAnswerReply (the classifier prevents
88
+ // interim acks from firing 👍, which would regress #1713).
89
+ expect(slice).toMatch(/isFinalAnswerReply\(/)
90
+ // Sanity: the finalize MUST appear AFTER the isFinalAnswerReply
91
+ // check (i.e. inside the gated branch), not as a sibling that
92
+ // fires unconditionally.
93
+ const gateIdx = slice.indexOf('isFinalAnswerReply(')
94
+ const finalizeIdx = slice.indexOf('finalizeStatusReaction(')
95
+ expect(gateIdx).toBeGreaterThan(-1)
96
+ expect(finalizeIdx).toBeGreaterThan(gateIdx)
97
+ })
98
+
56
99
  it('reply tool deps no longer wire a status-reaction terminal callback', () => {
57
100
  // Post-#1713 the stream-reply-handler has no call site for
58
101
  // `deps.endStatusReaction`. Post follow-up cleanup, the dep itself