switchroom 0.14.37 → 0.14.39

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.
@@ -11422,18 +11422,11 @@ var init_schema = __esm(() => {
11422
11422
  webhook_via_gateway: exports_external.boolean().optional().describe("Route verified webhook events to the agent's in-container gateway " + "over a peercred-gated UDS (<agent>/telegram/webhook.sock) instead " + "of having the host-side web receiver write the agent dir directly. " + "Required under the Docker runtime: the receiver runs as the host " + "operator UID and cannot write the per-agent-UID-owned agent dir " + "(EACCES 500) nor connect the gateway socket. When true the gateway " + "(running as the agent UID) becomes the sole writer of " + "webhook-events.jsonl + dedup/cooldown state and also fires " + "webhook_dispatch. Off by default for back-compat with host-runtime " + "installs. See docs/rfcs/webhook-via-gateway-socket.md."),
11423
11423
  webhook_require_edge: exports_external.boolean().optional().describe("Cloudflare-only edge lock: require the X-Switchroom-Edge header " + "(injected by a Cloudflare Transform Rule on hooks.switchroom.ai) to " + "match the operator's edge secret at ~/.switchroom/webhook-edge-secret " + "before any HMAC verification; reject 403 otherwise. Proves the " + "request entered through our Cloudflare edge — the per-agent HMAC " + "alone can't (it proves body provenance, not network path). Stacks " + "on the GitHub-IP WAF + per-agent HMAC. Fail-closed: when required " + "but the secret file is missing/empty every request is rejected. Off " + "by default. See docs/rfcs/webhook-cloudflare-edge-lock.md."),
11424
11424
  chat_id: exports_external.string().regex(/^-\d+$/, 'supergroup chat_id must be a negative integer as a string (e.g. "-1001234567890")').optional().describe("Per-agent supergroup ID — overrides fleet `telegram.forum_chat_id`. " + "When set, requires `default_topic_id`. Negative integer as string. " + "Forbidden when `dm_only: true`. See docs/rfcs/supergroup-mode.md."),
11425
- default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Required when `chat_id` is set. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field — the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
11425
+ default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Defaults to General (topic 1) when " + "`chat_id` is set and this is omitted — set it only to pin a different " + "fallback topic. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field — the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
11426
11426
  topic_aliases: exports_external.record(exports_external.string(), exports_external.number().int().positive()).optional().describe("Operator-friendly names for forum topic IDs (e.g. " + "`{ general: 1, planning: 17, cron: 23, admin: 31, alerts: 41 }`). " + "Referenced from per-cron `topic:` fields and the outbound router " + "for autonomous events (boot → alerts, hostd → admin, etc.). " + "Cascades per-key through defaults → profile → agent.")
11427
11427
  }).optional().superRefine((tg, ctx) => {
11428
11428
  if (!tg)
11429
11429
  return;
11430
- if (tg.chat_id != null && tg.default_topic_id == null) {
11431
- ctx.addIssue({
11432
- code: exports_external.ZodIssueCode.custom,
11433
- message: "`channels.telegram.chat_id` requires `default_topic_id` — supergroup-mode agents need a fallback topic for unclassified outbounds.",
11434
- path: ["default_topic_id"]
11435
- });
11436
- }
11437
11430
  if (tg.default_topic_id != null && tg.chat_id == null) {
11438
11431
  ctx.addIssue({
11439
11432
  code: exports_external.ZodIssueCode.custom,
@@ -11448,6 +11441,11 @@ var init_schema = __esm(() => {
11448
11441
  path: ["topic_aliases"]
11449
11442
  });
11450
11443
  }
11444
+ }).transform((tg) => {
11445
+ if (tg && tg.chat_id != null && tg.default_topic_id == null) {
11446
+ return { ...tg, default_topic_id: 1 };
11447
+ }
11448
+ return tg;
11451
11449
  });
11452
11450
  ChannelsSchema = exports_external.object({
11453
11451
  telegram: TelegramChannelSchema
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.37",
3
+ "version": "0.14.39",
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": {
@@ -23,9 +23,9 @@
23
23
  "build": "node scripts/build.mjs",
24
24
  "build:cli": "node scripts/build.mjs && bun build --compile --target=bun-linux-x64 --minify bin/switchroom.ts --outfile switchroom-linux-amd64",
25
25
  "pretest": "npm run build",
26
- "test": "vitest run && bun test telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/registry/api-registry.test.ts telegram-plugin/registry/turns-schema.test.ts telegram-plugin/tests/idle-footer-wiring.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
26
+ "test": "vitest run && bun test telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/registry/api-registry.test.ts telegram-plugin/registry/turns-schema.test.ts telegram-plugin/tests/idle-footer-wiring.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
27
27
  "test:vitest": "vitest run",
28
- "test:bun": "bun test src/watchdog/state.test.ts src/watchdog/policy.test.ts src/vault/grants.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/server-admin-only-keys.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/approval-origin.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
28
+ "test:bun": "bun test src/watchdog/state.test.ts src/watchdog/policy.test.ts src/vault/grants.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/server-admin-only-keys.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/approval-origin.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
29
29
  "test:watch": "vitest",
30
30
  "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs",
31
31
  "lint:tsc": "tsc --noEmit",
@@ -23807,18 +23807,11 @@ var init_schema = __esm(() => {
23807
23807
  webhook_via_gateway: exports_external.boolean().optional().describe("Route verified webhook events to the agent's in-container gateway " + "over a peercred-gated UDS (<agent>/telegram/webhook.sock) instead " + "of having the host-side web receiver write the agent dir directly. " + "Required under the Docker runtime: the receiver runs as the host " + "operator UID and cannot write the per-agent-UID-owned agent dir " + "(EACCES 500) nor connect the gateway socket. When true the gateway " + "(running as the agent UID) becomes the sole writer of " + "webhook-events.jsonl + dedup/cooldown state and also fires " + "webhook_dispatch. Off by default for back-compat with host-runtime " + "installs. See docs/rfcs/webhook-via-gateway-socket.md."),
23808
23808
  webhook_require_edge: exports_external.boolean().optional().describe("Cloudflare-only edge lock: require the X-Switchroom-Edge header " + "(injected by a Cloudflare Transform Rule on hooks.switchroom.ai) to " + "match the operator's edge secret at ~/.switchroom/webhook-edge-secret " + "before any HMAC verification; reject 403 otherwise. Proves the " + "request entered through our Cloudflare edge \u2014 the per-agent HMAC " + "alone can't (it proves body provenance, not network path). Stacks " + "on the GitHub-IP WAF + per-agent HMAC. Fail-closed: when required " + "but the secret file is missing/empty every request is rejected. Off " + "by default. See docs/rfcs/webhook-cloudflare-edge-lock.md."),
23809
23809
  chat_id: exports_external.string().regex(/^-\d+$/, 'supergroup chat_id must be a negative integer as a string (e.g. "-1001234567890")').optional().describe("Per-agent supergroup ID \u2014 overrides fleet `telegram.forum_chat_id`. " + "When set, requires `default_topic_id`. Negative integer as string. " + "Forbidden when `dm_only: true`. See docs/rfcs/supergroup-mode.md."),
23810
- default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Required when `chat_id` is set. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field \u2014 the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
23810
+ default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Defaults to General (topic 1) when " + "`chat_id` is set and this is omitted \u2014 set it only to pin a different " + "fallback topic. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field \u2014 the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
23811
23811
  topic_aliases: exports_external.record(exports_external.string(), exports_external.number().int().positive()).optional().describe("Operator-friendly names for forum topic IDs (e.g. " + "`{ general: 1, planning: 17, cron: 23, admin: 31, alerts: 41 }`). " + "Referenced from per-cron `topic:` fields and the outbound router " + "for autonomous events (boot \u2192 alerts, hostd \u2192 admin, etc.). " + "Cascades per-key through defaults \u2192 profile \u2192 agent.")
23812
23812
  }).optional().superRefine((tg, ctx) => {
23813
23813
  if (!tg)
23814
23814
  return;
23815
- if (tg.chat_id != null && tg.default_topic_id == null) {
23816
- ctx.addIssue({
23817
- code: exports_external.ZodIssueCode.custom,
23818
- message: "`channels.telegram.chat_id` requires `default_topic_id` \u2014 supergroup-mode agents need a fallback topic for unclassified outbounds.",
23819
- path: ["default_topic_id"]
23820
- });
23821
- }
23822
23815
  if (tg.default_topic_id != null && tg.chat_id == null) {
23823
23816
  ctx.addIssue({
23824
23817
  code: exports_external.ZodIssueCode.custom,
@@ -23833,6 +23826,11 @@ var init_schema = __esm(() => {
23833
23826
  path: ["topic_aliases"]
23834
23827
  });
23835
23828
  }
23829
+ }).transform((tg) => {
23830
+ if (tg && tg.chat_id != null && tg.default_topic_id == null) {
23831
+ return { ...tg, default_topic_id: 1 };
23832
+ }
23833
+ return tg;
23836
23834
  });
23837
23835
  ChannelsSchema = exports_external.object({
23838
23836
  telegram: TelegramChannelSchema
@@ -51796,10 +51794,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
51796
51794
  }
51797
51795
 
51798
51796
  // ../src/build-info.ts
51799
- var VERSION = "0.14.37";
51800
- var COMMIT_SHA = "90d0c420";
51801
- var COMMIT_DATE = "2026-06-02T02:10:03Z";
51802
- var LATEST_PR = 2078;
51797
+ var VERSION = "0.14.39";
51798
+ var COMMIT_SHA = "fb30b654";
51799
+ var COMMIT_DATE = "2026-06-02T05:46:22Z";
51800
+ var LATEST_PR = 2086;
51803
51801
  var COMMITS_AHEAD_OF_TAG = 0;
51804
51802
 
51805
51803
  // gateway/boot-version.ts
@@ -188,18 +188,53 @@ function toolResponseText(toolResponse) {
188
188
  * parent turn, so the pretool recorded background=0 and the worker card never
189
189
  * fired). We therefore trust this ACK over the pretool's input-derived flag.
190
190
  *
191
- * Anchored on the specific "async agent launched" phrase (a foreground
192
- * sub-agent's final report is extremely unlikely to contain it), with a
193
- * structural backstop ("working in the background" + an agentId token) in case
194
- * the launch-verb wording drifts. A major wording change degrades to the
195
- * pretool flag still correct whenever the model DID pass run_in_background,
196
- * never worse than before.
191
+ * Three tiers, widest-stable-signal last, so a Claude Code wording change has
192
+ * to defeat ALL of them before promotion silently regresses (#2084):
193
+ *
194
+ * 1. The canonical ACK phrase "async agent launched" the exact current
195
+ * claude-code wording. A foreground report is extremely unlikely to
196
+ * contain it.
197
+ * 2. Structural backstop: the background-status phrase "working in the
198
+ * background" + an agentId token. Survives a reworded launch verb.
199
+ * 3. Drift-tolerant STRUCTURAL match: `agentId: <stem>` as a bare identifier
200
+ * on its OWN LINE — the functional, most wording-stable core of the ACK
201
+ * (the parent references the worker by this id). claude-code emits it on
202
+ * its own line; a foreground report that merely names an agent id embeds
203
+ * it mid-sentence ("the agentId is X is managing …"), which the own-line
204
+ * anchor rejects. Paired with a launch/background/async/dispatch/notify
205
+ * context word so it can't trip on a foreground report that happens to
206
+ * print a bare id on its own line. This survives BOTH prose phrases
207
+ * (tiers 1 & 2) rewording in the same bump.
208
+ *
209
+ * If all three miss, promotion degrades to the pretool's input-derived flag —
210
+ * still correct whenever the model DID pass run_in_background, never worse than
211
+ * before. The exact ACK contract is pinned by drift-variant tests in
212
+ * subagent-tracker-hooks.test.ts ("async-launch ACK contract"); when bumping
213
+ * the pinned claude-code version, re-verify the live ACK against those.
214
+ *
215
+ * Known residual (accepted): an ACK reword that BOTH drops the own-line id
216
+ * form AND removes every context word degrades to the pretool flag; a
217
+ * foreground report that prints a bare `agentId: <id>` on its own line next to
218
+ * a launch/background word would false-promote. Both are narrow; the own-line
219
+ * + context-word conjunction is the balance point. Re-verify on a pin bump.
220
+ *
221
+ * ACK contract verified against claude-code 2.1.156 (the fleet pin):
222
+ * "Async agent launched successfully.\n
223
+ * agentId: <stem>\n
224
+ * The agent is working in the background. You will be notified
225
+ * automatically when it completes."
197
226
  */
198
227
  function isAsyncLaunchAck(toolResponse) {
199
228
  const t = toolResponseText(toolResponse).toLowerCase()
200
229
  if (!t) return false
201
230
  if (t.includes('async agent launched')) return true
202
231
  if (t.includes('working in the background') && t.includes('agentid')) return true
232
+ // Tier 3 — own-line `agentid: <bare-stem>` + a context word (leading \b so
233
+ // reworded/derived forms like "launched"/"dispatching"/"notified" still
234
+ // count). `m` flag anchors $ to line end so a mid-sentence id is rejected.
235
+ if (/agentid:\s*[a-z0-9][\w-]*\s*$/m.test(t) && /\b(background|launch|dispatch|async|notif)/.test(t)) {
236
+ return true
237
+ }
203
238
  return false
204
239
  }
205
240
 
@@ -191,6 +191,24 @@ function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, descr
191
191
  db.exec('ALTER TABLE subagents ADD COLUMN jsonl_agent_id TEXT')
192
192
  db.exec('CREATE INDEX IF NOT EXISTS subagents_jsonl_id ON subagents(jsonl_agent_id)')
193
193
  }
194
+ // Verify the marker-derived parent_turn_key (snapParams[2]) actually has
195
+ // a row in the turns table before trusting it. The gateway writes the
196
+ // turn-active marker even when recordTurnStart's INSERT failed (the two
197
+ // writes have independent failure surfaces), so a marker can name a
198
+ // turn_key with no turns row. Stamping that phantom key would route the
199
+ // worker card to the operator DM AND block the watcher's NULL-guarded
200
+ // window backfill from recovering it. Downgrade to NULL so the backfill
201
+ // stays eligible — this also defends against a stale/corrupted marker.
202
+ if (snapParams[2] != null) {
203
+ let turnRow = null
204
+ try {
205
+ turnRow = db.prepare('SELECT 1 FROM turns WHERE turn_key = ? LIMIT 1').get(snapParams[2])
206
+ } catch {
207
+ // turns table may not exist yet on a brand-new agent — treat as no row.
208
+ turnRow = null
209
+ }
210
+ if (turnRow == null) snapParams[2] = null
211
+ }
194
212
  db.prepare(snapInsertSql).run(...snapParams)
195
213
  db.close()
196
214
  done(null)
@@ -202,12 +220,65 @@ function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, descr
202
220
  }
203
221
 
204
222
  // sqlite3 CLI fallback — two non-blocking spawns sequenced via callbacks.
223
+ // This legacy path (neither node:sqlite nor bun:sqlite available) can't
224
+ // cheaply verify the marker's turn_key against the turns table, so drop
225
+ // parent_turn_key and let the gateway's window backfill attribute it.
226
+ // Production agents use node:sqlite; bun test uses bun:sqlite — both take
227
+ // the verified path above.
228
+ params[2] = null
205
229
  spawnSql(dbPath, SCHEMA_SQL.replace(/\n\s+/g, ' '), (err) => {
206
230
  if (err) { done(err); return }
207
231
  spawnSql(dbPath, fillPlaceholders(INSERT_SQL.trim(), params), done)
208
232
  })
209
233
  }
210
234
 
235
+ // ---------------------------------------------------------------------------
236
+ // Active-turn resolution (the parent_turn_key the row belongs to)
237
+ // ---------------------------------------------------------------------------
238
+
239
+ /**
240
+ * Read the gateway's turn-active marker to learn the turn_key of the turn that
241
+ * is active *right now* — the turn whose tool call is dispatching this
242
+ * sub-agent. The gateway writes `<TELEGRAM_STATE_DIR>/turn-active.json`
243
+ * synchronously at turn-start (gateway/turn-active-marker.ts), keyed
244
+ * `{turnKey, chatId, threadId, startedAt}`, and removes it at turn-complete.
245
+ * `telegramDir` here resolves to that same `TELEGRAM_STATE_DIR` in production
246
+ * (verified: identical inode to the registry.db dir), so the marker is a
247
+ * sibling of registry.db.
248
+ *
249
+ * Stamping parent_turn_key from this marker at INSERT time — instead of
250
+ * leaving it NULL for the gateway to reconstruct from a started_at time-window
251
+ * at jsonl-link time — fixes two bugs:
252
+ * - #2081: the time-window backfill mis-attributes when turn windows overlap
253
+ * (supergroup forum topics multiplex many concurrent turns under one
254
+ * chat_id; `ended_at` is unreliable/batch-swept). The live marker is the
255
+ * ground truth for "which turn dispatched this", so there is nothing to
256
+ * reconstruct and no overlap to disambiguate.
257
+ * - #2083: the backfill only runs when a sub-agent's JSONL links; ~8% never
258
+ * link and were never attributed. Stamping at INSERT is independent of
259
+ * linking.
260
+ *
261
+ * `turnKey` equals `turns.turn_key` (both minted by chatKeyWithSuffix at
262
+ * turn-start), so resolveSubagentOriginChat()'s getTurnByKey() finds the exact
263
+ * (chat_id, thread_id) and routes the worker card to the originating topic.
264
+ *
265
+ * Best-effort: if no turn is active (no marker — e.g. a sub-agent dispatched
266
+ * outside a turn) or the marker is unreadable/malformed, return null and let
267
+ * the gateway's started_at backfill remain the fallback (today's behaviour).
268
+ * Never throws; never blocks the tool call.
269
+ */
270
+ function readActiveTurnKey(telegramDir) {
271
+ try {
272
+ // Mirrors TURN_ACTIVE_MARKER_FILE in gateway/turn-active-marker.ts.
273
+ const raw = readFileSync(join(telegramDir, 'turn-active.json'), 'utf8')
274
+ const marker = JSON.parse(raw)
275
+ const turnKey = marker?.turnKey
276
+ return typeof turnKey === 'string' && turnKey.length > 0 ? turnKey : null
277
+ } catch {
278
+ return null
279
+ }
280
+ }
281
+
211
282
  // ---------------------------------------------------------------------------
212
283
  // main
213
284
  // ---------------------------------------------------------------------------
@@ -257,22 +328,22 @@ function main() {
257
328
  }
258
329
 
259
330
  const input = event.tool_input ?? {}
331
+ // Resolve parent_turn_key from the live turn-active marker (the turn whose
332
+ // tool call is dispatching this sub-agent). Claude Code's PreToolUse payload
333
+ // carries only its own session id, never the gateway-minted Telegram turn_key
334
+ // — but the gateway writes that turn_key to <telegramDir>/turn-active.json
335
+ // for the duration of the turn, so we read it directly here. Stamping it at
336
+ // INSERT (vs leaving NULL for the gateway's started_at time-window backfill)
337
+ // fixes overlapping-window mis-attribution (#2081) and attributes sub-agents
338
+ // whose JSONL never links (#2083). NULL when no turn is active → the gateway
339
+ // backfill remains the fallback. See readActiveTurnKey().
340
+ const parentTurnKey = readActiveTurnKey(telegramDir)
260
341
  writeRow(
261
342
  dbPath,
262
343
  {
263
344
  id: event.tool_use_id ?? null,
264
345
  parentSessionId: event.session_id ?? null,
265
- // parent_turn_key is intentionally NULL here. Claude Code's PreToolUse
266
- // payload carries its own session id, not the gateway-minted Telegram
267
- // turn_key (a chat+topic+turn key) the `turns` table is keyed on —
268
- // `event.turn_id` is always undefined, and even if a future CLI
269
- // populated it, it would not match a `turns.turn_key`. The gateway
270
- // resolves parent_turn_key from the
271
- // sub-agent's started_at at jsonl-link time (subagent-watcher.ts
272
- // backfillJsonlAgentId), which works even after the parent turn ends.
273
- // Writing a bogus value here would defeat that backfill's
274
- // `parent_turn_key IS NULL` guard.
275
- parentTurnKey: null,
346
+ parentTurnKey,
276
347
  agentType: input.subagent_type ?? null,
277
348
  description: input.description ?? null,
278
349
  background: input.run_in_background === true ? 1 : 0,
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
15
- import { mkdtempSync, mkdirSync, rmSync } from 'fs'
15
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'
16
16
  import { tmpdir } from 'os'
17
17
  import { join } from 'path'
18
18
  import { spawnSync } from 'child_process'
@@ -385,26 +385,62 @@ describe('Bug 4 — result_summary always NULL (hook integration)', () => {
385
385
  })
386
386
  })
387
387
 
388
- // ─── Bug 5 — parent_turn_key always NULL ─────────────────────────────────────
389
-
390
- describe('Bug 5 parent_turn_key backfilled by gateway, not the hook', () => {
391
- it('pretool writes parent_turn_key=NULL even when event.turn_id is present', () => {
392
- // Claude Code's PreToolUse payload carries its own session id, never the
393
- // gateway-minted Telegram turn_key (a chat+topic+turn key) the `turns`
394
- // table is keyed on. `event.turn_id` — even if a future CLI populated it
395
- // would not match a `turns.turn_key`, so the hook intentionally writes
396
- // NULL and lets the gateway backfill parent_turn_key from the sub-agent's
397
- // started_at at jsonl-link time (subagent-watcher.ts backfillJsonlAgentId).
398
- // Writing a bogus value here would defeat that backfill's
399
- // `parent_turn_key IS NULL` guard.
388
+ // ─── Bug 5 — parent_turn_key stamped from the live turn-active marker ─────────
389
+ // (#2081 / #2083) The PreToolUse hook reads <telegramDir>/turn-active.json —
390
+ // the gateway-written marker for the turn whose tool call is dispatching this
391
+ // sub-agent and stamps parent_turn_key = marker.turnKey at INSERT. This
392
+ // captures the EXACT active turn (no started_at time-window reconstruction at
393
+ // jsonl-link time), so it can't mis-attribute under overlapping turn windows
394
+ // (#2081) and works even for sub-agents whose JSONL never links (#2083).
395
+
396
+ /** Write the gateway's turn-active marker into the agent's telegram dir. */
397
+ function writeTurnActiveMarker(turnKey: string, chatId = '12345', threadId: string | null = null) {
398
+ writeFileSync(
399
+ join(agentDir, 'telegram', 'turn-active.json'),
400
+ JSON.stringify({ turnKey, chatId, threadId, startedAt: Date.now() }, null, 2) + '\n',
401
+ )
402
+ }
403
+
404
+ /**
405
+ * Seed a turns row so the hook's phantom-turn_key guard (it only stamps a
406
+ * marker turn_key that actually has a turns row) is satisfied. In production
407
+ * the gateway writes this row via recordTurnStart at turn-start.
408
+ */
409
+ function seedTurn(turnKey: string, chatId = '12345', threadId: string | null = null) {
410
+ const { Database } = require('bun:sqlite') as {
411
+ Database: new (path: string) => {
412
+ prepare(sql: string): { run(...p: unknown[]): unknown }
413
+ exec(sql: string): void
414
+ close(): void
415
+ }
416
+ }
417
+ const db = new Database(dbPath)
418
+ db.exec(
419
+ `CREATE TABLE IF NOT EXISTS turns (
420
+ turn_key TEXT PRIMARY KEY, chat_id TEXT, thread_id TEXT,
421
+ started_at INTEGER, ended_at INTEGER, created_at INTEGER, updated_at INTEGER
422
+ )`,
423
+ )
424
+ const now = Date.now()
425
+ db.prepare(
426
+ 'INSERT OR IGNORE INTO turns (turn_key, chat_id, thread_id, started_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
427
+ ).run(turnKey, chatId, threadId, now, now, now)
428
+ db.close()
429
+ }
430
+
431
+ describe('Bug 5 — parent_turn_key stamped from the turn-active marker', () => {
432
+ it('stamps parent_turn_key = marker.turnKey when a turn is active', () => {
433
+ // Supergroup forum-topic turn_key (chat:thread:startedAt).
434
+ const turnKey = '-1003831053471:4:1780370238492'
435
+ seedTurn(turnKey, '-1003831053471', '4')
436
+ writeTurnActiveMarker(turnKey, '-1003831053471', '4')
437
+
400
438
  const event = {
401
439
  session_id: 'sess-turnkey',
402
- turn_id: 'turn-abc-001',
403
440
  tool_name: 'Agent',
404
441
  tool_use_id: 'toolu_turnkey001',
405
442
  tool_input: { description: 'Task with turn context', run_in_background: false },
406
443
  }
407
-
408
444
  const result = runHook(PRETOOL_SCRIPT, event)
409
445
  expect(result.status).toBe(0)
410
446
 
@@ -414,18 +450,43 @@ describe('Bug 5 — parent_turn_key backfilled by gateway, not the hook', () =>
414
450
  | undefined
415
451
 
416
452
  expect(row).toBeDefined()
417
- // The hook never trusts event.turn_id — gateway backfill owns this column.
453
+ expect(row!.parent_turn_key).toBe(turnKey)
454
+ })
455
+
456
+ it('downgrades to NULL when the marker names a turn_key with no turns row (phantom-marker guard)', () => {
457
+ // The gateway writes the marker even if recordTurnStart's INSERT failed, so
458
+ // a marker can point at a turn_key with no row. Stamping it would mis-route
459
+ // the worker card AND block the watcher backfill (NULL guard). The hook must
460
+ // verify the row exists and fall back to NULL.
461
+ seedTurn('12345:_:1780000000000') // a DIFFERENT, real turn exists…
462
+ writeTurnActiveMarker('12345:_:9999999999999') // …but the marker names a phantom.
463
+
464
+ const event = {
465
+ session_id: 'sess-phantom',
466
+ tool_name: 'Agent',
467
+ tool_use_id: 'toolu_phantom001',
468
+ tool_input: { description: 'Task', run_in_background: false },
469
+ }
470
+ const result = runHook(PRETOOL_SCRIPT, event)
471
+ expect(result.status).toBe(0)
472
+
473
+ const db = openDb()
474
+ const row = db.prepare('SELECT parent_turn_key FROM subagents WHERE id = ?').get('toolu_phantom001') as
475
+ | { parent_turn_key: string | null }
476
+ | undefined
477
+ expect(row).toBeDefined()
418
478
  expect(row!.parent_turn_key).toBeNull()
419
479
  })
420
480
 
421
- it('pretool stores parent_turn_key as NULL when turn_id absent (no regression)', () => {
481
+ it('writes parent_turn_key=NULL when no turn is active (gateway backfill fallback)', () => {
482
+ // No marker written → no active turn → hook leaves NULL and the gateway's
483
+ // started_at backfill remains the fallback (today's behaviour).
422
484
  const event = {
423
485
  session_id: 'sess-noturnkey',
424
486
  tool_name: 'Agent',
425
487
  tool_use_id: 'toolu_noturn001',
426
488
  tool_input: { description: 'Task without turn context', run_in_background: false },
427
489
  }
428
-
429
490
  runHook(PRETOOL_SCRIPT, event)
430
491
 
431
492
  const db = openDb()
@@ -434,7 +495,47 @@ describe('Bug 5 — parent_turn_key backfilled by gateway, not the hook', () =>
434
495
  | undefined
435
496
 
436
497
  expect(row).toBeDefined()
437
- // When no turn_id in event, parent_turn_key should be NULL — no crash
498
+ expect(row!.parent_turn_key).toBeNull()
499
+ })
500
+
501
+ it('ignores event.turn_id — only the marker is authoritative', () => {
502
+ // A future CLI populating event.turn_id must NOT be trusted: it is Claude
503
+ // Code's session turn, never a gateway turns.turn_key. With no marker the
504
+ // result is NULL regardless of turn_id.
505
+ const event = {
506
+ session_id: 'sess-turnid-only',
507
+ turn_id: 'turn-abc-001',
508
+ tool_name: 'Agent',
509
+ tool_use_id: 'toolu_turnid001',
510
+ tool_input: { description: 'Task', run_in_background: false },
511
+ }
512
+ runHook(PRETOOL_SCRIPT, event)
513
+
514
+ const db = openDb()
515
+ const row = db.prepare('SELECT parent_turn_key FROM subagents WHERE id = ?').get('toolu_turnid001') as
516
+ | { parent_turn_key: string | null }
517
+ | undefined
518
+
519
+ expect(row).toBeDefined()
520
+ expect(row!.parent_turn_key).toBeNull()
521
+ })
522
+
523
+ it('a malformed marker degrades to NULL (never crashes the dispatch)', () => {
524
+ writeFileSync(join(agentDir, 'telegram', 'turn-active.json'), '{ not valid json')
525
+ const event = {
526
+ session_id: 'sess-badmarker',
527
+ tool_name: 'Agent',
528
+ tool_use_id: 'toolu_badmarker001',
529
+ tool_input: { description: 'Task', run_in_background: false },
530
+ }
531
+ const result = runHook(PRETOOL_SCRIPT, event)
532
+ expect(result.status).toBe(0)
533
+
534
+ const db = openDb()
535
+ const row = db.prepare('SELECT parent_turn_key FROM subagents WHERE id = ?').get('toolu_badmarker001') as
536
+ | { parent_turn_key: string | null }
537
+ | undefined
538
+ expect(row).toBeDefined()
438
539
  expect(row!.parent_turn_key).toBeNull()
439
540
  })
440
541
 
@@ -344,6 +344,117 @@ describe('subagent-tracker-posttool', () => {
344
344
  expect(row?.background).toBe(0)
345
345
  expect(row?.status).toBe('completed')
346
346
  })
347
+
348
+ // ─── async-launch ACK contract — drift tolerance (#2084) ────────────────────
349
+ // Tier 3 of isAsyncLaunchAck keys on the functional `agentId: <stem>` token
350
+ // (the most wording-stable part of the ACK) so promotion survives a
351
+ // claude-code bump that rewords BOTH the launch verb AND the "working in the
352
+ // background" phrase. The context-word requirement keeps it from tripping on
353
+ // a foreground report that merely mentions an agentId.
354
+ it('promotes on reworded ACK prose when the agentId token + a context word survive', () => {
355
+ runHook(PRETOOL_SCRIPT, {
356
+ session_id: 's-drift',
357
+ tool_name: 'Agent',
358
+ tool_use_id: 'toolu_drift1',
359
+ tool_input: { subagent_type: 'worker', description: 'Drifted ACK' },
360
+ })
361
+ // Neither "async agent launched" nor "working in the background" — a
362
+ // hypothetical reworded ACK — but the agentId token + "background" remain.
363
+ const postResult = runHook(POSTTOOL_SCRIPT, {
364
+ tool_name: 'Agent',
365
+ tool_use_id: 'toolu_drift1',
366
+ tool_response: {
367
+ content: [{ type: 'text', text: 'Background worker started.\nagentId: drift-7f3a91\nYou will be notified when it finishes.' }],
368
+ },
369
+ })
370
+ expect(postResult.status).toBe(0)
371
+ expect(postResult.stdout).not.toContain('additionalContext')
372
+
373
+ const db = openDb()
374
+ const row = db.prepare('SELECT background, status, ended_at FROM subagents WHERE id = ?').get('toolu_drift1') as
375
+ | { background: number; status: string; ended_at: number | null }
376
+ | undefined
377
+ expect(row?.background).toBe(1)
378
+ expect(row?.status).toBe('running')
379
+ expect(row?.ended_at == null).toBe(true)
380
+ })
381
+
382
+ it('does NOT promote when an agentId token appears without any launch/background context word', () => {
383
+ // A genuine foreground report mentioning an agentId in passing — no
384
+ // launch/background/async/dispatch word — must terminalize, not promote.
385
+ runHook(PRETOOL_SCRIPT, {
386
+ session_id: 's-falsepos',
387
+ tool_name: 'Agent',
388
+ tool_use_id: 'toolu_falsepos1',
389
+ tool_input: { subagent_type: 'worker', description: 'Foreground lookup', run_in_background: false },
390
+ })
391
+ const postResult = runHook(POSTTOOL_SCRIPT, {
392
+ tool_name: 'Agent',
393
+ tool_use_id: 'toolu_falsepos1',
394
+ tool_response: { result: 'Done. Verified the record; agentId: svc-42 is valid and active.', is_error: false },
395
+ })
396
+ expect(postResult.status).toBe(0)
397
+ expect(postResult.stdout).toContain('additionalContext')
398
+
399
+ const db = openDb()
400
+ const row = db.prepare('SELECT background, status FROM subagents WHERE id = ?').get('toolu_falsepos1') as
401
+ | { background: number; status: string }
402
+ | undefined
403
+ expect(row?.background).toBe(0)
404
+ expect(row?.status).toBe('completed')
405
+ })
406
+
407
+ it('does NOT promote a foreground orchestrator narrative that embeds an agentId mid-sentence', () => {
408
+ // The #2085-review false-positive: a coordinator agent reporting on other
409
+ // agents. The agentId is mid-sentence (not on its own line), so tier 3's
410
+ // own-line anchor rejects it even though "background"/"dispatching" appear.
411
+ runHook(PRETOOL_SCRIPT, {
412
+ session_id: 's-orch',
413
+ tool_name: 'Agent',
414
+ tool_use_id: 'toolu_orch1',
415
+ tool_input: { subagent_type: 'worker', description: 'Coordinator', run_in_background: false },
416
+ })
417
+ const postResult = runHook(POSTTOOL_SCRIPT, {
418
+ tool_name: 'Agent',
419
+ tool_use_id: 'toolu_orch1',
420
+ tool_response: { result: 'agentId: coord-x is managing background work and dispatching checks.', is_error: false },
421
+ })
422
+ expect(postResult.status).toBe(0)
423
+ expect(postResult.stdout).toContain('additionalContext')
424
+
425
+ const db = openDb()
426
+ const row = db.prepare('SELECT background, status FROM subagents WHERE id = ?').get('toolu_orch1') as
427
+ | { background: number; status: string }
428
+ | undefined
429
+ expect(row?.background).toBe(0)
430
+ expect(row?.status).toBe('completed')
431
+ })
432
+
433
+ it('does NOT promote an own-line agentId with no launch/background context word (boundary)', () => {
434
+ // Documents the accepted tier-3 boundary: an own-line bare agentId alone
435
+ // (no background/launch/dispatch/async/notify word) is not enough to
436
+ // promote — guards a foreground report that prints an id on its own line.
437
+ runHook(PRETOOL_SCRIPT, {
438
+ session_id: 's-bound',
439
+ tool_name: 'Agent',
440
+ tool_use_id: 'toolu_bound1',
441
+ tool_input: { subagent_type: 'worker', description: 'Infra', run_in_background: false },
442
+ })
443
+ const postResult = runHook(POSTTOOL_SCRIPT, {
444
+ tool_name: 'Agent',
445
+ tool_use_id: 'toolu_bound1',
446
+ tool_response: { content: [{ type: 'text', text: 'Created the worker.\nagentId: svc-99\nIt is ready.' }] },
447
+ })
448
+ expect(postResult.status).toBe(0)
449
+ expect(postResult.stdout).toContain('additionalContext')
450
+
451
+ const db = openDb()
452
+ const row = db.prepare('SELECT background, status FROM subagents WHERE id = ?').get('toolu_bound1') as
453
+ | { background: number; status: string }
454
+ | undefined
455
+ expect(row?.background).toBe(0)
456
+ expect(row?.status).toBe('completed')
457
+ })
347
458
  })
348
459
 
349
460
  describe('agent-dir resolution (RFC §Bug 2)', () => {