switchroom 0.14.3 → 0.14.5
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/switchroom.js +78 -4
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +54 -2
- package/telegram-plugin/dist/gateway/gateway.js +37 -10
- package/telegram-plugin/dist/server.js +54 -2
- package/telegram-plugin/gateway/gateway.ts +39 -8
- package/telegram-plugin/permission-rule.ts +22 -0
- package/telegram-plugin/session-tail.ts +69 -3
- package/telegram-plugin/tests/always-allow-grant.test.ts +147 -0
- package/telegram-plugin/tests/always-allow-persist.test.ts +124 -0
- package/telegram-plugin/tests/session-tail-first-attach.test.ts +65 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +36 -0
- package/telegram-plugin/tool-label-sidecar.ts +18 -0
- package/profiles/default/CLAUDE.md +0 -122
package/dist/cli/switchroom.js
CHANGED
|
@@ -30840,8 +30840,11 @@ function defaultStatBroker(p) {
|
|
|
30840
30840
|
return { kind: "ok-with-stat", ino: inoStr, size };
|
|
30841
30841
|
}
|
|
30842
30842
|
function spawnDockerStat(p) {
|
|
30843
|
+
return spawnDockerStatForContainer("switchroom-vault-broker", p);
|
|
30844
|
+
}
|
|
30845
|
+
function spawnDockerStatForContainer(containerName2, p) {
|
|
30843
30846
|
try {
|
|
30844
|
-
const stdout = execFileSync16("docker", ["exec",
|
|
30847
|
+
const stdout = execFileSync16("docker", ["exec", containerName2, "stat", "-c", "%i %s", p], { stdio: ["ignore", "pipe", "pipe"], timeout: 3000, encoding: "utf8" });
|
|
30845
30848
|
return { status: 0, stdout, stderr: "", error: null };
|
|
30846
30849
|
} catch (err) {
|
|
30847
30850
|
const e = err;
|
|
@@ -30933,9 +30936,80 @@ function runVaultBrokerDurabilityChecks(_config, opts) {
|
|
|
30933
30936
|
probeMachineIdMount(),
|
|
30934
30937
|
formatBindMountResult("vault-broker: vault.enc bind mount", join52(home2, ".switchroom", "vault", "vault.enc"), "/state/vault/vault.enc", probe2(join52(home2, ".switchroom", "vault", "vault.enc"), "/state/vault/vault.enc")),
|
|
30935
30938
|
formatBindMountResult("vault-broker: vault-grants.db bind mount (#1737)", join52(home2, ".switchroom", "vault-grants.db"), "/root/.switchroom/vault-grants.db", probe2(join52(home2, ".switchroom", "vault-grants.db"), "/root/.switchroom/vault-grants.db")),
|
|
30936
|
-
formatBindMountResult("vault-broker: vault-audit.log bind mount (#1025)", join52(home2, ".switchroom", "vault-audit.log"), "/root/.switchroom/vault-audit.log", probe2(join52(home2, ".switchroom", "vault-audit.log"), "/root/.switchroom/vault-audit.log"))
|
|
30939
|
+
formatBindMountResult("vault-broker: vault-audit.log bind mount (#1025)", join52(home2, ".switchroom", "vault-audit.log"), "/root/.switchroom/vault-audit.log", probe2(join52(home2, ".switchroom", "vault-audit.log"), "/root/.switchroom/vault-audit.log")),
|
|
30940
|
+
probeKernelDbDurability(home2, {
|
|
30941
|
+
statBroker: opts?.kernelStatBroker
|
|
30942
|
+
})
|
|
30937
30943
|
];
|
|
30938
30944
|
}
|
|
30945
|
+
function probeKernelDbDurability(home2, opts) {
|
|
30946
|
+
const hostDir = join52(home2, ".switchroom", "approvals");
|
|
30947
|
+
const containerDir = "/state/approvals";
|
|
30948
|
+
const name = "approval-kernel: approvals bind mount (allow_always durability)";
|
|
30949
|
+
const kernelStat = opts?.statBroker ?? defaultKernelStatBroker;
|
|
30950
|
+
const result = probeBindMountInode(hostDir, containerDir, {
|
|
30951
|
+
statBroker: kernelStat,
|
|
30952
|
+
statHost: opts?.statHost
|
|
30953
|
+
});
|
|
30954
|
+
if (result.kind === "ok") {
|
|
30955
|
+
return {
|
|
30956
|
+
name,
|
|
30957
|
+
status: "ok",
|
|
30958
|
+
detail: `${hostDir} == ${containerDir} (same inode) \u2014 allow_always decisions persist across kernel recreate`
|
|
30959
|
+
};
|
|
30960
|
+
}
|
|
30961
|
+
if (result.kind === "host-missing") {
|
|
30962
|
+
return {
|
|
30963
|
+
name,
|
|
30964
|
+
status: "warn",
|
|
30965
|
+
detail: `host directory ${hostDir} missing \u2014 \`switchroom apply\` pre-creates it on greenfield`,
|
|
30966
|
+
fix: "Run `switchroom apply` to pre-create the host approvals directory"
|
|
30967
|
+
};
|
|
30968
|
+
}
|
|
30969
|
+
if (result.kind === "broker-unreachable") {
|
|
30970
|
+
return {
|
|
30971
|
+
name,
|
|
30972
|
+
status: "skip",
|
|
30973
|
+
detail: "approval-kernel container unreachable \u2014 bind mount unverified"
|
|
30974
|
+
};
|
|
30975
|
+
}
|
|
30976
|
+
if (result.kind === "broker-stat-failed") {
|
|
30977
|
+
return {
|
|
30978
|
+
name,
|
|
30979
|
+
status: "warn",
|
|
30980
|
+
detail: `approval-kernel stat failed: ${result.msg}`
|
|
30981
|
+
};
|
|
30982
|
+
}
|
|
30983
|
+
return {
|
|
30984
|
+
name,
|
|
30985
|
+
status: "fail",
|
|
30986
|
+
detail: `inode mismatch \u2014 approval-kernel \`/state/approvals\` is NOT backed by the host bind mount. ` + `host inode=${result.hostInode} size=${result.hostSize}; ` + `kernel inode=${result.brokerInode} size=${result.brokerSize}. ` + `The kernel is writing kernel.db to an ephemeral container-local directory; ` + `all allow_always decisions are lost on every container recreate (e.g. after \`switchroom update\`).`,
|
|
30987
|
+
fix: "Run `switchroom apply` to regenerate compose with the " + "`~/.switchroom/approvals:/state/approvals` bind mount, then " + "`docker compose -p switchroom up -d approval-kernel` to recreate the kernel container."
|
|
30988
|
+
};
|
|
30989
|
+
}
|
|
30990
|
+
function defaultKernelStatBroker(p) {
|
|
30991
|
+
const r = spawnDockerStatForContainer("switchroom-approval-kernel", p);
|
|
30992
|
+
if (r.error || r.status === null)
|
|
30993
|
+
return { kind: "broker-unreachable" };
|
|
30994
|
+
if (r.status !== 0) {
|
|
30995
|
+
if (r.status >= 125)
|
|
30996
|
+
return { kind: "broker-unreachable" };
|
|
30997
|
+
return {
|
|
30998
|
+
kind: "broker-stat-failed",
|
|
30999
|
+
msg: r.stderr?.trim() || `exit ${r.status}`
|
|
31000
|
+
};
|
|
31001
|
+
}
|
|
31002
|
+
const out = r.stdout.trim();
|
|
31003
|
+
const [inoStr, sizeStr] = out.split(/\s+/);
|
|
31004
|
+
const size = Number(sizeStr);
|
|
31005
|
+
if (!inoStr || !Number.isFinite(size)) {
|
|
31006
|
+
return {
|
|
31007
|
+
kind: "broker-stat-failed",
|
|
31008
|
+
msg: `unparseable stat output: ${out}`
|
|
31009
|
+
};
|
|
31010
|
+
}
|
|
31011
|
+
return { kind: "ok-with-stat", ino: inoStr, size };
|
|
31012
|
+
}
|
|
30939
31013
|
function probeAutoUnlockBlob(home2) {
|
|
30940
31014
|
const blobPath = join52(home2, ".switchroom", "vault-auto-unlock");
|
|
30941
31015
|
if (!existsSync52(blobPath)) {
|
|
@@ -49278,8 +49352,8 @@ var {
|
|
|
49278
49352
|
} = import__.default;
|
|
49279
49353
|
|
|
49280
49354
|
// src/build-info.ts
|
|
49281
|
-
var VERSION = "0.14.
|
|
49282
|
-
var COMMIT_SHA = "
|
|
49355
|
+
var VERSION = "0.14.5";
|
|
49356
|
+
var COMMIT_SHA = "c12d4240";
|
|
49283
49357
|
|
|
49284
49358
|
// src/cli/agent.ts
|
|
49285
49359
|
init_source();
|
package/package.json
CHANGED
|
@@ -23041,6 +23041,7 @@ import { join as join2 } from "node:path";
|
|
|
23041
23041
|
function createToolLabelSidecar(opts) {
|
|
23042
23042
|
const path = join2(opts.stateDir, `tool-labels-${opts.sessionId}.jsonl`);
|
|
23043
23043
|
const labels = new Map;
|
|
23044
|
+
const seen = [];
|
|
23044
23045
|
const subscribers = new Set;
|
|
23045
23046
|
let offset = 0;
|
|
23046
23047
|
let stopped = false;
|
|
@@ -23068,6 +23069,7 @@ function createToolLabelSidecar(opts) {
|
|
|
23068
23069
|
if (labels.has(row.tool_use_id))
|
|
23069
23070
|
continue;
|
|
23070
23071
|
labels.set(row.tool_use_id, row.label);
|
|
23072
|
+
seen.push({ toolUseId: row.tool_use_id, label: row.label, toolName: row.tool_name });
|
|
23071
23073
|
for (const cb of subscribers) {
|
|
23072
23074
|
try {
|
|
23073
23075
|
cb(row.tool_use_id, row.label, row.tool_name);
|
|
@@ -23109,6 +23111,11 @@ function createToolLabelSidecar(opts) {
|
|
|
23109
23111
|
return labels.get(toolUseId);
|
|
23110
23112
|
},
|
|
23111
23113
|
onLabel(cb) {
|
|
23114
|
+
for (const r of seen) {
|
|
23115
|
+
try {
|
|
23116
|
+
cb(r.toolUseId, r.label, r.toolName);
|
|
23117
|
+
} catch {}
|
|
23118
|
+
}
|
|
23112
23119
|
subscribers.add(cb);
|
|
23113
23120
|
return () => subscribers.delete(cb);
|
|
23114
23121
|
},
|
|
@@ -23415,6 +23422,46 @@ function extractAssistantText(obj) {
|
|
|
23415
23422
|
}
|
|
23416
23423
|
return parts.join(" ").trim();
|
|
23417
23424
|
}
|
|
23425
|
+
function computeFirstAttachCursor(file, size) {
|
|
23426
|
+
const SCAN_CAP = 1024 * 1024;
|
|
23427
|
+
const scanStart = Math.max(0, size - SCAN_CAP);
|
|
23428
|
+
let buf;
|
|
23429
|
+
try {
|
|
23430
|
+
const fd = openSync(file, "r");
|
|
23431
|
+
try {
|
|
23432
|
+
buf = Buffer.allocUnsafe(size - scanStart);
|
|
23433
|
+
readSync(fd, buf, 0, buf.length, scanStart);
|
|
23434
|
+
} finally {
|
|
23435
|
+
closeSync(fd);
|
|
23436
|
+
}
|
|
23437
|
+
} catch {
|
|
23438
|
+
return size;
|
|
23439
|
+
}
|
|
23440
|
+
let lastEnqueueOffset = -1;
|
|
23441
|
+
let turnEndedAfterEnqueue = false;
|
|
23442
|
+
let lineStart = 0;
|
|
23443
|
+
let skipPartial = scanStart > 0;
|
|
23444
|
+
for (let i = 0;i <= buf.length; i++) {
|
|
23445
|
+
if (i !== buf.length && buf[i] !== 10)
|
|
23446
|
+
continue;
|
|
23447
|
+
if (skipPartial) {
|
|
23448
|
+
skipPartial = false;
|
|
23449
|
+
} else if (i > lineStart) {
|
|
23450
|
+
const line = buf.toString("utf8", lineStart, i);
|
|
23451
|
+
if (line.includes('"type":"queue-operation"') && line.includes('"operation":"enqueue"')) {
|
|
23452
|
+
lastEnqueueOffset = scanStart + lineStart;
|
|
23453
|
+
turnEndedAfterEnqueue = false;
|
|
23454
|
+
} else if (lastEnqueueOffset >= 0 && line.includes('"subtype":"turn_duration"')) {
|
|
23455
|
+
turnEndedAfterEnqueue = true;
|
|
23456
|
+
}
|
|
23457
|
+
}
|
|
23458
|
+
lineStart = i + 1;
|
|
23459
|
+
}
|
|
23460
|
+
if (lastEnqueueOffset >= 0 && !turnEndedAfterEnqueue) {
|
|
23461
|
+
return lastEnqueueOffset;
|
|
23462
|
+
}
|
|
23463
|
+
return size;
|
|
23464
|
+
}
|
|
23418
23465
|
function startSessionTail(config2) {
|
|
23419
23466
|
const cwd = config2.cwd ?? process.cwd();
|
|
23420
23467
|
const claudeHome = config2.claudeHome ?? process.env.CLAUDE_CONFIG_DIR ?? join3(homedir2(), ".claude");
|
|
@@ -23551,11 +23598,16 @@ function startSessionTail(config2) {
|
|
|
23551
23598
|
} else {
|
|
23552
23599
|
pendingPartial = "";
|
|
23553
23600
|
try {
|
|
23554
|
-
|
|
23601
|
+
const size = statSync3(file).size;
|
|
23602
|
+
cursor = computeFirstAttachCursor(file, size);
|
|
23603
|
+
if (cursor < size) {
|
|
23604
|
+
log?.(`session-tail: attached to ${file} (cursor=${cursor}, replaying in-flight turn from offset; size=${size})`);
|
|
23605
|
+
} else {
|
|
23606
|
+
log?.(`session-tail: attached to ${file} (cursor=${cursor})`);
|
|
23607
|
+
}
|
|
23555
23608
|
} catch {
|
|
23556
23609
|
cursor = 0;
|
|
23557
23610
|
}
|
|
23558
|
-
log?.(`session-tail: attached to ${file} (cursor=${cursor})`);
|
|
23559
23611
|
}
|
|
23560
23612
|
const attachSid = sessionIdForFile(file);
|
|
23561
23613
|
if (attachSid)
|
|
@@ -49745,6 +49745,9 @@ function skillBasenameFromPath2(input) {
|
|
|
49745
49745
|
const trimmed = path.replace(/\/SKILL\.md$/i, "").replace(/\/$/, "");
|
|
49746
49746
|
return basename6(trimmed) || null;
|
|
49747
49747
|
}
|
|
49748
|
+
function isRulePersisted(resolvedAllow, ruleRule) {
|
|
49749
|
+
return resolvedAllow.includes(ruleRule);
|
|
49750
|
+
}
|
|
49748
49751
|
|
|
49749
49752
|
// credits-watch.ts
|
|
49750
49753
|
import { readFileSync as readFileSync29, writeFileSync as writeFileSync18, existsSync as existsSync30, mkdirSync as mkdirSync16 } from "fs";
|
|
@@ -50065,11 +50068,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
50065
50068
|
}
|
|
50066
50069
|
|
|
50067
50070
|
// ../src/build-info.ts
|
|
50068
|
-
var VERSION = "0.14.
|
|
50069
|
-
var COMMIT_SHA = "
|
|
50070
|
-
var COMMIT_DATE = "2026-05-
|
|
50071
|
-
var LATEST_PR =
|
|
50072
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
50071
|
+
var VERSION = "0.14.5";
|
|
50072
|
+
var COMMIT_SHA = "c12d4240";
|
|
50073
|
+
var COMMIT_DATE = "2026-05-28T21:57:39+10:00";
|
|
50074
|
+
var LATEST_PR = null;
|
|
50075
|
+
var COMMITS_AHEAD_OF_TAG = 2;
|
|
50073
50076
|
|
|
50074
50077
|
// gateway/boot-version.ts
|
|
50075
50078
|
function formatRelativeAgo(iso) {
|
|
@@ -59034,20 +59037,44 @@ ${prettyInput}`;
|
|
|
59034
59037
|
return;
|
|
59035
59038
|
}
|
|
59036
59039
|
let grantOk = false;
|
|
59040
|
+
let grantFailReason = "";
|
|
59037
59041
|
try {
|
|
59038
59042
|
switchroomExec(["agent", "grant", agentName3, rule.rule, "--no-restart"]);
|
|
59039
|
-
|
|
59040
|
-
|
|
59043
|
+
try {
|
|
59044
|
+
const cfg = loadConfig2();
|
|
59045
|
+
const rawAgent = cfg.agents?.[agentName3];
|
|
59046
|
+
if (rawAgent) {
|
|
59047
|
+
const resolved = resolveAgentConfig2(cfg.defaults, cfg.profiles, rawAgent);
|
|
59048
|
+
const allowList = resolved.tools?.allow ?? [];
|
|
59049
|
+
if (isRulePersisted(allowList, rule.rule)) {
|
|
59050
|
+
grantOk = true;
|
|
59051
|
+
process.stderr.write(`telegram gateway: always-allow added rule="${rule.rule}" agent=${agentName3} (request_id=${request_id})
|
|
59052
|
+
`);
|
|
59053
|
+
} else {
|
|
59054
|
+
grantFailReason = `rule "${rule.rule}" not found in resolved tools.allow after write \u2014 config location may have drifted`;
|
|
59055
|
+
process.stderr.write(`telegram gateway: always-allow VERIFY FAILED: ${grantFailReason} (request_id=${request_id})
|
|
59056
|
+
`);
|
|
59057
|
+
}
|
|
59058
|
+
} else {
|
|
59059
|
+
grantFailReason = `agent "${agentName3}" not found in config after write`;
|
|
59060
|
+
process.stderr.write(`telegram gateway: always-allow VERIFY FAILED: ${grantFailReason} (request_id=${request_id})
|
|
59041
59061
|
`);
|
|
59062
|
+
}
|
|
59063
|
+
} catch (verifyErr) {
|
|
59064
|
+
grantFailReason = `config re-read failed: ${verifyErr.message}`;
|
|
59065
|
+
process.stderr.write(`telegram gateway: always-allow VERIFY FAILED: ${grantFailReason} (request_id=${request_id})
|
|
59066
|
+
`);
|
|
59067
|
+
}
|
|
59042
59068
|
} catch (err) {
|
|
59043
|
-
|
|
59069
|
+
grantFailReason = err.message;
|
|
59070
|
+
process.stderr.write(`telegram gateway: always-allow grant failed: ${grantFailReason}
|
|
59044
59071
|
`);
|
|
59045
59072
|
}
|
|
59046
59073
|
pendingPermissions.delete(request_id);
|
|
59047
|
-
const ackText = grantOk ? `\uD83D\uDD01 Always allow ${rule.label} for ${agentName3}` : `\
|
|
59074
|
+
const ackText = grantOk ? `\uD83D\uDD01 Always allow ${rule.label} for ${agentName3}` : `\u26A0\uFE0F Allowed for now, but "always" did NOT save \u2014 it will ask again after restart. Check gateway log.`;
|
|
59048
59075
|
const sourceMsg = ctx.callbackQuery?.message;
|
|
59049
59076
|
const baseText2 = sourceMsg && "text" in sourceMsg && sourceMsg.text ? escapeHtmlForTg(sourceMsg.text) : "";
|
|
59050
|
-
const editLabel = grantOk ? `\uD83D\uDD01 <b>Always allow ${escapeHtmlForTg(rule.label)}</b> for ${escapeHtmlForTg(agentName3)} \u2014 restart agent for full effect` : `\
|
|
59077
|
+
const editLabel = grantOk ? `\uD83D\uDD01 <b>Always allow ${escapeHtmlForTg(rule.label)}</b> for ${escapeHtmlForTg(agentName3)} \u2014 restart agent for full effect` : `\u26A0\uFE0F <b>Allowed for now \u2014 "always" did NOT save.</b> It will ask again after restart. Check gateway log.`;
|
|
59051
59078
|
await finalizeCallback(ctx, {
|
|
59052
59079
|
ackText: ackText.slice(0, 200),
|
|
59053
59080
|
newText: baseText2 ? `${baseText2}
|
|
@@ -17069,6 +17069,7 @@ import { join as join3 } from "node:path";
|
|
|
17069
17069
|
function createToolLabelSidecar(opts) {
|
|
17070
17070
|
const path = join3(opts.stateDir, `tool-labels-${opts.sessionId}.jsonl`);
|
|
17071
17071
|
const labels = new Map;
|
|
17072
|
+
const seen = [];
|
|
17072
17073
|
const subscribers = new Set;
|
|
17073
17074
|
let offset = 0;
|
|
17074
17075
|
let stopped = false;
|
|
@@ -17096,6 +17097,7 @@ function createToolLabelSidecar(opts) {
|
|
|
17096
17097
|
if (labels.has(row.tool_use_id))
|
|
17097
17098
|
continue;
|
|
17098
17099
|
labels.set(row.tool_use_id, row.label);
|
|
17100
|
+
seen.push({ toolUseId: row.tool_use_id, label: row.label, toolName: row.tool_name });
|
|
17099
17101
|
for (const cb of subscribers) {
|
|
17100
17102
|
try {
|
|
17101
17103
|
cb(row.tool_use_id, row.label, row.tool_name);
|
|
@@ -17137,6 +17139,11 @@ function createToolLabelSidecar(opts) {
|
|
|
17137
17139
|
return labels.get(toolUseId);
|
|
17138
17140
|
},
|
|
17139
17141
|
onLabel(cb) {
|
|
17142
|
+
for (const r of seen) {
|
|
17143
|
+
try {
|
|
17144
|
+
cb(r.toolUseId, r.label, r.toolName);
|
|
17145
|
+
} catch {}
|
|
17146
|
+
}
|
|
17140
17147
|
subscribers.add(cb);
|
|
17141
17148
|
return () => subscribers.delete(cb);
|
|
17142
17149
|
},
|
|
@@ -17453,6 +17460,46 @@ function extractAssistantText(obj) {
|
|
|
17453
17460
|
}
|
|
17454
17461
|
return parts.join(" ").trim();
|
|
17455
17462
|
}
|
|
17463
|
+
function computeFirstAttachCursor(file, size) {
|
|
17464
|
+
const SCAN_CAP = 1024 * 1024;
|
|
17465
|
+
const scanStart = Math.max(0, size - SCAN_CAP);
|
|
17466
|
+
let buf;
|
|
17467
|
+
try {
|
|
17468
|
+
const fd = openSync(file, "r");
|
|
17469
|
+
try {
|
|
17470
|
+
buf = Buffer.allocUnsafe(size - scanStart);
|
|
17471
|
+
readSync(fd, buf, 0, buf.length, scanStart);
|
|
17472
|
+
} finally {
|
|
17473
|
+
closeSync(fd);
|
|
17474
|
+
}
|
|
17475
|
+
} catch {
|
|
17476
|
+
return size;
|
|
17477
|
+
}
|
|
17478
|
+
let lastEnqueueOffset = -1;
|
|
17479
|
+
let turnEndedAfterEnqueue = false;
|
|
17480
|
+
let lineStart = 0;
|
|
17481
|
+
let skipPartial = scanStart > 0;
|
|
17482
|
+
for (let i = 0;i <= buf.length; i++) {
|
|
17483
|
+
if (i !== buf.length && buf[i] !== 10)
|
|
17484
|
+
continue;
|
|
17485
|
+
if (skipPartial) {
|
|
17486
|
+
skipPartial = false;
|
|
17487
|
+
} else if (i > lineStart) {
|
|
17488
|
+
const line = buf.toString("utf8", lineStart, i);
|
|
17489
|
+
if (line.includes('"type":"queue-operation"') && line.includes('"operation":"enqueue"')) {
|
|
17490
|
+
lastEnqueueOffset = scanStart + lineStart;
|
|
17491
|
+
turnEndedAfterEnqueue = false;
|
|
17492
|
+
} else if (lastEnqueueOffset >= 0 && line.includes('"subtype":"turn_duration"')) {
|
|
17493
|
+
turnEndedAfterEnqueue = true;
|
|
17494
|
+
}
|
|
17495
|
+
}
|
|
17496
|
+
lineStart = i + 1;
|
|
17497
|
+
}
|
|
17498
|
+
if (lastEnqueueOffset >= 0 && !turnEndedAfterEnqueue) {
|
|
17499
|
+
return lastEnqueueOffset;
|
|
17500
|
+
}
|
|
17501
|
+
return size;
|
|
17502
|
+
}
|
|
17456
17503
|
function startSessionTail(config2) {
|
|
17457
17504
|
const cwd = config2.cwd ?? process.cwd();
|
|
17458
17505
|
const claudeHome = config2.claudeHome ?? process.env.CLAUDE_CONFIG_DIR ?? join4(homedir3(), ".claude");
|
|
@@ -17589,11 +17636,16 @@ function startSessionTail(config2) {
|
|
|
17589
17636
|
} else {
|
|
17590
17637
|
pendingPartial = "";
|
|
17591
17638
|
try {
|
|
17592
|
-
|
|
17639
|
+
const size = statSync4(file).size;
|
|
17640
|
+
cursor = computeFirstAttachCursor(file, size);
|
|
17641
|
+
if (cursor < size) {
|
|
17642
|
+
log?.(`session-tail: attached to ${file} (cursor=${cursor}, replaying in-flight turn from offset; size=${size})`);
|
|
17643
|
+
} else {
|
|
17644
|
+
log?.(`session-tail: attached to ${file} (cursor=${cursor})`);
|
|
17645
|
+
}
|
|
17593
17646
|
} catch {
|
|
17594
17647
|
cursor = 0;
|
|
17595
17648
|
}
|
|
17596
|
-
log?.(`session-tail: attached to ${file} (cursor=${cursor})`);
|
|
17597
17649
|
}
|
|
17598
17650
|
const attachSid = sessionIdForFile(file);
|
|
17599
17651
|
if (attachSid)
|
|
@@ -368,7 +368,7 @@ import { createIssuesCardHandle, type IssuesCardHandle } from '../issues-card.js
|
|
|
368
368
|
import { startIssuesWatcher, type IssuesWatcherHandle } from '../issues-watcher.js'
|
|
369
369
|
import { list as listIssues, resolve as resolveIssue } from '../../src/issues/index.js'
|
|
370
370
|
import { summarizeToolForTitle, formatPermissionCardBody } from '../permission-title.js'
|
|
371
|
-
import { resolveAlwaysAllowRule } from '../permission-rule.js'
|
|
371
|
+
import { resolveAlwaysAllowRule, isRulePersisted } from '../permission-rule.js'
|
|
372
372
|
import {
|
|
373
373
|
readClaudeJsonOverage,
|
|
374
374
|
evaluateCreditState,
|
|
@@ -15286,25 +15286,56 @@ bot.on('callback_query:data', async ctx => {
|
|
|
15286
15286
|
return
|
|
15287
15287
|
}
|
|
15288
15288
|
let grantOk = false
|
|
15289
|
+
let grantFailReason = ''
|
|
15289
15290
|
try {
|
|
15290
15291
|
// --no-restart: settings.json gets the new entry on the next
|
|
15291
15292
|
// reconcile but we don't bounce the agent mid-turn. Operator
|
|
15292
15293
|
// can restart manually if they want this rule live in this
|
|
15293
15294
|
// session; otherwise it kicks in next session.
|
|
15294
15295
|
switchroomExec(['agent', 'grant', agentName, rule.rule, '--no-restart'])
|
|
15295
|
-
|
|
15296
|
-
|
|
15297
|
-
|
|
15298
|
-
|
|
15296
|
+
// Verify the rule actually landed in the resolved config — guards
|
|
15297
|
+
// against config-location-drift (gateway edited a yaml that isn't
|
|
15298
|
+
// the durable source-of-truth, or the grant was a no-op). One
|
|
15299
|
+
// fresh config read; cheap since this is a rare operator tap.
|
|
15300
|
+
try {
|
|
15301
|
+
const cfg = loadSwitchroomConfig()
|
|
15302
|
+
const rawAgent = cfg.agents?.[agentName]
|
|
15303
|
+
if (rawAgent) {
|
|
15304
|
+
const resolved = resolveAgentConfig(cfg.defaults, cfg.profiles, rawAgent)
|
|
15305
|
+
const allowList: string[] = (resolved as { tools?: { allow?: string[] } }).tools?.allow ?? []
|
|
15306
|
+
if (isRulePersisted(allowList, rule.rule)) {
|
|
15307
|
+
grantOk = true
|
|
15308
|
+
process.stderr.write(
|
|
15309
|
+
`telegram gateway: always-allow added rule="${rule.rule}" agent=${agentName} (request_id=${request_id})\n`,
|
|
15310
|
+
)
|
|
15311
|
+
} else {
|
|
15312
|
+
grantFailReason = `rule "${rule.rule}" not found in resolved tools.allow after write — config location may have drifted`
|
|
15313
|
+
process.stderr.write(
|
|
15314
|
+
`telegram gateway: always-allow VERIFY FAILED: ${grantFailReason} (request_id=${request_id})\n`,
|
|
15315
|
+
)
|
|
15316
|
+
}
|
|
15317
|
+
} else {
|
|
15318
|
+
grantFailReason = `agent "${agentName}" not found in config after write`
|
|
15319
|
+
process.stderr.write(
|
|
15320
|
+
`telegram gateway: always-allow VERIFY FAILED: ${grantFailReason} (request_id=${request_id})\n`,
|
|
15321
|
+
)
|
|
15322
|
+
}
|
|
15323
|
+
} catch (verifyErr) {
|
|
15324
|
+
grantFailReason = `config re-read failed: ${(verifyErr as Error).message}`
|
|
15325
|
+
process.stderr.write(
|
|
15326
|
+
`telegram gateway: always-allow VERIFY FAILED: ${grantFailReason} (request_id=${request_id})\n`,
|
|
15327
|
+
)
|
|
15328
|
+
}
|
|
15299
15329
|
} catch (err) {
|
|
15300
|
-
|
|
15330
|
+
grantFailReason = (err as Error).message
|
|
15331
|
+
process.stderr.write(`telegram gateway: always-allow grant failed: ${grantFailReason}\n`)
|
|
15301
15332
|
}
|
|
15302
15333
|
|
|
15303
15334
|
pendingPermissions.delete(request_id)
|
|
15304
15335
|
|
|
15305
15336
|
const ackText = grantOk
|
|
15306
15337
|
? `🔁 Always allow ${rule.label} for ${agentName}`
|
|
15307
|
-
:
|
|
15338
|
+
: `⚠️ Allowed for now, but "always" did NOT save — it will ask again after restart. Check gateway log.`
|
|
15308
15339
|
// HTML-escape baseText — `ctx.callbackQuery.message.text` returns
|
|
15309
15340
|
// entities-stripped plain UTF-8, so raw `<`/`>`/`&` in the
|
|
15310
15341
|
// expanded permission card's `description` or `input_preview`
|
|
@@ -15317,7 +15348,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
15317
15348
|
: ''
|
|
15318
15349
|
const editLabel = grantOk
|
|
15319
15350
|
? `🔁 <b>Always allow ${escapeHtmlForTg(rule.label)}</b> for ${escapeHtmlForTg(agentName)} — restart agent for full effect`
|
|
15320
|
-
:
|
|
15351
|
+
: `⚠️ <b>Allowed for now — "always" did NOT save.</b> It will ask again after restart. Check gateway log.`
|
|
15321
15352
|
// #1150 audit: route through finalizeCallback so the keyboard
|
|
15322
15353
|
// strips alongside the status-line edit. Pre-fix this called
|
|
15323
15354
|
// editMessageText without `reply_markup` so the Allow/Deny/Always
|
|
@@ -132,6 +132,28 @@ function skillBasenameFromPath(input: Record<string, unknown>): string | null {
|
|
|
132
132
|
return basename(trimmed) || null;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Verify that a grant actually landed in the resolved `tools.allow` list.
|
|
137
|
+
*
|
|
138
|
+
* Called by the `perm:always:*` handler after `switchroom agent grant`
|
|
139
|
+
* returns to guard against silently-failed or misdirected yaml writes.
|
|
140
|
+
* Extracted as a pure helper so it can be unit-tested without a full
|
|
141
|
+
* Grammy + switchroomExec harness.
|
|
142
|
+
*
|
|
143
|
+
* @param resolvedAllow The `tools.allow` array from `resolveAgentConfig`
|
|
144
|
+
* for the target agent (pass `[]` when absent/undefined).
|
|
145
|
+
* @param ruleRule The rule string produced by `resolveAlwaysAllowRule`
|
|
146
|
+
* (e.g. `"Skill(garmin)"`, `"Bash"`, `"mcp__x__y"`).
|
|
147
|
+
* @returns `true` when the rule is present (grant confirmed), `false` when
|
|
148
|
+
* absent (grant failed / config location drifted).
|
|
149
|
+
*/
|
|
150
|
+
export function isRulePersisted(
|
|
151
|
+
resolvedAllow: readonly string[],
|
|
152
|
+
ruleRule: string,
|
|
153
|
+
): boolean {
|
|
154
|
+
return resolvedAllow.includes(ruleRule);
|
|
155
|
+
}
|
|
156
|
+
|
|
135
157
|
/**
|
|
136
158
|
* Inverse of `resolveAlwaysAllowRule` — does a stored allow-rule cover a
|
|
137
159
|
* fresh `permission_request`? Used by the bridge's session-scoped
|
|
@@ -603,6 +603,66 @@ export interface SessionTailHandle {
|
|
|
603
603
|
getActiveFile(): string | null
|
|
604
604
|
}
|
|
605
605
|
|
|
606
|
+
/**
|
|
607
|
+
* Byte offset to seek to on the FIRST attach to a session transcript.
|
|
608
|
+
*
|
|
609
|
+
* Normally EOF — we only want NEW events, not replayed history. But if the
|
|
610
|
+
* agent restarted MID-TURN (the bridge's session-tail starts only after
|
|
611
|
+
* claude has already written this turn's `queue-operation enqueue` line),
|
|
612
|
+
* a plain seek-to-EOF skips that enqueue. `enqueue` is the ONLY event that
|
|
613
|
+
* carries the chatId and that sets the gateway's `currentTurn`, so missing
|
|
614
|
+
* it leaves the first post-restart turn with no currentTurn — killing the
|
|
615
|
+
* progress card, draft-mirror, and silence-poke for that turn.
|
|
616
|
+
*
|
|
617
|
+
* Fix: in a bounded tail scan, find the last `enqueue` that has NO
|
|
618
|
+
* `turn_duration` (turn_end) after it — an in-flight turn — and return its
|
|
619
|
+
* line offset so it (and the turn's subsequent events) replay. A completed
|
|
620
|
+
* turn (a `turn_duration` follows the enqueue) returns EOF: no replay.
|
|
621
|
+
*/
|
|
622
|
+
export function computeFirstAttachCursor(file: string, size: number): number {
|
|
623
|
+
const SCAN_CAP = 1024 * 1024 // bound the tail read at 1 MiB
|
|
624
|
+
const scanStart = Math.max(0, size - SCAN_CAP)
|
|
625
|
+
let buf: Buffer
|
|
626
|
+
try {
|
|
627
|
+
const fd = openSync(file, 'r')
|
|
628
|
+
try {
|
|
629
|
+
buf = Buffer.allocUnsafe(size - scanStart)
|
|
630
|
+
readSync(fd, buf, 0, buf.length, scanStart)
|
|
631
|
+
} finally {
|
|
632
|
+
closeSync(fd)
|
|
633
|
+
}
|
|
634
|
+
} catch {
|
|
635
|
+
return size
|
|
636
|
+
}
|
|
637
|
+
let lastEnqueueOffset = -1
|
|
638
|
+
let turnEndedAfterEnqueue = false
|
|
639
|
+
let lineStart = 0
|
|
640
|
+
// If the scan didn't start at byte 0, the first line is a partial — skip it.
|
|
641
|
+
let skipPartial = scanStart > 0
|
|
642
|
+
for (let i = 0; i <= buf.length; i++) {
|
|
643
|
+
if (i !== buf.length && buf[i] !== 0x0a) continue
|
|
644
|
+
if (skipPartial) {
|
|
645
|
+
skipPartial = false
|
|
646
|
+
} else if (i > lineStart) {
|
|
647
|
+
const line = buf.toString('utf8', lineStart, i)
|
|
648
|
+
if (
|
|
649
|
+
line.includes('"type":"queue-operation"') &&
|
|
650
|
+
line.includes('"operation":"enqueue"')
|
|
651
|
+
) {
|
|
652
|
+
lastEnqueueOffset = scanStart + lineStart
|
|
653
|
+
turnEndedAfterEnqueue = false
|
|
654
|
+
} else if (lastEnqueueOffset >= 0 && line.includes('"subtype":"turn_duration"')) {
|
|
655
|
+
turnEndedAfterEnqueue = true
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
lineStart = i + 1
|
|
659
|
+
}
|
|
660
|
+
if (lastEnqueueOffset >= 0 && !turnEndedAfterEnqueue) {
|
|
661
|
+
return lastEnqueueOffset
|
|
662
|
+
}
|
|
663
|
+
return size
|
|
664
|
+
}
|
|
665
|
+
|
|
606
666
|
/**
|
|
607
667
|
* Start tailing the active Claude Code session file. The tailer:
|
|
608
668
|
* 1. Polls the projects dir for the most recent .jsonl
|
|
@@ -778,14 +838,20 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
|
|
|
778
838
|
log?.(`session-tail: re-attached to ${file} (cursor=${cursor}, restored)`)
|
|
779
839
|
} else {
|
|
780
840
|
// First attach to this file — seek to current end so we only see
|
|
781
|
-
// new events,
|
|
841
|
+
// new events, EXCEPT replay from an in-flight turn's enqueue if the
|
|
842
|
+
// agent restarted mid-turn (see firstAttachCursor).
|
|
782
843
|
pendingPartial = ''
|
|
783
844
|
try {
|
|
784
|
-
|
|
845
|
+
const size = statSync(file).size
|
|
846
|
+
cursor = computeFirstAttachCursor(file, size)
|
|
847
|
+
if (cursor < size) {
|
|
848
|
+
log?.(`session-tail: attached to ${file} (cursor=${cursor}, replaying in-flight turn from offset; size=${size})`)
|
|
849
|
+
} else {
|
|
850
|
+
log?.(`session-tail: attached to ${file} (cursor=${cursor})`)
|
|
851
|
+
}
|
|
785
852
|
} catch {
|
|
786
853
|
cursor = 0
|
|
787
854
|
}
|
|
788
|
-
log?.(`session-tail: attached to ${file} (cursor=${cursor})`)
|
|
789
855
|
}
|
|
790
856
|
// Eagerly create + subscribe the PreToolUse sidecar for this session
|
|
791
857
|
// NOW (on attach), not lazily on the first JSONL tool_use — otherwise
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural contract tests for the "🔁 Always allow" handler in
|
|
3
|
+
* gateway.ts (the `behavior === 'always'` branch of the perm: callback
|
|
4
|
+
* dispatcher).
|
|
5
|
+
*
|
|
6
|
+
* Why structural: the handler lives inside a Grammy callback closure
|
|
7
|
+
* that's not exported. Full-function invocation would require a complete
|
|
8
|
+
* Grammy + switchroomExec harness. Instead, we pin the source-level
|
|
9
|
+
* invariants that were introduced to fix the silent-failure bug:
|
|
10
|
+
*
|
|
11
|
+
* 1. Loud failure text — the failure path must NOT read like success
|
|
12
|
+
* (`✅ Allowed …`). After the fix, both the toast (ackText) and the
|
|
13
|
+
* chat edit (editLabel) use the `⚠️` marker.
|
|
14
|
+
* 2. Post-write verification — after `switchroomExec` returns success
|
|
15
|
+
* the handler MUST re-read the config and check that the rule is
|
|
16
|
+
* actually present in `tools.allow`. If the check fails it sets
|
|
17
|
+
* grantOk=false and surfaces the loud message.
|
|
18
|
+
* 3. Success path unchanged — when `grantOk` is true the success
|
|
19
|
+
* strings (`🔁 Always allow …`, `restart agent for full effect`)
|
|
20
|
+
* are still present.
|
|
21
|
+
* 4. Error reason capture — `grantFailReason` is declared and
|
|
22
|
+
* populated from `(err as Error).message` so the root cause can
|
|
23
|
+
* appear in logs; it is NOT silently swallowed into `message`-less
|
|
24
|
+
* stderr output.
|
|
25
|
+
*
|
|
26
|
+
* Slicing strategy: we extract the `if (behavior === 'always') {` block
|
|
27
|
+
* from gateway.ts and run string assertions against that slice only —
|
|
28
|
+
* so additions elsewhere in the 17k-line file don't produce false
|
|
29
|
+
* positives or negatives.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { describe, it, expect } from 'vitest'
|
|
33
|
+
import { readFileSync } from 'node:fs'
|
|
34
|
+
import { resolve } from 'node:path'
|
|
35
|
+
|
|
36
|
+
const gatewaySrc = readFileSync(
|
|
37
|
+
resolve(__dirname, '..', 'gateway', 'gateway.ts'),
|
|
38
|
+
'utf-8',
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Extract the `behavior === 'always'` block from the perm: callback
|
|
43
|
+
* dispatcher. The slice runs from the `if (behavior === 'always')` guard
|
|
44
|
+
* up to (but not including) the next top-level `// Forward permission`
|
|
45
|
+
* comment which opens the allow/deny branch.
|
|
46
|
+
*/
|
|
47
|
+
function sliceAlwaysBlock(): string {
|
|
48
|
+
const start = gatewaySrc.indexOf("if (behavior === 'always')")
|
|
49
|
+
const end = gatewaySrc.indexOf('// Forward permission decision to connected bridges', start)
|
|
50
|
+
if (start === -1 || end === -1) return ''
|
|
51
|
+
return gatewaySrc.slice(start, end)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const alwaysBlock = sliceAlwaysBlock()
|
|
55
|
+
|
|
56
|
+
describe('always-allow handler — loud failure invariants', () => {
|
|
57
|
+
it('failure ackText uses the ⚠️ warning marker, not ✅', () => {
|
|
58
|
+
// The failure path must be unambiguous. Before the fix, the failure
|
|
59
|
+
// ackText started with "✅ Allowed …" which reads like success.
|
|
60
|
+
expect(alwaysBlock).toContain(
|
|
61
|
+
`⚠️ Allowed for now, but "always" did NOT save — it will ask again after restart. Check gateway log.`,
|
|
62
|
+
)
|
|
63
|
+
// Confirm the old misleading text is gone.
|
|
64
|
+
expect(alwaysBlock).not.toContain('✅ Allowed (always-allow yaml edit failed')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('failure editLabel uses the ⚠️ warning marker, not ✅', () => {
|
|
68
|
+
// The inline-keyboard collapse edit also must NOT look like success.
|
|
69
|
+
expect(alwaysBlock).toContain(
|
|
70
|
+
`⚠️ <b>Allowed for now — "always" did NOT save.</b> It will ask again after restart. Check gateway log.`,
|
|
71
|
+
)
|
|
72
|
+
// Confirm the old misleading text is gone.
|
|
73
|
+
expect(alwaysBlock).not.toContain('✅ <b>Allowed</b> (always-allow rule edit failed')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('always-allow handler — success path unchanged', () => {
|
|
78
|
+
it('success ackText still uses 🔁 and names the rule', () => {
|
|
79
|
+
expect(alwaysBlock).toContain('`🔁 Always allow ${rule.label} for ${agentName}`')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('success editLabel still uses 🔁 bold + restart hint', () => {
|
|
83
|
+
expect(alwaysBlock).toContain('restart agent for full effect')
|
|
84
|
+
expect(alwaysBlock).toContain('🔁 <b>Always allow')
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('always-allow handler — post-write verification', () => {
|
|
89
|
+
it('reloads config after switchroomExec returns', () => {
|
|
90
|
+
// The verification block must call loadSwitchroomConfig() AFTER
|
|
91
|
+
// the switchroomExec call to confirm the rule landed in the
|
|
92
|
+
// resolved tools.allow.
|
|
93
|
+
const execIdx = alwaysBlock.indexOf("switchroomExec(['agent', 'grant'")
|
|
94
|
+
const loadIdx = alwaysBlock.indexOf('loadSwitchroomConfig()', execIdx)
|
|
95
|
+
expect(execIdx).toBeGreaterThan(-1)
|
|
96
|
+
expect(loadIdx).toBeGreaterThan(execIdx)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('calls resolveAgentConfig to obtain the merged tools.allow list', () => {
|
|
100
|
+
const execIdx = alwaysBlock.indexOf("switchroomExec(['agent', 'grant'")
|
|
101
|
+
const resolveIdx = alwaysBlock.indexOf('resolveAgentConfig(', execIdx)
|
|
102
|
+
expect(resolveIdx).toBeGreaterThan(execIdx)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('calls isRulePersisted(allowList, rule.rule) after the reload', () => {
|
|
106
|
+
// The handler delegates the membership check to the extracted pure
|
|
107
|
+
// helper so the behavioral test in always-allow-persist.test.ts can
|
|
108
|
+
// cover the same code path.
|
|
109
|
+
expect(alwaysBlock).toContain('isRulePersisted(allowList, rule.rule)')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('sets grantOk=true only when isRulePersisted returns true', () => {
|
|
113
|
+
// grantOk=true must be inside the `if (isRulePersisted(...))` branch,
|
|
114
|
+
// not unconditionally after switchroomExec.
|
|
115
|
+
const persistIdx = alwaysBlock.indexOf('isRulePersisted(allowList, rule.rule)')
|
|
116
|
+
const grantOkIdx = alwaysBlock.indexOf('grantOk = true', persistIdx)
|
|
117
|
+
expect(persistIdx).toBeGreaterThan(-1)
|
|
118
|
+
expect(grantOkIdx).toBeGreaterThan(persistIdx)
|
|
119
|
+
// Confirm grantOk=true does NOT appear before the persistence check
|
|
120
|
+
// (i.e., not unconditionally on switchroomExec success as in the old code).
|
|
121
|
+
const grantOkFirst = alwaysBlock.indexOf('grantOk = true')
|
|
122
|
+
expect(grantOkFirst).toBeGreaterThanOrEqual(persistIdx)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('logs a VERIFY FAILED message when the rule is absent after the write', () => {
|
|
126
|
+
expect(alwaysBlock).toContain('always-allow VERIFY FAILED')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('surfaces config-location drift as a failure reason', () => {
|
|
130
|
+
expect(alwaysBlock).toContain('config location may have drifted')
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('always-allow handler — error reason capture', () => {
|
|
135
|
+
it('declares grantFailReason to capture the root cause', () => {
|
|
136
|
+
expect(alwaysBlock).toContain('let grantFailReason')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('populates grantFailReason from the thrown error on switchroomExec failure', () => {
|
|
140
|
+
// After the catch for switchroomExec, grantFailReason must be set
|
|
141
|
+
// from the error object so log messages can show the actual cause.
|
|
142
|
+
const catchIdx = alwaysBlock.lastIndexOf('} catch (err) {')
|
|
143
|
+
const reasonIdx = alwaysBlock.indexOf('grantFailReason = (err as Error).message', catchIdx)
|
|
144
|
+
expect(catchIdx).toBeGreaterThan(-1)
|
|
145
|
+
expect(reasonIdx).toBeGreaterThan(catchIdx)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Behavioral tests for the always-allow grant verification helper
|
|
3
|
+
* (`isRulePersisted` in `permission-rule.ts`).
|
|
4
|
+
*
|
|
5
|
+
* The `perm:always:*` handler in gateway.ts calls `isRulePersisted` after
|
|
6
|
+
* `switchroom agent grant` returns to confirm the rule actually landed in
|
|
7
|
+
* `tools.allow`. These tests drive the invariants that the structural test
|
|
8
|
+
* in `always-allow-grant.test.ts` can only pin by text-slicing:
|
|
9
|
+
*
|
|
10
|
+
* 1. exec "succeeds" but the reloaded allow-list does NOT contain the
|
|
11
|
+
* rule → isRulePersisted returns false (loud-failure path).
|
|
12
|
+
* 2. reloaded allow-list DOES contain the rule → returns true
|
|
13
|
+
* (success path).
|
|
14
|
+
* 3. Realistic rule values (`Skill(garmin)`, `Bash`, `mcp__x__y`) round-
|
|
15
|
+
* trip correctly — guards against normalization divergence where the
|
|
16
|
+
* value written by `agent grant` and the value read back from yaml
|
|
17
|
+
* diverge in shape.
|
|
18
|
+
*
|
|
19
|
+
* Because `isRulePersisted` is a pure function (takes the already-resolved
|
|
20
|
+
* allow-list directly), no mocking of `loadSwitchroomConfig` /
|
|
21
|
+
* `resolveAgentConfig` is required here. The handler's interaction with
|
|
22
|
+
* those config loaders is covered by the structural test.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect } from 'vitest'
|
|
26
|
+
import { isRulePersisted, resolveAlwaysAllowRule } from '../permission-rule.js'
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Core behavioral invariants
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
describe('isRulePersisted — failure path', () => {
|
|
33
|
+
it('returns false when the allow-list is empty (exec succeeded but nothing was written)', () => {
|
|
34
|
+
expect(isRulePersisted([], 'Bash')).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('returns false when the rule is absent from a non-empty allow-list', () => {
|
|
38
|
+
expect(isRulePersisted(['Read', 'Write', 'Edit'], 'Bash')).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('returns false for a Skill rule when the list only contains the bare tool name', () => {
|
|
42
|
+
// `agent grant` for Skill(garmin) should write `Skill(garmin)`, not
|
|
43
|
+
// `Skill`. If the yaml ended up with the wrong shape, the verification
|
|
44
|
+
// must catch it.
|
|
45
|
+
expect(isRulePersisted(['Skill'], 'Skill(garmin)')).toBe(false)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('returns false for a bare tool name when only the parameterized form is present', () => {
|
|
49
|
+
expect(isRulePersisted(['Skill(garmin)'], 'Bash')).toBe(false)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('returns false when a similar-looking rule is present but not an exact match', () => {
|
|
53
|
+
expect(isRulePersisted(['mcp__garmin__read_activity'], 'mcp__garmin__list_activities')).toBe(false)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('isRulePersisted — success path', () => {
|
|
58
|
+
it('returns true when the exact rule is present', () => {
|
|
59
|
+
expect(isRulePersisted(['Read', 'Bash', 'Write'], 'Bash')).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('returns true when the rule is the only entry', () => {
|
|
63
|
+
expect(isRulePersisted(['Skill(garmin)'], 'Skill(garmin)')).toBe(true)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('returns true for a namespaced MCP tool rule', () => {
|
|
67
|
+
expect(isRulePersisted(['mcp__garmin__list_activities', 'Bash'], 'mcp__garmin__list_activities')).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Round-trip: resolveAlwaysAllowRule → isRulePersisted
|
|
73
|
+
// Simulates the full handler flow: resolve the rule from a permission_request,
|
|
74
|
+
// "grant" it (allow-list contains the resolved rule.rule), then verify.
|
|
75
|
+
// Guards against normalization divergence between the value the handler
|
|
76
|
+
// resolves and the value `agent grant` writes + the config reader returns.
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
describe('rule round-trip through isRulePersisted', () => {
|
|
80
|
+
it('Skill tool: resolved rule persists correctly', () => {
|
|
81
|
+
const rule = resolveAlwaysAllowRule('Skill', JSON.stringify({ skill: 'garmin' }))
|
|
82
|
+
expect(rule).not.toBeNull()
|
|
83
|
+
// Simulate: allow-list now contains the rule that `agent grant` wrote.
|
|
84
|
+
expect(isRulePersisted([rule!.rule], rule!.rule)).toBe(true)
|
|
85
|
+
// Confirm the written form is `Skill(garmin)` — not a bare `Skill`.
|
|
86
|
+
expect(rule!.rule).toBe('Skill(garmin)')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('Skill tool: absent rule is detected', () => {
|
|
90
|
+
const rule = resolveAlwaysAllowRule('Skill', JSON.stringify({ skill: 'garmin' }))
|
|
91
|
+
expect(rule).not.toBeNull()
|
|
92
|
+
// allow-list was not updated (silent grant failure).
|
|
93
|
+
expect(isRulePersisted([], rule!.rule)).toBe(false)
|
|
94
|
+
expect(isRulePersisted(['Skill'], rule!.rule)).toBe(false)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('Bash tool: round-trips correctly', () => {
|
|
98
|
+
const rule = resolveAlwaysAllowRule('Bash', undefined)
|
|
99
|
+
expect(rule).not.toBeNull()
|
|
100
|
+
expect(rule!.rule).toBe('Bash')
|
|
101
|
+
expect(isRulePersisted(['Bash', 'Read'], rule!.rule)).toBe(true)
|
|
102
|
+
expect(isRulePersisted(['Read'], rule!.rule)).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('MCP tool: round-trips with exact namespaced form', () => {
|
|
106
|
+
const toolName = 'mcp__garmin__list_activities'
|
|
107
|
+
const rule = resolveAlwaysAllowRule(toolName, undefined)
|
|
108
|
+
expect(rule).not.toBeNull()
|
|
109
|
+
expect(rule!.rule).toBe(toolName)
|
|
110
|
+
expect(isRulePersisted([toolName], rule!.rule)).toBe(true)
|
|
111
|
+
expect(isRulePersisted(['mcp__garmin__read_activity'], rule!.rule)).toBe(false)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('multiple Skill tools do not cross-contaminate', () => {
|
|
115
|
+
const garmin = resolveAlwaysAllowRule('Skill', JSON.stringify({ skill: 'garmin' }))
|
|
116
|
+
const mail = resolveAlwaysAllowRule('Skill', JSON.stringify({ skill: 'mail' }))
|
|
117
|
+
expect(garmin).not.toBeNull()
|
|
118
|
+
expect(mail).not.toBeNull()
|
|
119
|
+
// Allow-list only has garmin's rule.
|
|
120
|
+
const allowList = [garmin!.rule]
|
|
121
|
+
expect(isRulePersisted(allowList, garmin!.rule)).toBe(true)
|
|
122
|
+
expect(isRulePersisted(allowList, mail!.rule)).toBe(false)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync, statSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { computeFirstAttachCursor } from '../session-tail.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* computeFirstAttachCursor: on first attach to a transcript, seek to EOF
|
|
9
|
+
* UNLESS the agent restarted mid-turn (an `enqueue` with no `turn_duration`
|
|
10
|
+
* after it). Missing that enqueue strands the first post-restart turn with
|
|
11
|
+
* no currentTurn (dead progress card / draft-mirror / silence-poke).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const ENQUEUE = '{"type":"queue-operation","operation":"enqueue","content":"chat:123 msg:1"}'
|
|
15
|
+
const DEQUEUE = '{"type":"queue-operation","operation":"dequeue"}'
|
|
16
|
+
const ASSISTANT = '{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}'
|
|
17
|
+
const TURN_DURATION = '{"type":"system","subtype":"turn_duration","durationMs":4200}'
|
|
18
|
+
|
|
19
|
+
function writeTranscript(dir: string, lines: string[]): { file: string; size: number } {
|
|
20
|
+
const file = join(dir, 'sess.jsonl')
|
|
21
|
+
writeFileSync(file, lines.join('\n') + '\n')
|
|
22
|
+
return { file, size: statSync(file).size }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function offsetOfLine(lines: string[], index: number): number {
|
|
26
|
+
let off = 0
|
|
27
|
+
for (let i = 0; i < index; i++) off += Buffer.byteLength(lines[i]!, 'utf8') + 1 // +1 for '\n'
|
|
28
|
+
return off
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('computeFirstAttachCursor', () => {
|
|
32
|
+
let dir: string
|
|
33
|
+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'first-attach-')) })
|
|
34
|
+
afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
|
|
35
|
+
|
|
36
|
+
it('in-flight turn (enqueue, no turn_duration after) → replays from the enqueue offset', () => {
|
|
37
|
+
const lines = [ASSISTANT, ENQUEUE, DEQUEUE, ASSISTANT] // enqueue at index 1, no turn_duration
|
|
38
|
+
const { file, size } = writeTranscript(dir, lines)
|
|
39
|
+
expect(computeFirstAttachCursor(file, size)).toBe(offsetOfLine(lines, 1))
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('completed turn (turn_duration after the enqueue) → EOF, no replay', () => {
|
|
43
|
+
const lines = [ENQUEUE, DEQUEUE, ASSISTANT, TURN_DURATION]
|
|
44
|
+
const { file, size } = writeTranscript(dir, lines)
|
|
45
|
+
expect(computeFirstAttachCursor(file, size)).toBe(size)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('no enqueue in the tail → EOF', () => {
|
|
49
|
+
const lines = [ASSISTANT, ASSISTANT, TURN_DURATION]
|
|
50
|
+
const { file, size } = writeTranscript(dir, lines)
|
|
51
|
+
expect(computeFirstAttachCursor(file, size)).toBe(size)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('completed turn followed by a NEW in-flight turn → replays from the second enqueue', () => {
|
|
55
|
+
// turn 1: enqueue+turn_duration (done). turn 2: enqueue, still running.
|
|
56
|
+
const lines = [ENQUEUE, ASSISTANT, TURN_DURATION, ENQUEUE, DEQUEUE, ASSISTANT]
|
|
57
|
+
const { file, size } = writeTranscript(dir, lines)
|
|
58
|
+
expect(computeFirstAttachCursor(file, size)).toBe(offsetOfLine(lines, 3))
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('empty / missing file → returns the given size (degrades to EOF)', () => {
|
|
62
|
+
const missing = join(dir, 'nope.jsonl')
|
|
63
|
+
expect(computeFirstAttachCursor(missing, 0)).toBe(0)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -83,6 +83,42 @@ describe('tool-label-sidecar', () => {
|
|
|
83
83
|
s.stop()
|
|
84
84
|
})
|
|
85
85
|
|
|
86
|
+
it('replays pre-existing rows to a subscriber that attaches after construction', () => {
|
|
87
|
+
// Regression: the gateway's session-tail constructs the sidecar (which
|
|
88
|
+
// does an initial drain of the file) and only THEN wires `onLabel`. On a
|
|
89
|
+
// fast/clustered turn — or a resumed/flipped session — the hook has
|
|
90
|
+
// already written labels, so the initial drain consumed them with an
|
|
91
|
+
// empty subscriber set. Before the replay fix the late subscriber got
|
|
92
|
+
// nothing, so the real-time draft-mirror never fired (every label lost).
|
|
93
|
+
const sessionId = 'sess-replay'
|
|
94
|
+
const f = join(stateDir, `tool-labels-${sessionId}.jsonl`)
|
|
95
|
+
writeFileSync(
|
|
96
|
+
f,
|
|
97
|
+
JSON.stringify({ ts: 1, tool_use_id: 'A', agent_id: 'g', label: 'Reading foo.ts', tool_name: 'Read' }) + '\n' +
|
|
98
|
+
JSON.stringify({ ts: 2, tool_use_id: 'B', agent_id: 'g', label: 'List workspace', tool_name: 'Bash' }) + '\n',
|
|
99
|
+
)
|
|
100
|
+
const sched = makeManualScheduler()
|
|
101
|
+
const s = createToolLabelSidecar({ stateDir, sessionId, scheduler: sched })
|
|
102
|
+
// Subscribe AFTER construction (the real ensureSidecar ordering).
|
|
103
|
+
const seen: Array<[string, string, string]> = []
|
|
104
|
+
s.onLabel((id, label, toolName) => seen.push([id, label, toolName]))
|
|
105
|
+
expect(seen).toEqual([
|
|
106
|
+
['A', 'Reading foo.ts', 'Read'],
|
|
107
|
+
['B', 'List workspace', 'Bash'],
|
|
108
|
+
])
|
|
109
|
+
|
|
110
|
+
// And a row appended afterwards still reaches the subscriber exactly once
|
|
111
|
+
// (no double-emit of the replayed rows).
|
|
112
|
+
appendFileSync(f, JSON.stringify({ ts: 3, tool_use_id: 'C', agent_id: 'g', label: 'Searching memory', tool_name: 'mcp__hindsight__recall' }) + '\n')
|
|
113
|
+
s.poll()
|
|
114
|
+
expect(seen).toEqual([
|
|
115
|
+
['A', 'Reading foo.ts', 'Read'],
|
|
116
|
+
['B', 'List workspace', 'Bash'],
|
|
117
|
+
['C', 'Searching memory', 'mcp__hindsight__recall'],
|
|
118
|
+
])
|
|
119
|
+
s.stop()
|
|
120
|
+
})
|
|
121
|
+
|
|
86
122
|
it('ignores malformed JSON lines', () => {
|
|
87
123
|
const sessionId = 'sess4'
|
|
88
124
|
const sched = makeManualScheduler()
|
|
@@ -66,6 +66,14 @@ export interface SidecarOptions {
|
|
|
66
66
|
export function createToolLabelSidecar(opts: SidecarOptions): ToolLabelSidecar {
|
|
67
67
|
const path = join(opts.stateDir, `tool-labels-${opts.sessionId}.jsonl`)
|
|
68
68
|
const labels = new Map<string, string>()
|
|
69
|
+
// Ordered log of every row ingested so far (label + tool_name), used to
|
|
70
|
+
// replay history to a subscriber that attaches AFTER rows were already
|
|
71
|
+
// read. Without this, a sidecar whose file is already populated when
|
|
72
|
+
// `onLabel` is wired (fast/clustered turns, resumed/flipped sessions —
|
|
73
|
+
// the gateway's `ensureSidecar` subscribes *after* construction's initial
|
|
74
|
+
// drain) would silently lose every pre-existing label, breaking the
|
|
75
|
+
// real-time draft-mirror determinism the sidecar exists to provide.
|
|
76
|
+
const seen: Array<{ toolUseId: string; label: string; toolName: string }> = []
|
|
69
77
|
const subscribers = new Set<(toolUseId: string, label: string, toolName: string) => void>()
|
|
70
78
|
let offset = 0
|
|
71
79
|
let stopped = false
|
|
@@ -97,6 +105,7 @@ export function createToolLabelSidecar(opts: SidecarOptions): ToolLabelSidecar {
|
|
|
97
105
|
// expect duplicates, but if one lands we keep the earliest.
|
|
98
106
|
if (labels.has(row.tool_use_id)) continue
|
|
99
107
|
labels.set(row.tool_use_id, row.label)
|
|
108
|
+
seen.push({ toolUseId: row.tool_use_id, label: row.label, toolName: row.tool_name })
|
|
100
109
|
for (const cb of subscribers) {
|
|
101
110
|
try { cb(row.tool_use_id, row.label, row.tool_name) } catch { /* ignore */ }
|
|
102
111
|
}
|
|
@@ -134,6 +143,15 @@ export function createToolLabelSidecar(opts: SidecarOptions): ToolLabelSidecar {
|
|
|
134
143
|
return labels.get(toolUseId)
|
|
135
144
|
},
|
|
136
145
|
onLabel(cb) {
|
|
146
|
+
// Replay rows already ingested before this subscriber attached, then
|
|
147
|
+
// register for future rows. Single-threaded: no row can be ingested
|
|
148
|
+
// between the replay loop and the add, so each row reaches `cb`
|
|
149
|
+
// exactly once. This is what makes the draft-mirror deterministic
|
|
150
|
+
// regardless of when the gateway subscribes relative to the hook's
|
|
151
|
+
// writes (see the `seen` declaration above).
|
|
152
|
+
for (const r of seen) {
|
|
153
|
+
try { cb(r.toolUseId, r.label, r.toolName) } catch { /* ignore */ }
|
|
154
|
+
}
|
|
137
155
|
subscribers.add(cb)
|
|
138
156
|
return () => subscribers.delete(cb)
|
|
139
157
|
},
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
# Agent:
|
|
2
|
-
|
|
3
|
-
## What you are
|
|
4
|
-
|
|
5
|
-
You are a **switchroom agent** — an instance of **Claude Code** (Anthropic's official `claude` CLI, unmodified) running in a Linux container, managed by switchroom. Your `$SWITCHROOM_AGENT_NAME` is ``. Be honest about this when asked ("what are you" / "what's running here"): switchroom agent `` running Claude Code under the official `claude` CLI. Not a custom model, not a wrapper, not "an AI assistant" in the abstract.
|
|
6
|
-
|
|
7
|
-
You are one of several agents here. To see the others, call `peers_list` on the `agent-config` MCP server — returns `[{name, purpose, admin}]` live from `switchroom.yaml`. **Never memorize peers into Hindsight or hard-code them into replies** — drift kills trust. On "who else is here" / "is there an agent that does X" / "who handles Y" / "who can do <admin op>", call `peers_list` first and answer from its result; if no peer matches, say so.
|
|
8
|
-
|
|
9
|
-
## Who you are
|
|
10
|
-
|
|
11
|
-
See `SOUL.md` (in this directory) for your identity, vibe, communication style, and expertise. That file is your persona source of truth.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
## Core Behavior
|
|
15
|
-
- Respond helpfully, concisely, and conversationally.
|
|
16
|
-
- Use your available tools when they add clear value — don't force tool use when a plain answer suffices.
|
|
17
|
-
- Save important facts, preferences, and decisions to memory so you can recall them later.
|
|
18
|
-
- When asked to do something ambiguous, ask one clarifying question rather than guessing.
|
|
19
|
-
- If a task has multiple steps, outline your plan before executing.
|
|
20
|
-
|
|
21
|
-
## Safety
|
|
22
|
-
- Don't exfiltrate private data. Ever.
|
|
23
|
-
- Don't run destructive commands without asking.
|
|
24
|
-
- Prefer `trash` over `rm` when available (recoverable beats gone forever).
|
|
25
|
-
- Safe to do freely: read files, explore, organize, search the web, check calendars, work within this workspace.
|
|
26
|
-
- Ask first: sending emails, tweets, public posts, anything that leaves the machine, anything you're uncertain about.
|
|
27
|
-
|
|
28
|
-
## Execution Bias
|
|
29
|
-
|
|
30
|
-
How you should decide what to do next. These are procedural rules, not vibe.
|
|
31
|
-
|
|
32
|
-
- **Act in-turn.** If the request is actionable, do it this turn. Don't finish with a plan or promise when tools can move it forward.
|
|
33
|
-
- **Verify mutable facts before claiming them.** Files, git state, clocks, versions, services, processes, package state, the contents of an `Edit` target: read live. Memory and prior context are not verification sources. "I think the function is at line 200" is not an answer; `Grep`/`Read` is.
|
|
34
|
-
- **Final answer needs evidence.** Test/build/lint output, screenshot, inspection, tool output, or a named blocker. "It should work" is not a finalization.
|
|
35
|
-
- **Weak or empty tool result is not a conclusion.** Vary the query, path, command, or source before deciding the thing isn't there.
|
|
36
|
-
- **Non-final turn:** use tools to advance, or ask the one clarifying question that unblocks safe progress. One question, not five.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
## Memory — Hindsight is your single backend
|
|
40
|
-
|
|
41
|
-
**Claude Code's built-in file-based auto-memory is disabled for this agent.** Don't try to write `.md` files under `.claude/projects/.../memory/` or maintain a `MEMORY.md` index — that whole system is off. There's exactly one memory backend: **Hindsight**.
|
|
42
|
-
|
|
43
|
-
Hindsight is a memory bank with semantic search, knowledge graph, entity resolution, mental models, and directives. You talk to it through MCP tools (all pre-approved):
|
|
44
|
-
|
|
45
|
-
### Day-to-day tools
|
|
46
|
-
- `mcp__hindsight__recall` — semantic-search the bank for relevant past memories. Auto-fires on every inbound user message via the plugin's UserPromptSubmit hook (you'll see "Relevant memories from past conversations" in your context). Call manually when you need a more specific query than the auto-fired one.
|
|
47
|
-
- `mcp__hindsight__retain` — store a new memory. The plugin automatically retains the conversation transcript every ~10 turns via the Stop hook, so you usually don't need this. Call manually for significant decisions, corrections, or facts you want immediately searchable.
|
|
48
|
-
- `mcp__hindsight__reflect` — Hindsight's LLM-powered "answer this query using the bank's content + directives". Use when the user asks a question that requires synthesis across multiple past memories.
|
|
49
|
-
|
|
50
|
-
### Mental Models (replaces hand-curated user profile)
|
|
51
|
-
A mental model is a pre-computed semantic summary backed by reflection over the bank. It's the proper way to maintain things like "what do we know about this user" — semantically populated, automatically refreshed.
|
|
52
|
-
|
|
53
|
-
- `mcp__hindsight__create_mental_model(name, source_query)` — create one. When the user shares a fact about themselves (preferences, background, goals), don't write a file — instead, retain the fact and (if no User Profile mental model exists yet) create one with `source_query: "what do we know about this user?"`. Hindsight will populate it from the retained memories.
|
|
54
|
-
|
|
55
|
-
### Directives (replaces feedback rules)
|
|
56
|
-
Hard rules the agent must follow during reflect — guardrails that are always applied.
|
|
57
|
-
|
|
58
|
-
- `mcp__hindsight__create_directive(text)` — e.g., `create_directive("Always prefer TypeScript over JavaScript for this user's projects")`. When the user gives you a correction or "always do X" rule, create a directive instead of writing a feedback `.md` file.
|
|
59
|
-
|
|
60
|
-
(Inspection tools like `list_memories`, `list_mental_models`, `update_mental_model`, `refresh_mental_model`, `list_directives`, `delete_directive` are available under the `mcp__hindsight__*` namespace if you ever need them, but you rarely should — Hindsight's own auto-recall surfaces what matters and the operator handles bank curation out-of-band.)
|
|
61
|
-
|
|
62
|
-
### What to retain — and what NOT to retain
|
|
63
|
-
|
|
64
|
-
Retain proactively when:
|
|
65
|
-
- The user shares a preference or fact about themselves
|
|
66
|
-
- The user gives you a correction or rule (these go to directives, not retain)
|
|
67
|
-
- A significant decision was made and the rationale matters for next time
|
|
68
|
-
- You did real work and the result + the path you took would be useful next session
|
|
69
|
-
|
|
70
|
-
Don't retain:
|
|
71
|
-
- Routine pleasantries, "thanks", "got it"
|
|
72
|
-
- Conversation chatter that doesn't carry forward
|
|
73
|
-
- Sensitive content the user explicitly asked you to not remember
|
|
74
|
-
- Things already in a mental model — they'll be re-derived from underlying memories
|
|
75
|
-
|
|
76
|
-
The plugin's auto-retain (Stop hook) handles transcript-level storage on a 10-turn cadence, so you don't need to manually retain everything. Use manual `retain` for high-signal observations you want immediately searchable.
|
|
77
|
-
|
|
78
|
-
## Sub-Agent Delegation
|
|
79
|
-
|
|
80
|
-
The main session is for conversation. Execution belongs in sub-agents. Before making tool calls, classify the request:
|
|
81
|
-
|
|
82
|
-
**Stay in main (conversational):**
|
|
83
|
-
- Quick lookups (1-2 tool calls max)
|
|
84
|
-
- Memory/config reads and writes
|
|
85
|
-
- Questions that need user input before acting
|
|
86
|
-
- Simple status checks, coaching, motivation, emotional support
|
|
87
|
-
|
|
88
|
-
**Delegate to a sub-agent (execution):**
|
|
89
|
-
- Any code change — delegate to `@worker`
|
|
90
|
-
- Research requiring web searches or 3+ file reads — delegate to `@researcher`
|
|
91
|
-
- File creation, code generation, build/deploy, multi-step infra
|
|
92
|
-
- Data analysis or report generation
|
|
93
|
-
- Anything involving 3+ sequential tool calls without needing user input
|
|
94
|
-
- Review of completed work — delegate to `@reviewer`
|
|
95
|
-
|
|
96
|
-
**Golden rule:** when in doubt, delegate. Unnecessary delegation costs slightly more tokens. A blocked session costs the user's attention. Keep your own turns short — dispatch and acknowledge. The user should never wait more than 10 seconds for a response from you.
|
|
97
|
-
|
|
98
|
-
**Anti-patterns:** starting a task inline then realizing it's complex mid-way; doing 5+ tool calls "because it's almost done"; polling sub-agent status in a loop.
|
|
99
|
-
|
|
100
|
-
If no sub-agents are configured, do the work yourself.
|
|
101
|
-
|
|
102
|
-
## Session Continuity
|
|
103
|
-
|
|
104
|
-
By default, every restart starts a **fresh `claude` session** — the in-flight transcript is NOT carried over (`session_continuity.resume_mode: handoff`, the default since switchroom #362). Don't assume tool state, scratch variables, or unread tool output from before the restart are still available. What does survive:
|
|
105
|
-
|
|
106
|
-
- **Handoff briefing** — on a clean shutdown, the Stop hook writes a bounded raw transcript tail of the prior session to `.handoff.md`. On boot, start.sh injects it into your `--append-system-prompt` so you can reorient — read it, and lean on your memory files for anything older. If `.handoff.md` is missing or stale (fresh agent, or pre-Stop-hook crash), `start.sh` runs `handoff-briefing.sh` to assemble `.handoff-briefing.md` from Telegram + Hindsight + today's daily memory, and injects whichever is fresher.
|
|
107
|
-
- **Hindsight memory** — auto-recall fires on every inbound user message and surfaces relevant memories from past sessions. Long-term facts, decisions, and mental models live here, not in the transcript.
|
|
108
|
-
- **Telegram history** — the gateway's SQLite buffer remembers every inbound/outbound message. Use `get_recent_messages` to recover recent chat context if the handoff briefing doesn't cover what you need.
|
|
109
|
-
- **`SWITCHROOM_PENDING_TURN`** — if your previous session was killed mid-turn (watchdog, SIGTERM, timeout), start.sh exports this env var plus the chat/thread/last-user-message context. Acknowledge the interruption and ask for direction rather than silently resuming.
|
|
110
|
-
- **`.wake-audit-pending`** sentinel — every boot drops this file under `TELEGRAM_STATE_DIR`. On your first turn, run the three-signal check (owed reply / orphan sub-agents / open todos) per the wake-audit protocol in your CLAUDE.md, then `rm -f` the sentinel.
|
|
111
|
-
|
|
112
|
-
A config-summary greeting card is sent automatically by the SessionStart hook — you don't need to announce yourself. If your context feels thin (after compaction or any fresh session), proactively recall from Hindsight before proceeding.
|
|
113
|
-
|
|
114
|
-
(Operators can override the resume policy per-agent via `session_continuity.resume_mode` in switchroom.yaml — `auto`, `continue`, `handoff`, or `none`. The default is `handoff`.)
|
|
115
|
-
|
|
116
|
-
## Admin operations
|
|
117
|
-
|
|
118
|
-
You're NOT `admin: true`. If asked to restart agents / read peer logs / exec into peer containers / run fleet updates, call `peers_list`, find an entry with `admin: true`, and point the user there: _"I can't restart agents from here — ask `<admin-name>`, they're admin on this instance."_ No long apology; just hand off.
|
|
119
|
-
|
|
120
|
-
## Tools
|
|
121
|
-
Use your available tools when appropriate. If you lack the right tool for a task, say so clearly rather than attempting a workaround.
|
|
122
|
-
|