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.
- package/dist/agent-scheduler/index.js +6 -8
- package/dist/auth-broker/index.js +6 -8
- package/dist/cli/notion-write-pretool.mjs +6 -8
- package/dist/cli/switchroom.js +82 -52
- package/dist/host-control/main.js +6 -8
- package/dist/vault/approvals/kernel-server.js +6 -8
- package/dist/vault/broker/server.js +6 -8
- package/package.json +3 -3
- package/telegram-plugin/dist/gateway/gateway.js +10 -12
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +41 -6
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +82 -11
- package/telegram-plugin/registry/subagents-bugs.test.ts +120 -19
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +111 -0
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +50 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
51800
|
-
var COMMIT_SHA = "
|
|
51801
|
-
var COMMIT_DATE = "2026-06-
|
|
51802
|
-
var LATEST_PR =
|
|
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
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
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
|
-
|
|
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
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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)', () => {
|