switchroom 0.18.19 → 0.18.21
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/cli/ms-365-write-pretool.mjs +92 -20
- package/dist/cli/switchroom.js +59 -6
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
- package/profiles/_shared/dev-protocol.md.hbs +2 -0
- package/profiles/_shared/execution-discipline.md.hbs +2 -2
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/answer-ready-flush.ts +187 -0
- package/telegram-plugin/dist/gateway/gateway.js +1114 -184
- package/telegram-plugin/format.ts +179 -20
- package/telegram-plugin/gateway/cron-session.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +794 -106
- package/telegram-plugin/gateway/idle-clear.ts +170 -0
- package/telegram-plugin/gateway/inject-handler.ts +11 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -9
- package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
- package/telegram-plugin/gateway/turn-record-status.ts +134 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
- package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
- package/telegram-plugin/narrative-flush.ts +181 -0
- package/telegram-plugin/pending-work-progress.ts +65 -1
- package/telegram-plugin/registry/subagents-schema.ts +6 -0
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/silent-end.ts +182 -0
- package/telegram-plugin/stream-reply-handler.ts +14 -5
- package/telegram-plugin/subagent-watcher.ts +330 -82
- package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
- package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
- package/telegram-plugin/tests/format-consistency.test.ts +54 -34
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +6 -5
- package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
- package/telegram-plugin/tests/idle-clear.test.ts +315 -37
- package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
- package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
- package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +20 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +5 -4
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +100 -42
- package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
- package/telegram-plugin/tests/silent-end.test.ts +296 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +12 -9
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
- package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
- package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +220 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
- package/telegram-plugin/tests/telegram-format.test.ts +36 -23
- package/telegram-plugin/tests/turn-flush-safety.test.ts +21 -17
- package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
- package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
- package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +165 -0
- package/telegram-plugin/tool-activity-summary.ts +78 -16
- package/telegram-plugin/turn-flush-safety.ts +4 -4
- package/telegram-plugin/worker-activity-feed.ts +181 -30
|
@@ -16,31 +16,89 @@ var GATEWAY_SOCKET = process.env.SWITCHROOM_GATEWAY_SOCKET ?? (process.env.TELEG
|
|
|
16
16
|
var KERNEL_SOCKET = process.env.SWITCHROOM_KERNEL_SOCKET ?? "/run/switchroom/kernel/sock";
|
|
17
17
|
var TOOL_PREFIX = "mcp__ms-365__";
|
|
18
18
|
var GATED_MS365_WRITE_TOOLS = new Set([
|
|
19
|
+
"create-calendar",
|
|
20
|
+
"update-calendar",
|
|
21
|
+
"delete-calendar",
|
|
22
|
+
"create-calendar-event",
|
|
23
|
+
"create-specific-calendar-event",
|
|
24
|
+
"update-calendar-event",
|
|
25
|
+
"update-specific-calendar-event",
|
|
26
|
+
"delete-calendar-event",
|
|
27
|
+
"delete-specific-calendar-event",
|
|
28
|
+
"accept-calendar-event",
|
|
29
|
+
"decline-calendar-event",
|
|
30
|
+
"tentatively-accept-calendar-event",
|
|
31
|
+
"cancel-calendar-event",
|
|
32
|
+
"forward-calendar-event",
|
|
33
|
+
"dismiss-calendar-event-reminder",
|
|
34
|
+
"snooze-calendar-event-reminder",
|
|
35
|
+
"create-my-calendar-permission",
|
|
36
|
+
"update-my-calendar-permission",
|
|
37
|
+
"delete-my-calendar-permission",
|
|
38
|
+
"send-mail",
|
|
39
|
+
"send",
|
|
40
|
+
"update-mail-message",
|
|
41
|
+
"delete-mail-message",
|
|
42
|
+
"move-mail-message",
|
|
43
|
+
"copy-mail-message",
|
|
44
|
+
"reply-mail-message",
|
|
45
|
+
"reply-all-mail-message",
|
|
46
|
+
"forward-mail-message",
|
|
47
|
+
"create-mail-attachment-upload-session",
|
|
48
|
+
"add-mail-attachment",
|
|
49
|
+
"delete-mail-attachment",
|
|
50
|
+
"create-mail-folder",
|
|
51
|
+
"create-mail-child-folder",
|
|
52
|
+
"update-mail-folder",
|
|
53
|
+
"delete-mail-folder",
|
|
54
|
+
"create-mail-rule",
|
|
55
|
+
"update-mail-rule",
|
|
56
|
+
"delete-mail-rule",
|
|
57
|
+
"update-mailbox-settings",
|
|
19
58
|
"upload-file-content",
|
|
20
59
|
"create-upload-session",
|
|
21
|
-
"create-
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
60
|
+
"create-onedrive-folder",
|
|
61
|
+
"delete-onedrive-file",
|
|
62
|
+
"move-rename-onedrive-item",
|
|
63
|
+
"copy-drive-item",
|
|
64
|
+
"share-drive-item",
|
|
65
|
+
"create-drive-item-share-link",
|
|
66
|
+
"delete-drive-item-permission"
|
|
26
67
|
]);
|
|
27
68
|
var KNOWN_SAFE_MS365_READ_TOOLS = new Set([
|
|
28
|
-
"list-files",
|
|
29
|
-
"list-drive-items",
|
|
30
|
-
"get-drive-item",
|
|
31
|
-
"download-bytes",
|
|
32
|
-
"search-files",
|
|
33
|
-
"list-events",
|
|
34
|
-
"get-event",
|
|
35
69
|
"list-calendars",
|
|
36
|
-
"get-calendar",
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
70
|
+
"get-calendar-view",
|
|
71
|
+
"get-specific-calendar-view",
|
|
72
|
+
"list-calendar-events",
|
|
73
|
+
"list-specific-calendar-events",
|
|
74
|
+
"get-calendar-event",
|
|
75
|
+
"get-specific-calendar-event",
|
|
76
|
+
"list-calendar-event-instances",
|
|
77
|
+
"list-calendar-events-delta",
|
|
78
|
+
"list-calendar-view-delta",
|
|
79
|
+
"list-my-calendar-permissions",
|
|
80
|
+
"list-mail-messages",
|
|
81
|
+
"get-mail-message",
|
|
82
|
+
"get-mail-message-mime",
|
|
40
83
|
"list-mail-folders",
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
84
|
+
"list-mail-child-folders",
|
|
85
|
+
"list-mail-folder-messages",
|
|
86
|
+
"list-mail-folder-messages-delta",
|
|
87
|
+
"list-mail-attachments",
|
|
88
|
+
"list-mail-rules",
|
|
89
|
+
"get-mail-tips",
|
|
90
|
+
"get-mailbox-settings",
|
|
91
|
+
"create-draft-email",
|
|
92
|
+
"get-drive-item",
|
|
93
|
+
"get-drive-root-item",
|
|
94
|
+
"download-bytes",
|
|
95
|
+
"list-drives",
|
|
96
|
+
"list-folder-files",
|
|
97
|
+
"search-onedrive-files",
|
|
98
|
+
"get-drive-delta",
|
|
99
|
+
"login",
|
|
100
|
+
"verify-login",
|
|
101
|
+
"list-accounts"
|
|
44
102
|
]);
|
|
45
103
|
function isGatedMs365Tool(toolName) {
|
|
46
104
|
if (!toolName.startsWith(TOOL_PREFIX))
|
|
@@ -52,6 +110,18 @@ function isGatedMs365Tool(toolName) {
|
|
|
52
110
|
return false;
|
|
53
111
|
return true;
|
|
54
112
|
}
|
|
113
|
+
function loadAllowFrom() {
|
|
114
|
+
const stateDir = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), ".claude", "channels", "telegram");
|
|
115
|
+
const accessPath = join(stateDir, "access.json");
|
|
116
|
+
try {
|
|
117
|
+
const raw = readFileSync(accessPath, "utf8");
|
|
118
|
+
const j = JSON.parse(raw);
|
|
119
|
+
if (Array.isArray(j.allowFrom)) {
|
|
120
|
+
return j.allowFrom.filter((s) => typeof s === "string");
|
|
121
|
+
}
|
|
122
|
+
} catch {}
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
55
125
|
function readStdin() {
|
|
56
126
|
try {
|
|
57
127
|
return readFileSync(0, "utf8");
|
|
@@ -254,9 +324,10 @@ async function main() {
|
|
|
254
324
|
}
|
|
255
325
|
const requestId = response.requestId;
|
|
256
326
|
const deadline = response.expiresAtMs ?? Date.now() + HOOK_TIMEOUT_MS;
|
|
327
|
+
const approverSet = loadAllowFrom();
|
|
257
328
|
while (Date.now() < deadline) {
|
|
258
329
|
await new Promise((r) => setTimeout(r, KERNEL_POLL_INTERVAL_MS));
|
|
259
|
-
const lookup = await approvalLookupByRequest(agentName, requestId,
|
|
330
|
+
const lookup = await approvalLookupByRequest(agentName, requestId, approverSet);
|
|
260
331
|
if (!lookup)
|
|
261
332
|
continue;
|
|
262
333
|
const state = lookup.state;
|
|
@@ -275,6 +346,7 @@ if (__require.main == __require.module) {
|
|
|
275
346
|
});
|
|
276
347
|
}
|
|
277
348
|
export {
|
|
349
|
+
loadAllowFrom,
|
|
278
350
|
isGatedMs365Tool,
|
|
279
351
|
extractMs365Preview,
|
|
280
352
|
KNOWN_SAFE_MS365_READ_TOOLS,
|
package/dist/cli/switchroom.js
CHANGED
|
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
|
|
|
2120
2120
|
});
|
|
2121
2121
|
|
|
2122
2122
|
// src/build-info.ts
|
|
2123
|
-
var VERSION = "0.18.
|
|
2123
|
+
var VERSION = "0.18.21", COMMIT_SHA = "c237ff59";
|
|
2124
2124
|
|
|
2125
2125
|
// src/cli/resolve-version.ts
|
|
2126
2126
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -15464,11 +15464,12 @@ async function injectSlashCommand(agentName, command, opts = {}) {
|
|
|
15464
15464
|
session,
|
|
15465
15465
|
command: command.trim(),
|
|
15466
15466
|
settleMs,
|
|
15467
|
-
timeoutMs
|
|
15467
|
+
timeoutMs,
|
|
15468
|
+
precondition: opts.precondition
|
|
15468
15469
|
}));
|
|
15469
15470
|
}
|
|
15470
15471
|
async function injectSlashCommandWith(runner, args) {
|
|
15471
|
-
const { socket, session, command, settleMs, timeoutMs } = args;
|
|
15472
|
+
const { socket, session, command, settleMs, timeoutMs, precondition } = args;
|
|
15472
15473
|
let bareVerb;
|
|
15473
15474
|
try {
|
|
15474
15475
|
bareVerb = validateInjectCommand(command);
|
|
@@ -15498,6 +15499,17 @@ async function injectSlashCommandWith(runner, args) {
|
|
|
15498
15499
|
errorMessage: `tmux session "${session}" on socket "${socket}" not found. ` + `Is the agent running under the tmux supervisor (the default)? ` + `If experimental.legacy_pty=true is set, inject is unsupported.`
|
|
15499
15500
|
};
|
|
15500
15501
|
}
|
|
15502
|
+
if (precondition && !precondition()) {
|
|
15503
|
+
return {
|
|
15504
|
+
outcome: "skipped",
|
|
15505
|
+
output: "",
|
|
15506
|
+
truncated: false,
|
|
15507
|
+
command: bareVerb,
|
|
15508
|
+
meta,
|
|
15509
|
+
errorCode: "precondition_failed",
|
|
15510
|
+
errorMessage: "inject precondition returned false at write time; send aborted " + "(no keys sent)."
|
|
15511
|
+
};
|
|
15512
|
+
}
|
|
15501
15513
|
const before = runner.capture(socket, session) ?? "";
|
|
15502
15514
|
try {
|
|
15503
15515
|
runner.send(socket, session, ["send-keys", "-l", command]);
|
|
@@ -23787,6 +23799,14 @@ function renderDevProtocolFragment(context = {}, profilesRoot = PROFILES_ROOT) {
|
|
|
23787
23799
|
const template = import_handlebars.default.compile(source, { noEscape: true });
|
|
23788
23800
|
return template(context).trimEnd();
|
|
23789
23801
|
}
|
|
23802
|
+
function renderDelegationGoldenRuleFragment(context = {}, profilesRoot = PROFILES_ROOT) {
|
|
23803
|
+
const fragPath = join4(resolve4(profilesRoot, "_shared"), "delegation-golden-rule.md.hbs");
|
|
23804
|
+
if (!existsSync7(fragPath))
|
|
23805
|
+
return "";
|
|
23806
|
+
const source = readFileSync7(fragPath, "utf-8");
|
|
23807
|
+
const template = import_handlebars.default.compile(source, { noEscape: true });
|
|
23808
|
+
return template(context).trimEnd();
|
|
23809
|
+
}
|
|
23790
23810
|
function renderReplyDisciplineFragment(context = {}, profilesRoot = PROFILES_ROOT) {
|
|
23791
23811
|
const fragPath = join4(resolve4(profilesRoot, "_shared"), "reply-discipline.md.hbs");
|
|
23792
23812
|
if (!existsSync7(fragPath))
|
|
@@ -27567,6 +27587,13 @@ function scaffoldAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchro
|
|
|
27567
27587
|
rendered = rendered.trimEnd() + `
|
|
27568
27588
|
|
|
27569
27589
|
` + devProtocol + `
|
|
27590
|
+
`;
|
|
27591
|
+
}
|
|
27592
|
+
const delegationGoldenRule = renderDelegationGoldenRuleFragment(context);
|
|
27593
|
+
if (delegationGoldenRule) {
|
|
27594
|
+
rendered = rendered.trimEnd() + `
|
|
27595
|
+
|
|
27596
|
+
` + delegationGoldenRule + `
|
|
27570
27597
|
`;
|
|
27571
27598
|
}
|
|
27572
27599
|
}
|
|
@@ -28450,6 +28477,13 @@ ${baseAppend}` : TELEGRAM_FORMATTING_FLOOR_CARD;
|
|
|
28450
28477
|
rendered = rendered.trimEnd() + `
|
|
28451
28478
|
|
|
28452
28479
|
` + devProtocol + `
|
|
28480
|
+
`;
|
|
28481
|
+
}
|
|
28482
|
+
const delegationGoldenRule = renderDelegationGoldenRuleFragment(claudeContext);
|
|
28483
|
+
if (delegationGoldenRule) {
|
|
28484
|
+
rendered = rendered.trimEnd() + `
|
|
28485
|
+
|
|
28486
|
+
` + delegationGoldenRule + `
|
|
28453
28487
|
`;
|
|
28454
28488
|
}
|
|
28455
28489
|
if (agentConfig.claude_md_raw) {
|
|
@@ -63192,7 +63226,15 @@ function detectTurnFindings(agent, turns) {
|
|
|
63192
63226
|
const dur = typeof t.duration_ms === "number" ? t.duration_ms : 0;
|
|
63193
63227
|
const synthetic = tid.includes("synthetic-");
|
|
63194
63228
|
const ts = isoFromTs(t.ts);
|
|
63195
|
-
if (st
|
|
63229
|
+
if (st === "send_failed") {
|
|
63230
|
+
findings.push({
|
|
63231
|
+
signal: "send-failed-delivery",
|
|
63232
|
+
agent,
|
|
63233
|
+
turn_id: tid,
|
|
63234
|
+
log_pointer: `turns.jsonl:${tid} status=send_failed`,
|
|
63235
|
+
ts
|
|
63236
|
+
});
|
|
63237
|
+
} else if (st !== "complete" && st !== "no_reply") {
|
|
63196
63238
|
findings.push({
|
|
63197
63239
|
signal: "killed-incomplete-turn",
|
|
63198
63240
|
agent,
|
|
@@ -63268,7 +63310,7 @@ function scanAgent(agent, turnsText, gatewayText) {
|
|
|
63268
63310
|
const turnFindings = detectTurnFindings(agent, turns);
|
|
63269
63311
|
const { findings: gwFindings, gw_hits } = detectGatewayFindings(agent, gatewayText);
|
|
63270
63312
|
const findings = [...turnFindings, ...gwFindings];
|
|
63271
|
-
const escalate = turnFindings.some((f) => f.signal === "killed-incomplete-turn" || f.signal === "hang-long-stalled" || f.signal === "silent-no-op-candidate") || gw_hits["duplicate-delivery-represent"] > 0 || gw_hits["reply-delivery-failure"] > 0;
|
|
63313
|
+
const escalate = turnFindings.some((f) => f.signal === "killed-incomplete-turn" || f.signal === "hang-long-stalled" || f.signal === "silent-no-op-candidate" || f.signal === "send-failed-delivery") || gw_hits["duplicate-delivery-represent"] > 0 || gw_hits["reply-delivery-failure"] > 0;
|
|
63272
63314
|
return { agent, turns: turns.length, status_mix, findings, gw_hits, escalate };
|
|
63273
63315
|
}
|
|
63274
63316
|
var HANG_MS = 360000, HANG_MAXTOOLS = 2, GATEWAY_SIGNATURES;
|
|
@@ -63346,6 +63388,12 @@ var init_mapping = __esm(() => {
|
|
|
63346
63388
|
job_spec: "steer-or-queue-mid-flight",
|
|
63347
63389
|
signature: "killed:incomplete-turn"
|
|
63348
63390
|
},
|
|
63391
|
+
"send-failed-delivery": {
|
|
63392
|
+
failure_mode: "success-theater",
|
|
63393
|
+
severity: 3,
|
|
63394
|
+
job_spec: "talk-to-agents-from-anywhere",
|
|
63395
|
+
signature: "send-failed:turn-flush-backstop"
|
|
63396
|
+
},
|
|
63349
63397
|
"represent-escalation": {
|
|
63350
63398
|
failure_mode: "drift",
|
|
63351
63399
|
severity: 1,
|
|
@@ -66775,6 +66823,10 @@ Scaffolding agent: ${name}
|
|
|
66775
66823
|
}
|
|
66776
66824
|
return;
|
|
66777
66825
|
}
|
|
66826
|
+
if (result.outcome === "skipped") {
|
|
66827
|
+
console.log(source_default.yellow(`\u21b7 ${result.command} \u2014 skipped (precondition not met at send time)`));
|
|
66828
|
+
return;
|
|
66829
|
+
}
|
|
66778
66830
|
const code = result.errorCode ?? "tmux_failed";
|
|
66779
66831
|
const msg = result.errorMessage ?? "unknown error";
|
|
66780
66832
|
console.error(source_default.red(`inject failed (${code}): ${msg}`));
|
|
@@ -78749,6 +78801,7 @@ function applySubagentsSchema(db) {
|
|
|
78749
78801
|
db.exec("ALTER TABLE subagents ADD COLUMN model TEXT");
|
|
78750
78802
|
}
|
|
78751
78803
|
db.exec("CREATE INDEX IF NOT EXISTS subagents_jsonl_id ON subagents(jsonl_agent_id)");
|
|
78804
|
+
db.exec("CREATE INDEX IF NOT EXISTS subagents_parent_agent ON subagents(parent_agent_id)");
|
|
78752
78805
|
}
|
|
78753
78806
|
function mapSubagentRow(row) {
|
|
78754
78807
|
return {
|
|
@@ -81332,7 +81385,7 @@ async function onHermesMessage(ctx, raw) {
|
|
|
81332
81385
|
sendResponse(ctx, rpcErr(id, -32603, msg));
|
|
81333
81386
|
break;
|
|
81334
81387
|
}
|
|
81335
|
-
const output = injectResult.outcome === "ok" ? injectResult.output ?? "" : `*(${fullCommand} sent)*`;
|
|
81388
|
+
const output = injectResult.outcome === "ok" ? injectResult.output ?? "" : injectResult.outcome === "skipped" ? `*(${fullCommand} skipped \u2014 precondition not met)*` : `*(${fullCommand} sent)*`;
|
|
81336
81389
|
sendResponse(ctx, rpcOk(id, { ok: true, output }));
|
|
81337
81390
|
break;
|
|
81338
81391
|
}
|
|
@@ -26605,7 +26605,7 @@ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:f
|
|
|
26605
26605
|
import { dirname as dirname4, join as join7 } from "node:path";
|
|
26606
26606
|
|
|
26607
26607
|
// src/build-info.ts
|
|
26608
|
-
var VERSION = "0.18.
|
|
26608
|
+
var VERSION = "0.18.21";
|
|
26609
26609
|
|
|
26610
26610
|
// src/cli/resolve-version.ts
|
|
26611
26611
|
function readPackageVersion() {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchroom",
|
|
3
3
|
"//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
|
|
4
|
-
"version": "0.18.
|
|
4
|
+
"version": "0.18.21",
|
|
5
5
|
"description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
## Delegation — the last word
|
|
2
|
+
|
|
3
|
+
This is the tail reminder on purpose: whatever the grounding, execution-bias, and development-protocol guidance above told you to *do*, the default way to do execution-class work is to **delegate it, not to run it inline.**
|
|
4
|
+
|
|
5
|
+
- **Golden rule: when in doubt, delegate.** Any code change, research spanning 3+ file reads, file/report generation, build/deploy, or a task needing 3+ sequential tool calls without user input goes to a sub-agent (`@worker` for changes, `@researcher` for research, `@reviewer` for review) — per the Sub-Agent Delegation section. Unnecessary delegation costs a few tokens; a blocked foreground session costs the user's attention.
|
|
6
|
+
- **The main session is for conversation.** Keep your own turns short — dispatch and acknowledge. The user should never wait more than 10 seconds for a response. "Act in-turn" is satisfied by dispatching the worker, not by doing the work yourself.
|
|
7
|
+
- **Anti-pattern:** starting a task inline "because it's almost done", then racking up 5+ tool calls in the foreground. If it's execution-class, hand it off at the first tool call, not the fifth.
|
|
8
|
+
|
|
9
|
+
If no sub-agents are configured, do the work yourself.
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
How development work gets done here — orient, clarify, align, ship, communicate. These are procedural rules for any substantive coding, infra, or debugging task. For the long-form playbook (design reports, adversarial review structure, re-review verdicts), load the bundled `dev-protocol` skill before starting substantive development work.
|
|
4
4
|
|
|
5
|
+
**This protocol governs HOW delegated work is done, not a license to do it inline.** Substantive dev work is execution-class — per the Sub-Agent Delegation section it is dispatched to `@worker`/sub-agents so the foreground session stays free for the user. The orient / clarify / design-align / pipeline / communicate rules below bind that delegated work (and the sub-agent doing it); they are not an invitation for the main session to start editing, building, and testing in-turn instead of delegating.
|
|
6
|
+
|
|
5
7
|
### Orient — ground before you build
|
|
6
8
|
|
|
7
9
|
- **Validate, don't assume.** Read the actual code, config, and system state before forming a theory. Never assert a fact you haven't checked this turn.
|
|
@@ -14,5 +14,5 @@ When you genuinely can't verify something this turn, say so plainly ("I haven't
|
|
|
14
14
|
|
|
15
15
|
How you should decide what to do next. These are procedural rules, not vibe.
|
|
16
16
|
|
|
17
|
-
- **Act in-turn.** If the request is actionable,
|
|
18
|
-
- **Non-final turn:** use tools to advance, or ask the one clarifying question that unblocks safe progress. One question, not five.
|
|
17
|
+
- **Act in-turn.** If the request is actionable, move it forward this turn. Don't finish with a plan or promise when tools can advance it. **But "act" composes with the Sub-Agent Delegation rules — it does not override them:** for an execution-class task (any code change, research spanning 3+ file reads, multi-step infra, report generation — see that section), the in-turn act IS dispatching the sub-agent, not doing the work inline. Acting immediately and delegating are the same move here; keep your own turn short and let the worker execute.
|
|
18
|
+
- **Non-final turn:** use tools to advance (dispatch the worker when the task is execution-class), or ask the one clarifying question that unblocks safe progress. One question, not five.
|
|
@@ -49,7 +49,7 @@ Save proactively: architecture decisions, codebase patterns, conventions, known
|
|
|
49
49
|
|
|
50
50
|
## Sub-Agent Delegation
|
|
51
51
|
|
|
52
|
-
If sub-agents are configured, delegate implementation to `@worker` (background, own worktree)
|
|
52
|
+
**Golden rule: when in doubt, delegate.** The main session is for conversation; execution belongs in sub-agents. If sub-agents are configured, delegate implementation to `@worker` (background, own worktree), research spanning 3+ file reads to `@researcher` (background), and review to `@reviewer`. Unnecessary delegation costs a few tokens; a blocked foreground session costs the user's attention. Keep your turns short — dispatch and acknowledge quickly so you stay available for the user; the user should never wait more than 10 seconds for a response. Acting in-turn on an execution-class task means dispatching the worker, not doing the work inline.
|
|
53
53
|
|
|
54
54
|
If the user amends in-flight delegated work mid-turn, steer the running worker now (`SendMessage` to the worker by name, or by the agent id from its spawn result) instead of holding the update for handback — and say in your reply whether you folded the update into the running worker or queued it as a separate task; never classify silently. If unsure whether a message amends in-flight work, queue it and say so — queue is the default. If the steer lands too late (worker effectively done), say so and apply the update yourself.
|
|
55
55
|
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Answer-ready quiescence flush (PR A — "late-delivery" fix).
|
|
3
|
+
*
|
|
4
|
+
* A "silent no-op" turn ends by emitting its final answer as plain transcript
|
|
5
|
+
* `text` (never calling the reply tool). Today that answer is only delivered
|
|
6
|
+
* when claude's `turn_duration`/`turn_end` signal lands — a signal that is
|
|
7
|
+
* KNOWN-UNRELIABLE for terminal-text turns (gateway.ts:13616-13618,
|
|
8
|
+
* 12992-12993). When it doesn't land, delivery falls to the orphaned-reply
|
|
9
|
+
* backstop, whose fuse is itself re-armed by the very answer text it is waiting
|
|
10
|
+
* on (the terminal text stamps `lastStreamEventAt`, so `recentlyStreaming`
|
|
11
|
+
* keeps deferring the ~30 s fuse across the full ~120 s window). Net: a fully
|
|
12
|
+
* composed answer sits ~150-196 s of dead wait AFTER it was ready.
|
|
13
|
+
*
|
|
14
|
+
* This module provides the DETERMINISTIC positive signal that closes that gap:
|
|
15
|
+
* once a turn has a genuine composed terminal answer AND goes quiescent (no
|
|
16
|
+
* in-flight tool, no pending async/background dispatch, no new stream event)
|
|
17
|
+
* for a short debounce (~1 s), a per-turn timer fires and routes the answer
|
|
18
|
+
* into the SAME existing turn-flush send path immediately — instead of waiting
|
|
19
|
+
* on the unreliable turn_end or the multi-minute backstop.
|
|
20
|
+
*
|
|
21
|
+
* The arm/fire decision is factored here as a pure, injectable predicate so the
|
|
22
|
+
* oracle asserts real behavior (mirrors `turn-record-status.ts` /
|
|
23
|
+
* `context-exhaustion.ts`). The gateway feeds it the SAME `decideTurnFlush`
|
|
24
|
+
* classifier the turn-flush branch uses, so a working-preamble, an ack, a tool
|
|
25
|
+
* preamble, or a genuine NO_REPLY turn never arms a spurious flush.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { decideTurnFlush, type FlushDecisionInput } from './turn-flush-safety.js'
|
|
29
|
+
|
|
30
|
+
/** Default answer-ready quiescence debounce (ms). Ken approved ~1 s as
|
|
31
|
+
* "immediate". Env-tunable via SWITCHROOM_ANSWER_READY_FLUSH_MS; 0 (or any
|
|
32
|
+
* non-positive value) is the kill-switch that disables the flush entirely,
|
|
33
|
+
* matching the repo's env-flag convention. */
|
|
34
|
+
export const ANSWER_READY_FLUSH_MS = 1000
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the debounce window from the environment. Returns a positive integer
|
|
38
|
+
* ms, or 0 when disabled (kill-switch) / unparseable / non-positive. The
|
|
39
|
+
* gateway treats 0 as "never arm", so a bad env value fails safe to the default
|
|
40
|
+
* rather than to a hot-loop.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveAnswerReadyFlushMs(
|
|
43
|
+
env: Record<string, string | undefined>,
|
|
44
|
+
): number {
|
|
45
|
+
const raw = env.SWITCHROOM_ANSWER_READY_FLUSH_MS
|
|
46
|
+
if (raw == null || raw.trim() === '') return ANSWER_READY_FLUSH_MS
|
|
47
|
+
const n = Number(raw)
|
|
48
|
+
if (!Number.isFinite(n)) return ANSWER_READY_FLUSH_MS
|
|
49
|
+
// Explicit 0 (or negative) = operator kill-switch → disabled.
|
|
50
|
+
if (n <= 0) return 0
|
|
51
|
+
return Math.floor(n)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AnswerReadyArmInput {
|
|
55
|
+
/** The exact `decideTurnFlush` inputs the turn-flush branch resolves at
|
|
56
|
+
* turn_end. Reusing the classifier is what guarantees the debounce only
|
|
57
|
+
* fires on a genuine composed final answer — never a silent marker, an
|
|
58
|
+
* empty turn, a reply-already-served turn, or a sub-agent turn. */
|
|
59
|
+
flush: FlushDecisionInput
|
|
60
|
+
/** `toolFlightTracker.inFlightCount()` — a live surface tool means the model
|
|
61
|
+
* is still working, not quiescent. */
|
|
62
|
+
inFlightToolCount: number
|
|
63
|
+
/** `pendingProgress.hasPendingAsyncDispatch(key)` — a detached background
|
|
64
|
+
* dispatch (Bash run_in_background, async tool) means work is still pending
|
|
65
|
+
* even though `inFlightCount` is 0. */
|
|
66
|
+
hasPendingAsyncDispatch: boolean
|
|
67
|
+
/** The resolved debounce window; 0 disables (kill-switch). */
|
|
68
|
+
flushWindowMs: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Should the answer-ready quiescence timer be armed / fired right now?
|
|
73
|
+
*
|
|
74
|
+
* True iff the kill-switch is off AND the turn has a genuine flushable answer
|
|
75
|
+
* (`decideTurnFlush` → `flush`) AND the turn is genuinely quiescent (no
|
|
76
|
+
* in-flight surface tool, no pending async/background dispatch). This same
|
|
77
|
+
* predicate gates BOTH the arm (in the `text` handler) AND the fire-time
|
|
78
|
+
* re-verification (in the timer callback) — so a tool that started after the
|
|
79
|
+
* arm, or a reply that landed in the interim, deterministically cancels the
|
|
80
|
+
* flush at fire time even if the explicit disarm was somehow missed.
|
|
81
|
+
*/
|
|
82
|
+
export function shouldArmAnswerReadyFlush(input: AnswerReadyArmInput): boolean {
|
|
83
|
+
if (input.flushWindowMs <= 0) return false
|
|
84
|
+
if (input.inFlightToolCount > 0) return false
|
|
85
|
+
if (input.hasPendingAsyncDispatch) return false
|
|
86
|
+
return decideTurnFlush(input.flush).kind === 'flush'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Opaque timer handle. Real code passes Node's `setTimeout` return; tests can
|
|
90
|
+
* inject fake-timer handles. */
|
|
91
|
+
export type FlushTimerHandle = ReturnType<typeof setTimeout>
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Injectable dependencies for {@link AnswerReadyFlushController}. Everything the
|
|
95
|
+
* orchestration needs from the gateway is threaded through here so the REAL
|
|
96
|
+
* arm / debounce / rollover-guard / fire-time-re-verify / disarm logic is
|
|
97
|
+
* unit-testable without importing the 30k-line gateway module (which has a
|
|
98
|
+
* top-level startup IIFE and cannot be imported). `Turn` is the gateway's
|
|
99
|
+
* `CurrentTurn`; the controller only touches it through these accessors.
|
|
100
|
+
*/
|
|
101
|
+
export interface AnswerReadyFlushDeps<Turn> {
|
|
102
|
+
/** The live turn atom (gateway `currentTurn`). Read at arm time and re-read at
|
|
103
|
+
* fire time so a superseded turn's timer never fires against a fresh atom. */
|
|
104
|
+
getCurrentTurn(): Turn | null
|
|
105
|
+
/** Resolve the `shouldArmAnswerReadyFlush` inputs for a turn (chat/reply/
|
|
106
|
+
* captured-text + live tool-flight + pending-async + the window). */
|
|
107
|
+
getArmInput(turn: Turn): AnswerReadyArmInput
|
|
108
|
+
/** Read / write the per-turn timer handle (stored on the `CurrentTurn`). */
|
|
109
|
+
getTimerHandle(turn: Turn): FlushTimerHandle | null
|
|
110
|
+
setTimerHandle(turn: Turn, handle: FlushTimerHandle | null): void
|
|
111
|
+
/**
|
|
112
|
+
* Deliver the composed answer — dispatch the positive `answer-ready-quiescence`
|
|
113
|
+
* synthetic turn_end that routes through the EXISTING turn-flush send path
|
|
114
|
+
* (endCurrentTurnAtomic → send-gated IIFE → PR-B honest record). The controller
|
|
115
|
+
* calls this AT MOST ONCE per turn (guarded by the rollover + timer-cleared
|
|
116
|
+
* checks). endCurrentTurnAtomic then nulls the atom, so a later real turn_end
|
|
117
|
+
* short-circuits — the exactly-once guarantee.
|
|
118
|
+
*/
|
|
119
|
+
onFlush(turn: Turn): void
|
|
120
|
+
/** Injectable timer primitives (default to the globals). */
|
|
121
|
+
setTimeoutFn?: (fn: () => void, ms: number) => FlushTimerHandle
|
|
122
|
+
clearTimeoutFn?: (handle: FlushTimerHandle) => void
|
|
123
|
+
log?: (msg: string) => void
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The deterministic answer-ready quiescence flush orchestration, extracted from
|
|
128
|
+
* the gateway so it is testable as a unit (mirrors `withTurnEndGateBackstop`).
|
|
129
|
+
*
|
|
130
|
+
* - `reset()` — called from `case 'text'`: clear any pending timer, then (re)arm
|
|
131
|
+
* iff the turn currently classifies as a genuine flushable answer AND is
|
|
132
|
+
* quiescent. Each text chunk re-arms → the debounce.
|
|
133
|
+
* - `clear(turn)` — the DISARM: called on any tool activity and from
|
|
134
|
+
* `endCurrentTurnAtomic`. A real turn_end that lands first cancels a pending
|
|
135
|
+
* flush (exactly-once), and a resumed turn (tool started) cancels a stale one.
|
|
136
|
+
*
|
|
137
|
+
* On fire the controller re-pins `getCurrentTurn() === turn` (rollover guard),
|
|
138
|
+
* clears the handle, RE-VERIFIES quiescence (a tool that started / a reply that
|
|
139
|
+
* landed since the arm cancels the flush deterministically), then dispatches
|
|
140
|
+
* exactly one `onFlush`.
|
|
141
|
+
*/
|
|
142
|
+
export class AnswerReadyFlushController<Turn> {
|
|
143
|
+
constructor(private readonly deps: AnswerReadyFlushDeps<Turn>) {}
|
|
144
|
+
|
|
145
|
+
private get setTimeoutFn(): (fn: () => void, ms: number) => FlushTimerHandle {
|
|
146
|
+
return this.deps.setTimeoutFn ?? ((fn, ms) => setTimeout(fn, ms))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private get clearTimeoutFn(): (handle: FlushTimerHandle) => void {
|
|
150
|
+
return this.deps.clearTimeoutFn ?? ((h) => clearTimeout(h))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Disarm the flush timer for a turn (idempotent). */
|
|
154
|
+
clear(turn: Turn | null): void {
|
|
155
|
+
if (turn == null) return
|
|
156
|
+
const handle = this.deps.getTimerHandle(turn)
|
|
157
|
+
if (handle != null) {
|
|
158
|
+
this.clearTimeoutFn(handle)
|
|
159
|
+
this.deps.setTimerHandle(turn, null)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** (Re)arm the flush timer for the current turn — the debounce. */
|
|
164
|
+
reset(): void {
|
|
165
|
+
const turn = this.deps.getCurrentTurn()
|
|
166
|
+
this.clear(turn)
|
|
167
|
+
if (turn == null) return
|
|
168
|
+
const armInput = this.deps.getArmInput(turn)
|
|
169
|
+
if (!shouldArmAnswerReadyFlush(armInput)) return
|
|
170
|
+
const handle = this.setTimeoutFn(() => this.onExpiry(turn), armInput.flushWindowMs)
|
|
171
|
+
this.deps.setTimerHandle(turn, handle)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Timer-expiry callback. Re-pins the turn, re-verifies quiescence, fires once. */
|
|
175
|
+
private onExpiry(armedTurn: Turn): void {
|
|
176
|
+
const live = this.deps.getCurrentTurn()
|
|
177
|
+
// Rollover guard: a superseded turn's timer must not fire against a fresh atom.
|
|
178
|
+
if (live == null || live !== armedTurn) return
|
|
179
|
+
this.deps.setTimerHandle(live, null)
|
|
180
|
+
// Fire-time re-verification: a tool that started (or a reply that landed)
|
|
181
|
+
// since the arm deterministically cancels the flush even if the explicit
|
|
182
|
+
// disarm was somehow missed.
|
|
183
|
+
if (!shouldArmAnswerReadyFlush(this.deps.getArmInput(live))) return
|
|
184
|
+
this.deps.log?.('answer-ready quiescence flush — delivering composed terminal answer')
|
|
185
|
+
this.deps.onFlush(live)
|
|
186
|
+
}
|
|
187
|
+
}
|