switchroom 0.13.38 → 0.13.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +1 -1
- package/dist/auth-broker/index.js +1 -1
- package/dist/cli/switchroom.js +59 -383
- package/dist/host-control/main.js +1 -1
- package/dist/vault/approvals/kernel-server.js +1 -1
- package/dist/vault/broker/server.js +8 -1
- package/package.json +1 -1
- package/telegram-plugin/answer-stream.ts +28 -3
- package/telegram-plugin/dist/gateway/gateway.js +17 -9
- package/telegram-plugin/steering.ts +38 -7
- package/telegram-plugin/tests/answer-stream.test.ts +86 -0
- package/telegram-plugin/tests/steering.test.ts +37 -4
- package/telegram-plugin/uat/scenarios/jtbd-pending-progress-html-dm.test.ts +124 -0
|
@@ -10972,7 +10972,7 @@ var AgentBindMountSchema = exports_external.object({
|
|
|
10972
10972
|
var ScheduleEntrySchema = exports_external.object({
|
|
10973
10973
|
cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
|
|
10974
10974
|
prompt: exports_external.string().describe("Prompt to send at the scheduled time"),
|
|
10975
|
-
model: exports_external.string().optional().describe("DEPRECATED / IGNORED. Pre-v0.8 the singleton scheduler ran each " + "task as an isolated
|
|
10975
|
+
model: exports_external.string().optional().describe("DEPRECATED / IGNORED. Pre-v0.8 the singleton scheduler ran each " + "task as an isolated headless invocation and could set --model per " + "task. Post cron-fold-in (v0.8) the fire is injected into the agent's " + "running session, so it always uses the agent's configured model " + "— this field has no effect. Accepted (optional) only so existing " + "configs keep validating; set the model at the agent level instead. " + "See docs/scheduling.md."),
|
|
10976
10976
|
secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default — broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary — " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing.")
|
|
10977
10977
|
});
|
|
10978
10978
|
var AgentSoulSchema = exports_external.object({
|
|
@@ -10972,7 +10972,7 @@ var AgentBindMountSchema = exports_external.object({
|
|
|
10972
10972
|
var ScheduleEntrySchema = exports_external.object({
|
|
10973
10973
|
cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
|
|
10974
10974
|
prompt: exports_external.string().describe("Prompt to send at the scheduled time"),
|
|
10975
|
-
model: exports_external.string().optional().describe("DEPRECATED / IGNORED. Pre-v0.8 the singleton scheduler ran each " + "task as an isolated
|
|
10975
|
+
model: exports_external.string().optional().describe("DEPRECATED / IGNORED. Pre-v0.8 the singleton scheduler ran each " + "task as an isolated headless invocation and could set --model per " + "task. Post cron-fold-in (v0.8) the fire is injected into the agent's " + "running session, so it always uses the agent's configured model " + "— this field has no effect. Accepted (optional) only so existing " + "configs keep validating; set the model at the agent level instead. " + "See docs/scheduling.md."),
|
|
10976
10976
|
secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default — broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary — " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing.")
|
|
10977
10977
|
});
|
|
10978
10978
|
var AgentSoulSchema = exports_external.object({
|
package/dist/cli/switchroom.js
CHANGED
|
@@ -13536,7 +13536,7 @@ var init_schema = __esm(() => {
|
|
|
13536
13536
|
ScheduleEntrySchema = exports_external.object({
|
|
13537
13537
|
cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
|
|
13538
13538
|
prompt: exports_external.string().describe("Prompt to send at the scheduled time"),
|
|
13539
|
-
model: exports_external.string().optional().describe("DEPRECATED / IGNORED. Pre-v0.8 the singleton scheduler ran each " + "task as an isolated
|
|
13539
|
+
model: exports_external.string().optional().describe("DEPRECATED / IGNORED. Pre-v0.8 the singleton scheduler ran each " + "task as an isolated headless invocation and could set --model per " + "task. Post cron-fold-in (v0.8) the fire is injected into the agent's " + "running session, so it always uses the agent's configured model " + "\u2014 this field has no effect. Accepted (optional) only so existing " + "configs keep validating; set the model at the agent level instead. " + "See docs/scheduling.md."),
|
|
13540
13540
|
secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default \u2014 broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary \u2014 " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing.")
|
|
13541
13541
|
});
|
|
13542
13542
|
AgentSoulSchema = exports_external.object({
|
|
@@ -22991,6 +22991,9 @@ function generateCompose(opts) {
|
|
|
22991
22991
|
lines.push(` - ${homePrefix}/.switchroom/vault-auto-unlock:/state/vault-auto-unlock:ro`);
|
|
22992
22992
|
lines.push(` - ${homePrefix}/.switchroom/vault-audit.log:/root/.switchroom/vault-audit.log`);
|
|
22993
22993
|
lines.push(` - ${homePrefix}/.switchroom/vault-grants.db:/root/.switchroom/vault-grants.db`);
|
|
22994
|
+
for (const a of describeAgents(config)) {
|
|
22995
|
+
lines.push(` - ${homePrefix}/.switchroom/agents/${a.name}/.vault-token:/root/.switchroom/agents/${a.name}/.vault-token`);
|
|
22996
|
+
}
|
|
22994
22997
|
lines.push(` - /etc/machine-id:/etc/machine-id:ro`);
|
|
22995
22998
|
lines.push(``);
|
|
22996
22999
|
lines.push(` approval-kernel:`);
|
|
@@ -47744,8 +47747,8 @@ var {
|
|
|
47744
47747
|
} = import__.default;
|
|
47745
47748
|
|
|
47746
47749
|
// src/build-info.ts
|
|
47747
|
-
var VERSION = "0.13.
|
|
47748
|
-
var COMMIT_SHA = "
|
|
47750
|
+
var VERSION = "0.13.40";
|
|
47751
|
+
var COMMIT_SHA = "1f8f075b";
|
|
47749
47752
|
|
|
47750
47753
|
// src/cli/agent.ts
|
|
47751
47754
|
init_source();
|
|
@@ -47838,7 +47841,7 @@ import {
|
|
|
47838
47841
|
} from "node:fs";
|
|
47839
47842
|
import { homedir as homedir4 } from "node:os";
|
|
47840
47843
|
import { execSync, execFileSync as execFileSync2 } from "node:child_process";
|
|
47841
|
-
import {
|
|
47844
|
+
import { join as join8, resolve as resolve10 } from "node:path";
|
|
47842
47845
|
import { createHash as createHash2 } from "node:crypto";
|
|
47843
47846
|
|
|
47844
47847
|
// src/agents/cron-unit-name.ts
|
|
@@ -47849,14 +47852,10 @@ function cronUnitHash(cron, prompt) {
|
|
|
47849
47852
|
function cronUnitName(cron, prompt) {
|
|
47850
47853
|
return `cron-${cronUnitHash(cron, prompt)}`;
|
|
47851
47854
|
}
|
|
47852
|
-
function cronScriptFilename(cron, prompt) {
|
|
47853
|
-
return `${cronUnitName(cron, prompt)}.sh`;
|
|
47854
|
-
}
|
|
47855
47855
|
var CRON_SCRIPT_BASENAME_RE = /^cron-[0-9a-f]{12}\.sh$/;
|
|
47856
47856
|
var LEGACY_CRON_SCRIPT_BASENAME_RE = /^cron-(\d+)\.sh$/;
|
|
47857
47857
|
|
|
47858
47858
|
// src/agents/scaffold.ts
|
|
47859
|
-
init_overlay_loader();
|
|
47860
47859
|
init_schema();
|
|
47861
47860
|
init_merge();
|
|
47862
47861
|
init_timezone();
|
|
@@ -48029,52 +48028,6 @@ function applyTelegramProgressGuidance(body, args) {
|
|
|
48029
48028
|
return body;
|
|
48030
48029
|
return body + buildTelegramProgressGuidance({ defaultChatId: args.defaultChatId });
|
|
48031
48030
|
}
|
|
48032
|
-
function buildCronTelegramGuidance(args) {
|
|
48033
|
-
const issuesBlock = args.jobSlug ? `
|
|
48034
|
-
|
|
48035
|
-
## If you need to record a transient issue
|
|
48036
|
-
|
|
48037
|
-
If something half-broken happens during this run (e.g. an upstream API timed out, a vault key was missing, a non-fatal data gap), record it via:
|
|
48038
|
-
|
|
48039
|
-
\`\`\`
|
|
48040
|
-
switchroom issues record --severity warn --source "cron:${args.jobSlug}" --code <stable-code> --summary "<one-line>" --detail "Fix: <one-line remediation, e.g. exact command the user can run>"
|
|
48041
|
-
\`\`\`
|
|
48042
|
-
|
|
48043
|
-
The \`--detail\` field is rendered as a "\u2192 Fix: ..." line under the issue on the user's Telegram card. Make it actionable \u2014 a copy-pastable command or a one-line action, not a description of the problem. Examples:
|
|
48044
|
-
|
|
48045
|
-
- \`--detail "Fix: switchroom vault unlock"\` (when the vault broker is locked)
|
|
48046
|
-
- \`--detail "Fix: re-run \`claude setup-token\` for ken@example.com"\` (when an account is unauthenticated)
|
|
48047
|
-
- \`--detail "Fix: \\\`switchroom auth heal --account=<name>\\\`"\` (when an OAuth token expired)
|
|
48048
|
-
|
|
48049
|
-
Skip \`--detail\` if there's no clean one-line fix \u2014 leaving it empty means the card shows just the summary, which is fine.
|
|
48050
|
-
|
|
48051
|
-
Use the EXACT \`--source "cron:${args.jobSlug}"\` shown above \u2014 the cron wrapper auto-resolves issues with that source on a clean run. Picking a different source means the issue persists across recoveries.
|
|
48052
|
-
` : "";
|
|
48053
|
-
return `
|
|
48054
|
-
|
|
48055
|
-
## Delivery instructions (cron context)
|
|
48056
|
-
|
|
48057
|
-
This task runs as a one-shot \`claude -p\` invocation \u2014 there is no live Telegram session. Your stdout is discarded; the user will NOT see anything you print.
|
|
48058
|
-
|
|
48059
|
-
To deliver your response to the user, you MUST call:
|
|
48060
|
-
|
|
48061
|
-
\`\`\`
|
|
48062
|
-
mcp__switchroom-telegram__reply(chat_id="${args.chatId}", text="<your message>")
|
|
48063
|
-
\`\`\`
|
|
48064
|
-
|
|
48065
|
-
The \`reply\` tool handles markdown\u2192HTML conversion, chunking, and all formatting automatically \u2014 write normal markdown and it will render correctly on the user's phone.
|
|
48066
|
-
|
|
48067
|
-
After calling \`reply\`, print \`HEARTBEAT_OK\` as your final stdout line and nothing else. This confirms successful execution to the cron watchdog.
|
|
48068
|
-
|
|
48069
|
-
If you have nothing useful to say (data is dull, all signals are nominal), print \`HEARTBEAT_OK\` without calling \`reply\` \u2014 a silent heartbeat is correct behaviour, not an error.
|
|
48070
|
-
${issuesBlock}`;
|
|
48071
|
-
}
|
|
48072
|
-
function applyCronTelegramGuidance(body, args) {
|
|
48073
|
-
if (!args.chatId)
|
|
48074
|
-
return body;
|
|
48075
|
-
const trimmed = body.replace(/\s+$/, "");
|
|
48076
|
-
return trimmed + buildCronTelegramGuidance({ chatId: args.chatId, jobSlug: args.jobSlug });
|
|
48077
|
-
}
|
|
48078
48031
|
|
|
48079
48032
|
// src/agents/scaffold.ts
|
|
48080
48033
|
init_hindsight();
|
|
@@ -48584,70 +48537,6 @@ function parseDurationToSeconds(d) {
|
|
|
48584
48537
|
return;
|
|
48585
48538
|
}
|
|
48586
48539
|
}
|
|
48587
|
-
function buildCronScript(agentDir, prompt, model, chatId, userId, secrets = [], brokerSocket, jobSlug) {
|
|
48588
|
-
const slug = jobSlug ?? "unknown";
|
|
48589
|
-
const wrappedPrompt = applyCronTelegramGuidance(prompt, { chatId, jobSlug: slug });
|
|
48590
|
-
const secretsComment = secrets.length > 0 ? `# Allowed vault keys for this cron (broker ACL): ${secrets.join(", ")}
|
|
48591
|
-
` : "";
|
|
48592
|
-
const brokerSocketExport = brokerSocket ? `export SWITCHROOM_VAULT_BROKER_SOCK=${shellSingleQuote(brokerSocket)}
|
|
48593
|
-
` : "";
|
|
48594
|
-
return `#!/bin/bash
|
|
48595
|
-
# Auto-generated by switchroom scaffold/reconcile.
|
|
48596
|
-
# One-shot scheduled task \u2014 runs claude -p, delivers output via MCP reply tool.
|
|
48597
|
-
${secretsComment}${brokerSocketExport}
|
|
48598
|
-
export NVM_DIR="$HOME/.nvm"
|
|
48599
|
-
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
|
|
48600
|
-
export PATH="$HOME/.bun/bin:$PATH"
|
|
48601
|
-
|
|
48602
|
-
cd ${shellSingleQuote(agentDir)}
|
|
48603
|
-
|
|
48604
|
-
# Auth: always OAuth, never API key.
|
|
48605
|
-
# Defensively unset ANTHROPIC_API_KEY so any ambient env or systemd
|
|
48606
|
-
# Environment= mapping cannot silently shift cron auth from OAuth
|
|
48607
|
-
# subscription quota to API billing.
|
|
48608
|
-
unset ANTHROPIC_API_KEY
|
|
48609
|
-
export CLAUDE_CONFIG_DIR=${shellSingleQuote(agentDir + "/.claude")}
|
|
48610
|
-
# SWITCHROOM_AGENT_NAME mirrors the gateway's container/unit environment.
|
|
48611
|
-
# Required so in-prompt \`switchroom issues
|
|
48612
|
-
# record\` calls without an explicit --agent flag attribute correctly,
|
|
48613
|
-
# and so the vault broker client can resolve a default agent.
|
|
48614
|
-
export SWITCHROOM_AGENT_NAME=${shellSingleQuote(basename3(agentDir))}
|
|
48615
|
-
|
|
48616
|
-
# CLAUDE_CODE_OAUTH_TOKEN injection was removed with RFC H (auth-broker).
|
|
48617
|
-
# Claude reads .credentials.json directly; the auth-broker writes it
|
|
48618
|
-
# atomically and refreshes ahead of claude's own window.
|
|
48619
|
-
unset CLAUDE_CODE_OAUTH_TOKEN
|
|
48620
|
-
|
|
48621
|
-
# MCP-only delivery path (issue #269, closes #251): the prompt instructs
|
|
48622
|
-
# the model to call mcp__switchroom-telegram__reply directly, then print
|
|
48623
|
-
# HEARTBEAT_OK as its sole stdout line. Stdout is discarded here so the
|
|
48624
|
-
# trailing model summary doesn't arrive as a second Telegram message.
|
|
48625
|
-
# Stderr remains open so systemd captures auth/network/bad-prompt errors
|
|
48626
|
-
# via journalctl \u2014 silently swallowing those would make a broken cron job
|
|
48627
|
-
# invisible to operators.
|
|
48628
|
-
#
|
|
48629
|
-
# We deliberately do NOT use \`exec\` here \u2014 the success-trailer below must
|
|
48630
|
-
# run after \`claude -p\` returns. Same reasoning as PR #565: when a cron
|
|
48631
|
-
# completes cleanly, any unresolved issues filed against this job's source
|
|
48632
|
-
# get auto-closed. Failure (non-zero exit) leaves issues open for the
|
|
48633
|
-
# Telegram surface to render, exactly as before.
|
|
48634
|
-
export TELEGRAM_STATE_DIR=${shellSingleQuote(join8(agentDir, "telegram"))}
|
|
48635
|
-
claude -p ${shellSingleQuote(wrappedPrompt)} \\
|
|
48636
|
-
--model ${shellSingleQuote(model)} \\
|
|
48637
|
-
--no-session-persistence \\
|
|
48638
|
-
> /dev/null
|
|
48639
|
-
rc=$?
|
|
48640
|
-
if [ $rc -eq 0 ]; then
|
|
48641
|
-
# Best-effort auto-resolve. Failure here (e.g. switchroom not on PATH in a
|
|
48642
|
-
# weird environment) must NOT mask the cron's own success \u2014 hence the
|
|
48643
|
-
# trailing \`|| true\`. PR #565 added bulk-close-by-source; we use the same
|
|
48644
|
-
# source string the agent's own \`issues record\` calls should use.
|
|
48645
|
-
switchroom issues resolve --source "cron:${slug}" --quiet \\
|
|
48646
|
-
--state-dir "$TELEGRAM_STATE_DIR" >/dev/null 2>&1 || true
|
|
48647
|
-
fi
|
|
48648
|
-
exit $rc
|
|
48649
|
-
`;
|
|
48650
|
-
}
|
|
48651
48540
|
function resolveSkillsPoolDir(override) {
|
|
48652
48541
|
return resolveDualPath(override ?? "~/.switchroom/skills");
|
|
48653
48542
|
}
|
|
@@ -49663,22 +49552,6 @@ ${body}
|
|
|
49663
49552
|
writeFileSync5(mdPath, content, "utf-8");
|
|
49664
49553
|
}
|
|
49665
49554
|
}
|
|
49666
|
-
if ((agentConfig.schedule?.length ?? 0) > 0) {
|
|
49667
|
-
const cronChatId = userId ?? telegramConfig.forum_chat_id;
|
|
49668
|
-
const brokerSocket = switchroomConfig?.vault?.broker?.socket ? resolveDualPath(switchroomConfig.vault.broker.socket) : resolveDualPath("~/.switchroom/vault-broker.sock");
|
|
49669
|
-
for (let i = 0;i < agentConfig.schedule.length; i++) {
|
|
49670
|
-
const entry = agentConfig.schedule[i];
|
|
49671
|
-
const model = entry.model ?? "claude-sonnet-4-6";
|
|
49672
|
-
const filename = cronScriptFilename(entry.cron, entry.prompt);
|
|
49673
|
-
const stem = filename.replace(/\.sh$/, "");
|
|
49674
|
-
const script = buildCronScript(agentDir, entry.prompt, model, cronChatId, userId, entry.secrets ?? [], brokerSocket, stem);
|
|
49675
|
-
const scriptPath = join8(agentDir, "telegram", filename);
|
|
49676
|
-
writeFileSync5(scriptPath, script, { encoding: "utf-8", mode: 448 });
|
|
49677
|
-
const source = entry[OVERLAY_SOURCE] ? "overlay" : "main";
|
|
49678
|
-
writeFileSync5(join8(agentDir, "telegram", `${stem}.source`), `${source}
|
|
49679
|
-
`, { encoding: "utf-8", mode: 384 });
|
|
49680
|
-
}
|
|
49681
|
-
}
|
|
49682
49555
|
copyProfileSkills(profilePath, join8(agentDir, ".claude", "skills"));
|
|
49683
49556
|
renderProfileClaudeTemplate(agentConfig.extends ?? DEFAULT_PROFILE);
|
|
49684
49557
|
if (agentConfig.skills && agentConfig.skills.length > 0) {
|
|
@@ -50439,55 +50312,19 @@ ${body}
|
|
|
50439
50312
|
changes.push(settingsPath);
|
|
50440
50313
|
}
|
|
50441
50314
|
}
|
|
50442
|
-
|
|
50443
|
-
|
|
50444
|
-
const
|
|
50445
|
-
|
|
50446
|
-
|
|
50447
|
-
|
|
50448
|
-
|
|
50449
|
-
|
|
50450
|
-
|
|
50451
|
-
|
|
50452
|
-
|
|
50453
|
-
|
|
50454
|
-
|
|
50455
|
-
const entry = agentConfig.schedule[i];
|
|
50456
|
-
const model = entry.model ?? "claude-sonnet-4-6";
|
|
50457
|
-
const filename = cronScriptFilename(entry.cron, entry.prompt);
|
|
50458
|
-
canonicalFilenames.add(filename);
|
|
50459
|
-
const script = buildCronScript(agentDir, entry.prompt, model, reconCronChatId, cronUserId, entry.secrets ?? [], reconBrokerSocket, filename.replace(/\.sh$/, ""));
|
|
50460
|
-
const scriptPath = join8(agentDir, "telegram", filename);
|
|
50461
|
-
const before = existsSync11(scriptPath) ? readFileSync11(scriptPath, "utf-8") : "";
|
|
50462
|
-
if (script !== before) {
|
|
50463
|
-
writeFileSync5(scriptPath, script, { encoding: "utf-8", mode: 448 });
|
|
50464
|
-
changes.push(scriptPath);
|
|
50465
|
-
}
|
|
50466
|
-
const source = entry[OVERLAY_SOURCE] ? "overlay" : "main";
|
|
50467
|
-
const stem = filename.replace(/\.sh$/, "");
|
|
50468
|
-
const sidecarPath = join8(agentDir, "telegram", `${stem}.source`);
|
|
50469
|
-
const sidecarBody = `${source}
|
|
50470
|
-
`;
|
|
50471
|
-
const sidecarBefore = existsSync11(sidecarPath) ? readFileSync11(sidecarPath, "utf-8") : "";
|
|
50472
|
-
if (sidecarBody !== sidecarBefore) {
|
|
50473
|
-
writeFileSync5(sidecarPath, sidecarBody, { encoding: "utf-8", mode: 384 });
|
|
50474
|
-
changes.push(sidecarPath);
|
|
50475
|
-
}
|
|
50476
|
-
}
|
|
50477
|
-
const telegramDir = join8(agentDir, "telegram");
|
|
50478
|
-
if (existsSync11(telegramDir)) {
|
|
50479
|
-
const files = readdirSync5(telegramDir);
|
|
50480
|
-
for (const file of files) {
|
|
50481
|
-
const isCron = CRON_SCRIPT_BASENAME_RE.test(file) || LEGACY_CRON_SCRIPT_BASENAME_RE.test(file);
|
|
50482
|
-
if (isCron && !canonicalFilenames.has(file)) {
|
|
50483
|
-
const staleScript = join8(telegramDir, file);
|
|
50484
|
-
unlinkSync4(staleScript);
|
|
50485
|
-
changes.push(staleScript);
|
|
50486
|
-
const sourceSidecar = staleScript.replace(/\.sh$/, ".source");
|
|
50487
|
-
if (existsSync11(sourceSidecar)) {
|
|
50488
|
-
unlinkSync4(sourceSidecar);
|
|
50489
|
-
changes.push(sourceSidecar);
|
|
50490
|
-
}
|
|
50315
|
+
const telegramDir = join8(agentDir, "telegram");
|
|
50316
|
+
if (existsSync11(telegramDir)) {
|
|
50317
|
+
const files = readdirSync5(telegramDir);
|
|
50318
|
+
for (const file of files) {
|
|
50319
|
+
const isCronScript = CRON_SCRIPT_BASENAME_RE.test(file) || LEGACY_CRON_SCRIPT_BASENAME_RE.test(file);
|
|
50320
|
+
if (isCronScript) {
|
|
50321
|
+
const staleScript = join8(telegramDir, file);
|
|
50322
|
+
unlinkSync4(staleScript);
|
|
50323
|
+
changes.push(staleScript);
|
|
50324
|
+
const sourceSidecar = staleScript.replace(/\.sh$/, ".source");
|
|
50325
|
+
if (existsSync11(sourceSidecar)) {
|
|
50326
|
+
unlinkSync4(sourceSidecar);
|
|
50327
|
+
changes.push(sourceSidecar);
|
|
50491
50328
|
}
|
|
50492
50329
|
}
|
|
50493
50330
|
}
|
|
@@ -56254,7 +56091,7 @@ init_compose();
|
|
|
56254
56091
|
init_vault();
|
|
56255
56092
|
import * as net3 from "node:net";
|
|
56256
56093
|
import { mkdirSync as mkdirSync20, chmodSync as chmodSync7, chownSync as chownSync2, existsSync as existsSync33, readFileSync as readFileSync29, readdirSync as readdirSync15, statSync as statSync19, unlinkSync as unlinkSync8, writeFileSync as writeFileSync18, renameSync as renameSync9 } from "node:fs";
|
|
56257
|
-
import { dirname as dirname6, resolve as resolve25, basename as
|
|
56094
|
+
import { dirname as dirname6, resolve as resolve25, basename as basename4 } from "node:path";
|
|
56258
56095
|
import * as os4 from "node:os";
|
|
56259
56096
|
import * as path3 from "node:path";
|
|
56260
56097
|
|
|
@@ -56276,7 +56113,7 @@ import {
|
|
|
56276
56113
|
unlinkSync as unlinkSync7
|
|
56277
56114
|
} from "node:fs";
|
|
56278
56115
|
import { createHash as createHash4 } from "node:crypto";
|
|
56279
|
-
import { basename as
|
|
56116
|
+
import { basename as basename3, dirname as dirname3, join as join24 } from "node:path";
|
|
56280
56117
|
function vaultLayoutPaths(home2) {
|
|
56281
56118
|
const switchroomRoot = join24(home2, ".switchroom");
|
|
56282
56119
|
return {
|
|
@@ -56429,7 +56266,7 @@ function sha256File(path) {
|
|
|
56429
56266
|
return createHash4("sha256").update(data).digest("hex");
|
|
56430
56267
|
}
|
|
56431
56268
|
function atomicReplaceWithSymlink(target, linkTarget) {
|
|
56432
|
-
const tmp = join24(dirname3(target), `.${
|
|
56269
|
+
const tmp = join24(dirname3(target), `.${basename3(target)}.symlink-tmp`);
|
|
56433
56270
|
if (existsSync31(tmp)) {
|
|
56434
56271
|
try {
|
|
56435
56272
|
unlinkSync7(tmp);
|
|
@@ -59992,6 +59829,13 @@ class VaultBroker {
|
|
|
59992
59829
|
const tmpPath = `${tokenPath}.tmp.${process.pid}`;
|
|
59993
59830
|
writeFileSync18(tmpPath, mintResult.token, { mode: 384 });
|
|
59994
59831
|
renameSync9(tmpPath, tokenPath);
|
|
59832
|
+
try {
|
|
59833
|
+
const uid = allocateAgentUid(agent);
|
|
59834
|
+
chownSync2(tokenPath, uid, uid);
|
|
59835
|
+
} catch (chownErr) {
|
|
59836
|
+
process.stderr.write(`[vault-broker] mint_grant: token written but chown failed for agent ${agent}: ${chownErr.message} (CAP_CHOWN missing?)
|
|
59837
|
+
`);
|
|
59838
|
+
}
|
|
59995
59839
|
} catch (err) {
|
|
59996
59840
|
process.stderr.write(`[vault-broker] mint_grant: failed to write token file for agent ${agent}: ${err.message}
|
|
59997
59841
|
`);
|
|
@@ -60364,12 +60208,12 @@ class VaultBroker {
|
|
|
60364
60208
|
}
|
|
60365
60209
|
function detectVaultLayoutDrift(vaultPath) {
|
|
60366
60210
|
const dir = dirname6(vaultPath);
|
|
60367
|
-
if (
|
|
60211
|
+
if (basename4(dir) !== "vault")
|
|
60368
60212
|
return;
|
|
60369
|
-
if (
|
|
60213
|
+
if (basename4(vaultPath) !== "vault.enc")
|
|
60370
60214
|
return;
|
|
60371
60215
|
const switchroomDir = dirname6(dir);
|
|
60372
|
-
if (
|
|
60216
|
+
if (basename4(switchroomDir) !== ".switchroom")
|
|
60373
60217
|
return;
|
|
60374
60218
|
const home2 = dirname6(switchroomDir);
|
|
60375
60219
|
const result = inspectVaultLayout(home2);
|
|
@@ -62646,7 +62490,7 @@ async function getVaultPassphrase() {
|
|
|
62646
62490
|
}
|
|
62647
62491
|
function registerDispatchVerb(tg, _program) {
|
|
62648
62492
|
const dispatch = tg.command("dispatch").description("Webhook dispatch utilities.");
|
|
62649
|
-
dispatch.command("test").description("Dry-run dispatch rule matching against a captured payload file. " + "Prints which rules would match and the rendered prompt, without " + "
|
|
62493
|
+
dispatch.command("test").description("Dry-run dispatch rule matching against a captured payload file. " + "Prints which rules would match and the rendered prompt, without " + "actually injecting an inbound into the agent's live session.").requiredOption("--agent <name>", "Agent name (must exist in switchroom.yaml)").requiredOption("--payload <file>", "Path to a JSON payload file").requiredOption("--event <type>", "GitHub event type (e.g. 'pull_request', 'push')").option("--source <name>", "Webhook source (default: github)", "github").action(withConfigError(async (opts) => {
|
|
62650
62494
|
const config = getConfig(_program);
|
|
62651
62495
|
const agentRaw = config.agents[opts.agent];
|
|
62652
62496
|
if (!agentRaw) {
|
|
@@ -73980,7 +73824,7 @@ function registerDriveMcpLauncherCommand(program3) {
|
|
|
73980
73824
|
|
|
73981
73825
|
// src/cli/apply.ts
|
|
73982
73826
|
init_source();
|
|
73983
|
-
import { accessSync as accessSync3, constants as fsConstants6, copyFileSync as copyFileSync11, existsSync as existsSync68, mkdirSync as mkdirSync36, readdirSync as readdirSync25, renameSync as renameSync13, writeFileSync as writeFileSync32 } from "node:fs";
|
|
73827
|
+
import { accessSync as accessSync3, chownSync as chownSync4, constants as fsConstants6, copyFileSync as copyFileSync11, existsSync as existsSync68, mkdirSync as mkdirSync36, readdirSync as readdirSync25, renameSync as renameSync13, writeFileSync as writeFileSync32 } from "node:fs";
|
|
73984
73828
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
73985
73829
|
import { spawnSync as childSpawnSync } from "node:child_process";
|
|
73986
73830
|
import readline from "node:readline";
|
|
@@ -74713,6 +74557,16 @@ async function ensureHostMountSources(config) {
|
|
|
74713
74557
|
if (!existsSync68(hostdAuditLogPath)) {
|
|
74714
74558
|
writeFileSync32(hostdAuditLogPath, "", { mode: 420 });
|
|
74715
74559
|
}
|
|
74560
|
+
for (const name of Object.keys(config.agents)) {
|
|
74561
|
+
const tokenPath = join62(home2, ".switchroom", "agents", name, ".vault-token");
|
|
74562
|
+
if (!existsSync68(tokenPath)) {
|
|
74563
|
+
writeFileSync32(tokenPath, "", { mode: 384 });
|
|
74564
|
+
}
|
|
74565
|
+
try {
|
|
74566
|
+
const uid = allocateAgentUid(name);
|
|
74567
|
+
chownSync4(tokenPath, uid, uid);
|
|
74568
|
+
} catch {}
|
|
74569
|
+
}
|
|
74716
74570
|
}
|
|
74717
74571
|
function detectComposeV2() {
|
|
74718
74572
|
try {
|
|
@@ -77016,189 +76870,12 @@ function registerHostdMcpCommand(program3) {
|
|
|
77016
76870
|
});
|
|
77017
76871
|
}
|
|
77018
76872
|
|
|
77019
|
-
// src/cli/migrate.ts
|
|
77020
|
-
init_source();
|
|
77021
|
-
init_helpers();
|
|
77022
|
-
init_loader();
|
|
77023
|
-
import {
|
|
77024
|
-
existsSync as existsSync76,
|
|
77025
|
-
readdirSync as readdirSync29,
|
|
77026
|
-
readFileSync as readFileSync61,
|
|
77027
|
-
renameSync as renameSync16,
|
|
77028
|
-
statSync as statSync29,
|
|
77029
|
-
unlinkSync as unlinkSync16
|
|
77030
|
-
} from "node:fs";
|
|
77031
|
-
import { createHash as createHash13 } from "node:crypto";
|
|
77032
|
-
import { join as join68 } from "node:path";
|
|
77033
|
-
function planCronUnitRenames(agentsDir, agents) {
|
|
77034
|
-
const plans = [];
|
|
77035
|
-
for (const [agentName, agentConfig] of Object.entries(agents)) {
|
|
77036
|
-
const schedule = agentConfig.schedule ?? [];
|
|
77037
|
-
if (schedule.length === 0)
|
|
77038
|
-
continue;
|
|
77039
|
-
const telegramDir = join68(agentsDir, agentName, "telegram");
|
|
77040
|
-
if (!existsSync76(telegramDir))
|
|
77041
|
-
continue;
|
|
77042
|
-
let entries;
|
|
77043
|
-
try {
|
|
77044
|
-
entries = readdirSync29(telegramDir);
|
|
77045
|
-
} catch {
|
|
77046
|
-
continue;
|
|
77047
|
-
}
|
|
77048
|
-
for (const file of entries) {
|
|
77049
|
-
const m = file.match(LEGACY_CRON_SCRIPT_BASENAME_RE);
|
|
77050
|
-
if (!m)
|
|
77051
|
-
continue;
|
|
77052
|
-
const idx = Number.parseInt(m[1], 10);
|
|
77053
|
-
const entry = schedule[idx];
|
|
77054
|
-
if (!entry)
|
|
77055
|
-
continue;
|
|
77056
|
-
const canonical = cronScriptFilename(entry.cron, entry.prompt);
|
|
77057
|
-
if (canonical === file)
|
|
77058
|
-
continue;
|
|
77059
|
-
plans.push({
|
|
77060
|
-
agent: agentName,
|
|
77061
|
-
from: join68(telegramDir, file),
|
|
77062
|
-
to: join68(telegramDir, canonical),
|
|
77063
|
-
scheduleIdx: idx,
|
|
77064
|
-
entry
|
|
77065
|
-
});
|
|
77066
|
-
}
|
|
77067
|
-
}
|
|
77068
|
-
return plans;
|
|
77069
|
-
}
|
|
77070
|
-
function sha256File2(path8) {
|
|
77071
|
-
return createHash13("sha256").update(readFileSync61(path8)).digest("hex");
|
|
77072
|
-
}
|
|
77073
|
-
function renamePair(from, to, opts = {}) {
|
|
77074
|
-
if (existsSync76(to)) {
|
|
77075
|
-
let identical = false;
|
|
77076
|
-
try {
|
|
77077
|
-
identical = sha256File2(from) === sha256File2(to);
|
|
77078
|
-
} catch {
|
|
77079
|
-
identical = false;
|
|
77080
|
-
}
|
|
77081
|
-
if (identical) {
|
|
77082
|
-
if (!opts.dryRun) {
|
|
77083
|
-
try {
|
|
77084
|
-
unlinkSync16(from);
|
|
77085
|
-
} catch {}
|
|
77086
|
-
}
|
|
77087
|
-
return { kind: "deduped", legacy: from };
|
|
77088
|
-
}
|
|
77089
|
-
return { kind: "skipped", reason: "target exists, legacy preserved", legacy: from };
|
|
77090
|
-
}
|
|
77091
|
-
if (!opts.dryRun)
|
|
77092
|
-
renameSync16(from, to);
|
|
77093
|
-
return { kind: "renamed" };
|
|
77094
|
-
}
|
|
77095
|
-
function extractPromptFromLegacyScript(path8) {
|
|
77096
|
-
let body;
|
|
77097
|
-
try {
|
|
77098
|
-
body = readFileSync61(path8, "utf-8");
|
|
77099
|
-
} catch {
|
|
77100
|
-
return null;
|
|
77101
|
-
}
|
|
77102
|
-
const idx = body.indexOf(`
|
|
77103
|
-
claude -p '`);
|
|
77104
|
-
if (idx < 0)
|
|
77105
|
-
return null;
|
|
77106
|
-
let i = idx + `
|
|
77107
|
-
claude -p '`.length;
|
|
77108
|
-
let out = "";
|
|
77109
|
-
while (i < body.length) {
|
|
77110
|
-
const ch = body[i];
|
|
77111
|
-
if (ch === "'") {
|
|
77112
|
-
if (body.startsWith(`'"'"'`, i)) {
|
|
77113
|
-
out += "'";
|
|
77114
|
-
i += 5;
|
|
77115
|
-
continue;
|
|
77116
|
-
}
|
|
77117
|
-
return out;
|
|
77118
|
-
}
|
|
77119
|
-
out += ch;
|
|
77120
|
-
i++;
|
|
77121
|
-
}
|
|
77122
|
-
return null;
|
|
77123
|
-
}
|
|
77124
|
-
function detectPromptDrift(legacyPath, entry, ctx) {
|
|
77125
|
-
const embedded = extractPromptFromLegacyScript(legacyPath);
|
|
77126
|
-
const expected = applyCronTelegramGuidance(entry.prompt, ctx);
|
|
77127
|
-
return {
|
|
77128
|
-
drifted: embedded !== null && embedded !== expected,
|
|
77129
|
-
embedded,
|
|
77130
|
-
expected
|
|
77131
|
-
};
|
|
77132
|
-
}
|
|
77133
|
-
function registerMigrateCommand(program3) {
|
|
77134
|
-
const cmd = program3.command("migrate").description("One-shot config/state migrations.");
|
|
77135
|
-
cmd.command("cron-unit-names").description("Rename legacy cron-<index>.sh scripts to the Phase D content-hash " + "form (cron-<sha12>.sh). Idempotent.").option("--dry-run", "Print the renames without performing them", false).option("--strict", "Treat drift (legacy script content disagrees with current schedule entry) as a hard error", false).action(withConfigError(async (opts) => {
|
|
77136
|
-
const config2 = getConfig(program3);
|
|
77137
|
-
const agentsDir = resolveAgentsDir(config2);
|
|
77138
|
-
const plans = planCronUnitRenames(agentsDir, config2.agents);
|
|
77139
|
-
if (plans.length === 0) {
|
|
77140
|
-
console.log(source_default.green("Nothing to migrate \u2014 all cron scripts already use the content-hash scheme."));
|
|
77141
|
-
return;
|
|
77142
|
-
}
|
|
77143
|
-
let driftErrors = 0;
|
|
77144
|
-
for (const p of plans) {
|
|
77145
|
-
const drift = detectPromptDrift(p.from, p.entry, {
|
|
77146
|
-
chatId: "-",
|
|
77147
|
-
jobSlug: p.to.split("/").pop().replace(/\.sh$/, "")
|
|
77148
|
-
});
|
|
77149
|
-
if (drift.drifted) {
|
|
77150
|
-
const msg = `DRIFT: ${p.from} was scaffolded with a prompt that differs from the current schedule[${p.scheduleIdx}] entry (cron=${JSON.stringify(p.entry.cron)}); renaming to ${p.to} \u2014 verify intent`;
|
|
77151
|
-
if (opts.strict) {
|
|
77152
|
-
console.error(source_default.red(`error: ${msg}`));
|
|
77153
|
-
driftErrors++;
|
|
77154
|
-
continue;
|
|
77155
|
-
}
|
|
77156
|
-
console.error(source_default.yellow(msg));
|
|
77157
|
-
}
|
|
77158
|
-
if (opts.dryRun) {
|
|
77159
|
-
console.log(source_default.cyan(`[dry-run] ${p.agent}: ${p.from} \u2192 ${p.to}`));
|
|
77160
|
-
continue;
|
|
77161
|
-
}
|
|
77162
|
-
try {
|
|
77163
|
-
const status = renamePair(p.from, p.to);
|
|
77164
|
-
const fromSidecar = p.from.replace(/\.sh$/, ".source");
|
|
77165
|
-
const toSidecar = p.to.replace(/\.sh$/, ".source");
|
|
77166
|
-
let sidecarStatus = null;
|
|
77167
|
-
if (existsSync76(fromSidecar) && statSync29(fromSidecar).isFile()) {
|
|
77168
|
-
sidecarStatus = renamePair(fromSidecar, toSidecar);
|
|
77169
|
-
}
|
|
77170
|
-
switch (status.kind) {
|
|
77171
|
-
case "renamed":
|
|
77172
|
-
console.log(source_default.green(`renamed: ${p.agent}: ${p.from} \u2192 ${p.to}`));
|
|
77173
|
-
break;
|
|
77174
|
-
case "deduped":
|
|
77175
|
-
console.log(source_default.green(`deduped: ${p.agent}: target already present with identical contents, legacy ${p.from} removed`));
|
|
77176
|
-
break;
|
|
77177
|
-
case "skipped":
|
|
77178
|
-
console.log(source_default.yellow(`skipped: target exists, legacy preserved at ${p.from}`));
|
|
77179
|
-
break;
|
|
77180
|
-
}
|
|
77181
|
-
if (sidecarStatus && sidecarStatus.kind === "skipped") {
|
|
77182
|
-
console.log(source_default.yellow(`skipped: target exists, legacy preserved at ${fromSidecar}`));
|
|
77183
|
-
} else if (sidecarStatus && sidecarStatus.kind === "deduped") {
|
|
77184
|
-
console.log(source_default.green(`deduped: sidecar ${fromSidecar} removed (identical to target)`));
|
|
77185
|
-
}
|
|
77186
|
-
} catch (err2) {
|
|
77187
|
-
console.error(source_default.red(`failed: ${p.agent}: ${p.from} \u2192 ${p.to}: ${err2.message}`));
|
|
77188
|
-
}
|
|
77189
|
-
}
|
|
77190
|
-
if (opts.strict && driftErrors > 0) {
|
|
77191
|
-
process.exitCode = 1;
|
|
77192
|
-
}
|
|
77193
|
-
}));
|
|
77194
|
-
}
|
|
77195
|
-
|
|
77196
76873
|
// src/cli/hostd.ts
|
|
77197
76874
|
init_source();
|
|
77198
76875
|
init_helpers();
|
|
77199
|
-
import { existsSync as
|
|
76876
|
+
import { existsSync as existsSync76, mkdirSync as mkdirSync40, readdirSync as readdirSync29, readFileSync as readFileSync61, writeFileSync as writeFileSync34, statSync as statSync29, copyFileSync as copyFileSync12 } from "node:fs";
|
|
77200
76877
|
import { homedir as homedir38 } from "node:os";
|
|
77201
|
-
import { join as
|
|
76878
|
+
import { join as join68 } from "node:path";
|
|
77202
76879
|
import { spawnSync as spawnSync11 } from "node:child_process";
|
|
77203
76880
|
init_audit_reader();
|
|
77204
76881
|
var DEFAULT_IMAGE_TAG = "latest";
|
|
@@ -77289,14 +76966,14 @@ networks:
|
|
|
77289
76966
|
`;
|
|
77290
76967
|
}
|
|
77291
76968
|
function hostdDir() {
|
|
77292
|
-
return
|
|
76969
|
+
return join68(homedir38(), ".switchroom", "hostd");
|
|
77293
76970
|
}
|
|
77294
76971
|
function hostdComposePath() {
|
|
77295
|
-
return
|
|
76972
|
+
return join68(hostdDir(), "docker-compose.yml");
|
|
77296
76973
|
}
|
|
77297
76974
|
function backupExistingCompose() {
|
|
77298
76975
|
const p = hostdComposePath();
|
|
77299
|
-
if (!
|
|
76976
|
+
if (!existsSync76(p))
|
|
77300
76977
|
return null;
|
|
77301
76978
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
77302
76979
|
const bak = `${p}.bak-${ts}`;
|
|
@@ -77373,7 +77050,7 @@ function doStatus() {
|
|
|
77373
77050
|
const composeYml = hostdComposePath();
|
|
77374
77051
|
console.log(source_default.bold("switchroom-hostd"));
|
|
77375
77052
|
console.log("");
|
|
77376
|
-
if (!
|
|
77053
|
+
if (!existsSync76(composeYml)) {
|
|
77377
77054
|
console.log(source_default.yellow(" compose: not installed"));
|
|
77378
77055
|
console.log(source_default.dim(" run `switchroom hostd install` to set up."));
|
|
77379
77056
|
return;
|
|
@@ -77394,15 +77071,15 @@ function doStatus() {
|
|
|
77394
77071
|
} else {
|
|
77395
77072
|
console.log(source_default.green(` container: ${ps.stdout.trim()}`));
|
|
77396
77073
|
}
|
|
77397
|
-
if (
|
|
77074
|
+
if (existsSync76(dir)) {
|
|
77398
77075
|
const entries = [];
|
|
77399
77076
|
try {
|
|
77400
|
-
for (const name of
|
|
77077
|
+
for (const name of readdirSync29(dir)) {
|
|
77401
77078
|
if (name === "docker-compose.yml" || name.startsWith("docker-compose.yml."))
|
|
77402
77079
|
continue;
|
|
77403
|
-
const sockPath =
|
|
77404
|
-
if (
|
|
77405
|
-
const st =
|
|
77080
|
+
const sockPath = join68(dir, name, "sock");
|
|
77081
|
+
if (existsSync76(sockPath)) {
|
|
77082
|
+
const st = statSync29(sockPath);
|
|
77406
77083
|
if ((st.mode & 61440) === 49152) {
|
|
77407
77084
|
entries.push(`${name} \u2192 ${sockPath}`);
|
|
77408
77085
|
}
|
|
@@ -77420,7 +77097,7 @@ function doStatus() {
|
|
|
77420
77097
|
}
|
|
77421
77098
|
function doUninstall() {
|
|
77422
77099
|
const composeYml = hostdComposePath();
|
|
77423
|
-
if (!
|
|
77100
|
+
if (!existsSync76(composeYml)) {
|
|
77424
77101
|
console.log(source_default.yellow(" No hostd install detected (no compose file at this path)."));
|
|
77425
77102
|
return;
|
|
77426
77103
|
}
|
|
@@ -77444,12 +77121,12 @@ function registerHostdCommand(program3) {
|
|
|
77444
77121
|
hostd.command("uninstall").description("Stop the hostd container. Leaves the compose file in place for re-install.").action(() => doUninstall());
|
|
77445
77122
|
hostd.command("audit").description("Tail and filter the hostd audit log (privileged-verb call history)").option("--tail <n>", "Number of matching entries to show (default: 50)", "50").option("--agent <name>", "Filter to a specific caller agent").option("--op <verb>", "Filter to a specific hostd verb (e.g. update_apply, agent_restart)").option("--error", "Show only failed (error/denied) entries").option("--verbose", "Show the captured stderr / error tail under each failed row").option("--path <file>", "Override audit log path (for debugging)").action((opts) => {
|
|
77446
77123
|
const logPath = opts.path ?? defaultAuditLogPath2();
|
|
77447
|
-
if (!
|
|
77124
|
+
if (!existsSync76(logPath)) {
|
|
77448
77125
|
console.error(source_default.yellow(`Audit log not found at ${logPath}.`) + source_default.gray(`
|
|
77449
77126
|
The log is created when hostd handles its first privileged-verb request.`));
|
|
77450
77127
|
return;
|
|
77451
77128
|
}
|
|
77452
|
-
const raw =
|
|
77129
|
+
const raw = readFileSync61(logPath, "utf-8");
|
|
77453
77130
|
const limit = Math.max(1, parseInt(opts.tail ?? "50", 10) || 50);
|
|
77454
77131
|
const filters = {
|
|
77455
77132
|
agent: opts.agent,
|
|
@@ -77528,7 +77205,6 @@ registerAgentConfigWriteCommands(program3);
|
|
|
77528
77205
|
registerAgentConfigSkillWriteCommands(program3);
|
|
77529
77206
|
registerAgentConfigMcpCommand(program3);
|
|
77530
77207
|
registerHostdMcpCommand(program3);
|
|
77531
|
-
registerMigrateCommand(program3);
|
|
77532
77208
|
registerHostdCommand(program3);
|
|
77533
77209
|
|
|
77534
77210
|
// bin/switchroom.ts
|
|
@@ -13707,7 +13707,7 @@ var AgentBindMountSchema = exports_external.object({
|
|
|
13707
13707
|
var ScheduleEntrySchema = exports_external.object({
|
|
13708
13708
|
cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
|
|
13709
13709
|
prompt: exports_external.string().describe("Prompt to send at the scheduled time"),
|
|
13710
|
-
model: exports_external.string().optional().describe("DEPRECATED / IGNORED. Pre-v0.8 the singleton scheduler ran each " + "task as an isolated
|
|
13710
|
+
model: exports_external.string().optional().describe("DEPRECATED / IGNORED. Pre-v0.8 the singleton scheduler ran each " + "task as an isolated headless invocation and could set --model per " + "task. Post cron-fold-in (v0.8) the fire is injected into the agent's " + "running session, so it always uses the agent's configured model " + "— this field has no effect. Accepted (optional) only so existing " + "configs keep validating; set the model at the agent level instead. " + "See docs/scheduling.md."),
|
|
13711
13711
|
secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default — broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary — " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing.")
|
|
13712
13712
|
});
|
|
13713
13713
|
var AgentSoulSchema = exports_external.object({
|
|
@@ -10964,7 +10964,7 @@ var init_schema = __esm(() => {
|
|
|
10964
10964
|
ScheduleEntrySchema = exports_external.object({
|
|
10965
10965
|
cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
|
|
10966
10966
|
prompt: exports_external.string().describe("Prompt to send at the scheduled time"),
|
|
10967
|
-
model: exports_external.string().optional().describe("DEPRECATED / IGNORED. Pre-v0.8 the singleton scheduler ran each " + "task as an isolated
|
|
10967
|
+
model: exports_external.string().optional().describe("DEPRECATED / IGNORED. Pre-v0.8 the singleton scheduler ran each " + "task as an isolated headless invocation and could set --model per " + "task. Post cron-fold-in (v0.8) the fire is injected into the agent's " + "running session, so it always uses the agent's configured model " + "— this field has no effect. Accepted (optional) only so existing " + "configs keep validating; set the model at the agent level instead. " + "See docs/scheduling.md."),
|
|
10968
10968
|
secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default — broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary — " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing.")
|
|
10969
10969
|
});
|
|
10970
10970
|
AgentSoulSchema = exports_external.object({
|
|
@@ -10964,7 +10964,7 @@ var init_schema = __esm(() => {
|
|
|
10964
10964
|
ScheduleEntrySchema = exports_external.object({
|
|
10965
10965
|
cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
|
|
10966
10966
|
prompt: exports_external.string().describe("Prompt to send at the scheduled time"),
|
|
10967
|
-
model: exports_external.string().optional().describe("DEPRECATED / IGNORED. Pre-v0.8 the singleton scheduler ran each " + "task as an isolated
|
|
10967
|
+
model: exports_external.string().optional().describe("DEPRECATED / IGNORED. Pre-v0.8 the singleton scheduler ran each " + "task as an isolated headless invocation and could set --model per " + "task. Post cron-fold-in (v0.8) the fire is injected into the agent's " + "running session, so it always uses the agent's configured model " + "— this field has no effect. Accepted (optional) only so existing " + "configs keep validating; set the model at the agent level instead. " + "See docs/scheduling.md."),
|
|
10968
10968
|
secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default — broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary — " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing.")
|
|
10969
10969
|
});
|
|
10970
10970
|
AgentSoulSchema = exports_external.object({
|
|
@@ -16934,6 +16934,13 @@ class VaultBroker {
|
|
|
16934
16934
|
const tmpPath = `${tokenPath}.tmp.${process.pid}`;
|
|
16935
16935
|
writeFileSync3(tmpPath, mintResult.token, { mode: 384 });
|
|
16936
16936
|
renameSync3(tmpPath, tokenPath);
|
|
16937
|
+
try {
|
|
16938
|
+
const uid = allocateAgentUid(agent);
|
|
16939
|
+
chownSync(tokenPath, uid, uid);
|
|
16940
|
+
} catch (chownErr) {
|
|
16941
|
+
process.stderr.write(`[vault-broker] mint_grant: token written but chown failed for agent ${agent}: ${chownErr.message} (CAP_CHOWN missing?)
|
|
16942
|
+
`);
|
|
16943
|
+
}
|
|
16937
16944
|
} catch (err) {
|
|
16938
16945
|
process.stderr.write(`[vault-broker] mint_grant: failed to write token file for agent ${agent}: ${err.message}
|
|
16939
16946
|
`);
|
package/package.json
CHANGED
|
@@ -244,15 +244,22 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
|
|
|
244
244
|
* must clear the draft. Best-effort: a failed clear is logged but
|
|
245
245
|
* not re-thrown — the worst case is a transient stale draft that
|
|
246
246
|
* Telegram's own 30 s draft expiry eventually mops up.
|
|
247
|
+
*
|
|
248
|
+
* #1792 — accepts an explicit `targetDraftId` so `forceNewMessage`
|
|
249
|
+
* can clear the OLD id before bumping the closure's `draftId`. The
|
|
250
|
+
* default reads the live closure, which is what stop() / retract()
|
|
251
|
+
* want — clear whatever's current at the time the call lands.
|
|
247
252
|
*/
|
|
248
|
-
async function clearDraftBestEffort(
|
|
249
|
-
|
|
253
|
+
async function clearDraftBestEffort(
|
|
254
|
+
targetDraftId: number | undefined = draftId,
|
|
255
|
+
): Promise<void> {
|
|
256
|
+
if (!usesDraftTransport || draftApi == null || targetDraftId == null) return
|
|
250
257
|
try {
|
|
251
258
|
const params: { message_thread_id?: number } = {}
|
|
252
259
|
if (threadId != null) params.message_thread_id = threadId
|
|
253
260
|
await draftApi(
|
|
254
261
|
chatId,
|
|
255
|
-
|
|
262
|
+
targetDraftId,
|
|
256
263
|
'',
|
|
257
264
|
Object.keys(params).length > 0 ? params : undefined,
|
|
258
265
|
)
|
|
@@ -531,6 +538,18 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
|
|
|
531
538
|
stopped = false
|
|
532
539
|
materialized = false
|
|
533
540
|
if (usesDraftTransport) {
|
|
541
|
+
// #1792: clear the OLD draftId BEFORE rotating. Otherwise the
|
|
542
|
+
// stale content stays in the user's compose box until the 30 s
|
|
543
|
+
// Telegram draft expiry — the typical caller (gateway.ts mid-
|
|
544
|
+
// turn rapid-steer path: `forceNewMessage(); stop();`) cleans
|
|
545
|
+
// up the prior turn's stream, so the prior draft's content is
|
|
546
|
+
// semantically retracted. Fire-and-forget — forceNewMessage is
|
|
547
|
+
// sync; the worst-case failure mode is the same 30 s expiry
|
|
548
|
+
// we'd have had without the call.
|
|
549
|
+
const staleDraftId = draftId
|
|
550
|
+
if (staleDraftId != null) {
|
|
551
|
+
void clearDraftBestEffort(staleDraftId)
|
|
552
|
+
}
|
|
534
553
|
draftId = allocateDraftId()
|
|
535
554
|
}
|
|
536
555
|
log?.(`answer-stream: forceNewMessage (gen=${generation})`)
|
|
@@ -546,6 +565,10 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
|
|
|
546
565
|
// #1704: clear the compose-box draft. stop() is sync — fire and
|
|
547
566
|
// forget. A dropped clear falls back on Telegram's own 30 s
|
|
548
567
|
// draft expiry; the worst case is a transient stale preview.
|
|
568
|
+
// (#1792: the stale-id-after-rotation hazard is owned by
|
|
569
|
+
// forceNewMessage itself now — it clears its own draftId before
|
|
570
|
+
// rotating. stop() just clears whatever's current; clearing an
|
|
571
|
+
// already-cleared or never-used id is a harmless no-op.)
|
|
549
572
|
void clearDraftBestEffort()
|
|
550
573
|
},
|
|
551
574
|
|
|
@@ -563,6 +586,8 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
|
|
|
563
586
|
// draft sitting in the user's input area and blocks them from
|
|
564
587
|
// typing until the 30 s draft expiry. Awaited so a follow-up
|
|
565
588
|
// sendMessage on the same chat doesn't race a stale draft edit.
|
|
589
|
+
// (See #1792 note in stop() — forceNewMessage owns its own stale
|
|
590
|
+
// id cleanup; retract just clears whatever's current.)
|
|
566
591
|
await clearDraftBestEffort()
|
|
567
592
|
// Delete the preliminary message if one was sent and deleteMessage
|
|
568
593
|
// is wired. Best-effort: failures are logged but not re-thrown.
|
|
@@ -23608,7 +23608,7 @@ var init_schema = __esm(() => {
|
|
|
23608
23608
|
ScheduleEntrySchema = exports_external.object({
|
|
23609
23609
|
cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
|
|
23610
23610
|
prompt: exports_external.string().describe("Prompt to send at the scheduled time"),
|
|
23611
|
-
model: exports_external.string().optional().describe("DEPRECATED / IGNORED. Pre-v0.8 the singleton scheduler ran each " + "task as an isolated
|
|
23611
|
+
model: exports_external.string().optional().describe("DEPRECATED / IGNORED. Pre-v0.8 the singleton scheduler ran each " + "task as an isolated headless invocation and could set --model per " + "task. Post cron-fold-in (v0.8) the fire is injected into the agent's " + "running session, so it always uses the agent's configured model " + "\u2014 this field has no effect. Accepted (optional) only so existing " + "configs keep validating; set the model at the agent level instead. " + "See docs/scheduling.md."),
|
|
23612
23612
|
secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default \u2014 broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary \u2014 " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing.")
|
|
23613
23613
|
});
|
|
23614
23614
|
AgentSoulSchema = exports_external.object({
|
|
@@ -37781,14 +37781,14 @@ function createAnswerStream(config) {
|
|
|
37781
37781
|
scheduledTimer = null;
|
|
37782
37782
|
}
|
|
37783
37783
|
}
|
|
37784
|
-
async function clearDraftBestEffort() {
|
|
37785
|
-
if (!usesDraftTransport || draftApi == null ||
|
|
37784
|
+
async function clearDraftBestEffort(targetDraftId = draftId) {
|
|
37785
|
+
if (!usesDraftTransport || draftApi == null || targetDraftId == null)
|
|
37786
37786
|
return;
|
|
37787
37787
|
try {
|
|
37788
37788
|
const params = {};
|
|
37789
37789
|
if (threadId != null)
|
|
37790
37790
|
params.message_thread_id = threadId;
|
|
37791
|
-
await draftApi(chatId,
|
|
37791
|
+
await draftApi(chatId, targetDraftId, "", Object.keys(params).length > 0 ? params : undefined);
|
|
37792
37792
|
} catch {}
|
|
37793
37793
|
}
|
|
37794
37794
|
async function sendDraft(text) {
|
|
@@ -38008,6 +38008,10 @@ function createAnswerStream(config) {
|
|
|
38008
38008
|
stopped = false;
|
|
38009
38009
|
materialized = false;
|
|
38010
38010
|
if (usesDraftTransport) {
|
|
38011
|
+
const staleDraftId = draftId;
|
|
38012
|
+
if (staleDraftId != null) {
|
|
38013
|
+
clearDraftBestEffort(staleDraftId);
|
|
38014
|
+
}
|
|
38011
38015
|
draftId = allocateDraftId2();
|
|
38012
38016
|
}
|
|
38013
38017
|
log?.(`answer-stream: forceNewMessage (gen=${generation})`);
|
|
@@ -39638,9 +39642,13 @@ function parseSteerPrefix(body) {
|
|
|
39638
39642
|
function escapeXmlAttribute(s) {
|
|
39639
39643
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
39640
39644
|
}
|
|
39645
|
+
function decodeXmlEntities(s) {
|
|
39646
|
+
return s.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, " ").replace(/&/g, "&");
|
|
39647
|
+
}
|
|
39641
39648
|
function formatPriorAssistantPreview(text, maxChars = 200) {
|
|
39642
39649
|
const stripped = text.replace(/<[^>]*>/g, "");
|
|
39643
|
-
const
|
|
39650
|
+
const decoded = decodeXmlEntities(stripped);
|
|
39651
|
+
const collapsed = decoded.replace(/\s+/g, " ").trim();
|
|
39644
39652
|
const truncated = collapsed.length > maxChars ? collapsed.slice(0, maxChars) : collapsed;
|
|
39645
39653
|
return escapeXmlAttribute(truncated);
|
|
39646
39654
|
}
|
|
@@ -48722,10 +48730,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
48722
48730
|
}
|
|
48723
48731
|
|
|
48724
48732
|
// ../src/build-info.ts
|
|
48725
|
-
var VERSION = "0.13.
|
|
48726
|
-
var COMMIT_SHA = "
|
|
48727
|
-
var COMMIT_DATE = "2026-05-
|
|
48728
|
-
var LATEST_PR =
|
|
48733
|
+
var VERSION = "0.13.40";
|
|
48734
|
+
var COMMIT_SHA = "1f8f075b";
|
|
48735
|
+
var COMMIT_DATE = "2026-05-25T07:28:32Z";
|
|
48736
|
+
var LATEST_PR = 1800;
|
|
48729
48737
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
48730
48738
|
|
|
48731
48739
|
// gateway/boot-version.ts
|
|
@@ -73,22 +73,53 @@ export function escapeXmlAttribute(s: string): string {
|
|
|
73
73
|
.replace(/'/g, ''')
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Decode the small set of HTML / XML entities switchroom emits when it
|
|
78
|
+
* renders model output as Telegram HTML. Pre-#1791 this function did
|
|
79
|
+
* not decode and `formatPriorAssistantPreview` then re-escaped the
|
|
80
|
+
* already-encoded entities, so a turn containing inline `<code>` would
|
|
81
|
+
* surface to the model on the next inbound as `&amp;lt;…&amp;gt;`
|
|
82
|
+
* (triple-encoded). The model had to mentally decode three layers to
|
|
83
|
+
* recover the original characters it wrote — measurably hostile to
|
|
84
|
+
* comprehension on turns with placeholders, JSX, XML, generics, etc.
|
|
85
|
+
*
|
|
86
|
+
* Decoding before re-escape closes that loop: the attribute boundary
|
|
87
|
+
* stays safe because `escapeXmlAttribute` runs unchanged at the tail.
|
|
88
|
+
*
|
|
89
|
+
* Limited to the canonical six entities — there's no general HTML
|
|
90
|
+
* entity table here, which keeps the surface predictable.
|
|
91
|
+
*/
|
|
92
|
+
function decodeXmlEntities(s: string): string {
|
|
93
|
+
return s
|
|
94
|
+
.replace(/</g, '<')
|
|
95
|
+
.replace(/>/g, '>')
|
|
96
|
+
.replace(/"/g, '"')
|
|
97
|
+
.replace(/'/g, "'")
|
|
98
|
+
.replace(/ /g, ' ')
|
|
99
|
+
// `&` last so we don't accidentally re-decode `&lt;` → `<` on
|
|
100
|
+
// a single pass — the order above relies on `&` still being
|
|
101
|
+
// intact during the prior replaces.
|
|
102
|
+
.replace(/&/g, '&')
|
|
103
|
+
}
|
|
104
|
+
|
|
76
105
|
/**
|
|
77
106
|
* Produce a short, safe preview of the last assistant turn for injection
|
|
78
107
|
* as an XML attribute. Strips HTML tags (so `<b>foo</b>` becomes `foo`),
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* survives as `&amp;` after escape, which is fine: the attribute is
|
|
84
|
-
* for the model's situational awareness, not faithful rendering.
|
|
108
|
+
* decodes the canonical six XML entities so the model sees the original
|
|
109
|
+
* characters (not triple-encoded `&amp;lt;` — see #1791), collapses
|
|
110
|
+
* all whitespace runs into single spaces, truncates to `maxChars` visible
|
|
111
|
+
* characters, then XML-escapes for safe attribute injection.
|
|
85
112
|
*/
|
|
86
113
|
export function formatPriorAssistantPreview(text: string, maxChars = 200): string {
|
|
87
114
|
// Strip HTML tags. Anything angle-bracketed between < and > goes away;
|
|
88
115
|
// this is deliberately liberal (no tag-name whitelist) because the
|
|
89
116
|
// preview is for the model's eyes only.
|
|
90
117
|
const stripped = text.replace(/<[^>]*>/g, '')
|
|
91
|
-
|
|
118
|
+
// #1791: decode entities BEFORE collapse/truncate/re-escape so the
|
|
119
|
+
// model sees the prose it actually wrote. The re-escape at the tail
|
|
120
|
+
// preserves attribute-injection safety.
|
|
121
|
+
const decoded = decodeXmlEntities(stripped)
|
|
122
|
+
const collapsed = decoded.replace(/\s+/g, ' ').trim()
|
|
92
123
|
const truncated = collapsed.length > maxChars ? collapsed.slice(0, maxChars) : collapsed
|
|
93
124
|
return escapeXmlAttribute(truncated)
|
|
94
125
|
}
|
|
@@ -527,6 +527,92 @@ describe('answer-stream — clears sendMessageDraft on terminal paths (#1704)',
|
|
|
527
527
|
})
|
|
528
528
|
})
|
|
529
529
|
|
|
530
|
+
// ─── #1792 — forceNewMessage clears the stale draftId before rotating ───
|
|
531
|
+
//
|
|
532
|
+
// Background: `forceNewMessage()` rotates `draftId` to a fresh allocation
|
|
533
|
+
// so the stream can be re-used for a new turn (typical caller: gateway
|
|
534
|
+
// rapid-steer path in `handleSessionEvent` enqueue branch — calls
|
|
535
|
+
// `forceNewMessage(); stop()` on the prior turn's stream before opening
|
|
536
|
+
// the new turn). Pre-#1792, the rotation orphaned the prior turn's
|
|
537
|
+
// draft content in the user's compose box until Telegram's 30 s draft
|
|
538
|
+
// expiry — `stop()`'s fire-and-forget clear closed over the (now-new)
|
|
539
|
+
// `draftId`, so the clear targeted the unused id, not the stale one.
|
|
540
|
+
//
|
|
541
|
+
// Post-fix: `forceNewMessage` itself clears the stale draftId BEFORE
|
|
542
|
+
// rotating. `stop()` continues to clear whatever draftId is current
|
|
543
|
+
// at the time it runs (defensive, also fine: clearing an unused id
|
|
544
|
+
// is a harmless no-op for the user).
|
|
545
|
+
|
|
546
|
+
describe('answer-stream — forceNewMessage clears the stale draft before rotating (#1792)', () => {
|
|
547
|
+
it('clears the pre-rotation draftId when forceNewMessage rotates', async () => {
|
|
548
|
+
const sendMessage = makeSendMessage()
|
|
549
|
+
const editMessageText = makeEditMessageText()
|
|
550
|
+
const sendMessageDraft = makeSendMessageDraft()
|
|
551
|
+
const stream = createAnswerStream({
|
|
552
|
+
chatId: 'chat1',
|
|
553
|
+
isPrivateChat: true,
|
|
554
|
+
throttleMs: 250,
|
|
555
|
+
sendMessage,
|
|
556
|
+
editMessageText,
|
|
557
|
+
sendMessageDraft,
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
// Open the stream — this allocates draftId N and fires sendDraft(N).
|
|
561
|
+
stream.update('first turn thought')
|
|
562
|
+
await flushMicrotasks()
|
|
563
|
+
expect(sendMessageDraft).toHaveBeenCalledTimes(1)
|
|
564
|
+
const staleDraftId = (sendMessageDraft.mock.calls[0] as unknown as [string, number, string, unknown])[1]
|
|
565
|
+
sendMessageDraft.mockClear()
|
|
566
|
+
|
|
567
|
+
// Rotate. forceNewMessage must enqueue a clear against the OLD
|
|
568
|
+
// draftId before bumping to the new allocation — pre-fix the
|
|
569
|
+
// stale content stayed in the compose box for 30 s.
|
|
570
|
+
stream.forceNewMessage()
|
|
571
|
+
await flushMicrotasks()
|
|
572
|
+
|
|
573
|
+
expect(sendMessageDraft).toHaveBeenCalledTimes(1)
|
|
574
|
+
const clearedId = (sendMessageDraft.mock.calls[0] as unknown as [string, number, string, unknown])[1]
|
|
575
|
+
const clearedText = (sendMessageDraft.mock.calls[0] as unknown as [string, number, string, unknown])[2]
|
|
576
|
+
expect(clearedId).toBe(staleDraftId)
|
|
577
|
+
expect(clearedText).toBe('')
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
it('the gateway sequence forceNewMessage(); stop() clears the stale draftId', async () => {
|
|
581
|
+
// Mirrors the only production caller — telegram-plugin/gateway/
|
|
582
|
+
// gateway.ts:6476-6477 cleans up the prior turn's answer-stream
|
|
583
|
+
// before opening a new turn (rapid steer / queue path).
|
|
584
|
+
const sendMessage = makeSendMessage()
|
|
585
|
+
const editMessageText = makeEditMessageText()
|
|
586
|
+
const sendMessageDraft = makeSendMessageDraft()
|
|
587
|
+
const stream = createAnswerStream({
|
|
588
|
+
chatId: 'chat1',
|
|
589
|
+
isPrivateChat: true,
|
|
590
|
+
throttleMs: 250,
|
|
591
|
+
sendMessage,
|
|
592
|
+
editMessageText,
|
|
593
|
+
sendMessageDraft,
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
stream.update('prior turn thought')
|
|
597
|
+
await flushMicrotasks()
|
|
598
|
+
const staleDraftId = (sendMessageDraft.mock.calls[0] as unknown as [string, number, string, unknown])[1]
|
|
599
|
+
sendMessageDraft.mockClear()
|
|
600
|
+
|
|
601
|
+
stream.forceNewMessage()
|
|
602
|
+
stream.stop()
|
|
603
|
+
await flushMicrotasks()
|
|
604
|
+
|
|
605
|
+
// The stale id must have been cleared by ONE of the two calls
|
|
606
|
+
// (forceNewMessage in this design); the new unused id may also
|
|
607
|
+
// be cleared by stop() — harmless. The load-bearing invariant
|
|
608
|
+
// is "the stale id reaches sendMessageDraft('') somewhere".
|
|
609
|
+
const clearedIds = (sendMessageDraft.mock.calls as unknown as Array<[string, number, string, unknown]>)
|
|
610
|
+
.filter(c => c[2] === '')
|
|
611
|
+
.map(c => c[1])
|
|
612
|
+
expect(clearedIds).toContain(staleDraftId)
|
|
613
|
+
})
|
|
614
|
+
})
|
|
615
|
+
|
|
530
616
|
describe('answer-stream — empty / whitespace-only text is a no-op', () => {
|
|
531
617
|
it('update("") does not trigger any transport call', async () => {
|
|
532
618
|
const sendMessage = makeSendMessage()
|
|
@@ -138,10 +138,43 @@ describe('formatPriorAssistantPreview', () => {
|
|
|
138
138
|
expect(formatPriorAssistantPreview('a & b < c')).toBe('a & b < c')
|
|
139
139
|
})
|
|
140
140
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
141
|
+
// ─── #1791 — decode entities before re-escape ───────────────────────────
|
|
142
|
+
// Pre-fix this function did NOT decode, so an already-encoded source
|
|
143
|
+
// (e.g. the rendered HTML stored in history) was re-escaped on top of
|
|
144
|
+
// its own encoding. The model saw `&amp;lt;bar&amp;gt;` (triple
|
|
145
|
+
// encoded) instead of `<bar>`. Decoding before the trim/re-escape pass
|
|
146
|
+
// closes that loop; the attribute boundary stays safe because
|
|
147
|
+
// escapeXmlAttribute runs unchanged at the tail.
|
|
148
|
+
|
|
149
|
+
test('decodes & before re-escape (single-pass, not triple) — #1791', () => {
|
|
150
|
+
// Source stored in history as escaped HTML: `a & b`.
|
|
151
|
+
// Pre-fix output: `a &amp; b`. Post-fix: `a & b` (single).
|
|
152
|
+
expect(formatPriorAssistantPreview('a & b')).toBe('a & b')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('decodes < / > inside stripped tags — #1791', () => {
|
|
156
|
+
// The classic #1120 case: model wrote `Path: \`/tmp/foo-<bar>/\``,
|
|
157
|
+
// markdownToHtml stored it as `<code>/tmp/foo-<bar>/</code>`,
|
|
158
|
+
// strip removes the <code> tags, decode brings back the angle
|
|
159
|
+
// brackets, escape re-encodes safely for the attribute.
|
|
160
|
+
expect(formatPriorAssistantPreview('<code>/tmp/foo-<bar>/</code>'))
|
|
161
|
+
.toBe('/tmp/foo-<bar>/')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('decodes " / ' / — #1791', () => {
|
|
165
|
+
expect(formatPriorAssistantPreview('say "hi"')).toBe('say "hi"')
|
|
166
|
+
expect(formatPriorAssistantPreview('it's here')).toBe("it's here")
|
|
167
|
+
expect(formatPriorAssistantPreview('a b')).toBe('a b')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('does not over-decode: bare `&lt;` decodes to `<`, not `<` — #1791', () => {
|
|
171
|
+
// The decode order (< / > / " / ' / first, then
|
|
172
|
+
// &) ensures a single pass doesn't strip two layers of escape.
|
|
173
|
+
// A literal `&lt;` in source (i.e. someone deliberately encoded
|
|
174
|
+
// the word "<") becomes `<` after one decode pass, and then
|
|
175
|
+
// re-escapes back to `&lt;`. Pin this so the order isn't accidentally
|
|
176
|
+
// flipped to a re-decode loop.
|
|
177
|
+
expect(formatPriorAssistantPreview('&lt;')).toBe('&lt;')
|
|
145
178
|
})
|
|
146
179
|
|
|
147
180
|
test('empty string returns empty', () => {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UAT — pending-progress edit preserves HTML formatting (#1698 regression gate).
|
|
3
|
+
*
|
|
4
|
+
* Promoted from the one-off `pr1706-pending-progress-html-dm.test.ts`
|
|
5
|
+
* verification scenario per #1793. The pending-progress / silent-anchor
|
|
6
|
+
* / answer-stream code family in `telegram-plugin/` all touch the
|
|
7
|
+
* parse_mode contract on cross-turn edits; the existing UAT suite
|
|
8
|
+
* (`cross-turn-pending-progress-dm.test.ts`, `jtbd-fast-trivial-dm.test.ts`,
|
|
9
|
+
* `jtbd-soft-commit-dm.test.ts`) covers cadence / round-trip / pacing
|
|
10
|
+
* but does NOT pin the parse_mode contract. #1698 shipped to prod and
|
|
11
|
+
* the existing suite went green throughout — this scenario closes that
|
|
12
|
+
* blind spot.
|
|
13
|
+
*
|
|
14
|
+
* Method:
|
|
15
|
+
* 1. Ask the agent to send ONE reply with both <b> and <code> via
|
|
16
|
+
* the reply tool (default html format).
|
|
17
|
+
* 2. Dispatch a background bash so the turn ends with pending async.
|
|
18
|
+
* 3. End turn. Pending-progress activates.
|
|
19
|
+
* 4. After ~60-90s, observe the first edit. Assert text reads back
|
|
20
|
+
* WITHOUT literal `<b>` / `<code>` substrings (Telegram parsed
|
|
21
|
+
* under HTML, formatting moved to entities, mtcute Message.text
|
|
22
|
+
* returns plain prose). Pre-fix, parse_mode was dropped on the
|
|
23
|
+
* edit and the tags would render as literal characters.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, it, expect } from "vitest";
|
|
27
|
+
import { spinUp, type ObservedMessage } from "../harness.js";
|
|
28
|
+
|
|
29
|
+
const SLEEP_SECONDS = 90;
|
|
30
|
+
const OVERALL_DEADLINE_MS = 4 * 60_000;
|
|
31
|
+
|
|
32
|
+
const PROMPT =
|
|
33
|
+
`Please run \`sleep ${SLEEP_SECONDS}\` in the background using the ` +
|
|
34
|
+
`Bash tool with \`run_in_background: true\`. Send exactly ONE reply, ` +
|
|
35
|
+
`using the reply tool with default html format, containing this text ` +
|
|
36
|
+
`VERBATIM:\n\n` +
|
|
37
|
+
`<b>Worker dispatched.</b> Running <code>sleep ${SLEEP_SECONDS}</code> ` +
|
|
38
|
+
`in background.\n\n` +
|
|
39
|
+
`Do NOT send any other reply until the sleep finishes. Just dispatch ` +
|
|
40
|
+
`the bash, send that one HTML reply, end your turn. When it finishes ` +
|
|
41
|
+
`much later, reply with the single word "done".`;
|
|
42
|
+
|
|
43
|
+
const SUFFIX_RE = /\n\n— still working \(\d+m\)$/;
|
|
44
|
+
|
|
45
|
+
describe("uat: pending-progress edit preserves HTML formatting (#1698 regression gate)", () => {
|
|
46
|
+
it(
|
|
47
|
+
"first pending-progress edit reads back WITHOUT literal HTML tags",
|
|
48
|
+
async () => {
|
|
49
|
+
const sc = await spinUp({ agent: "test-harness" });
|
|
50
|
+
try {
|
|
51
|
+
const startedAt = Date.now();
|
|
52
|
+
await sc.sendDM(PROMPT);
|
|
53
|
+
|
|
54
|
+
let anchorMsgId: number | null = null;
|
|
55
|
+
let editText: string | null = null;
|
|
56
|
+
const deadline = startedAt + OVERALL_DEADLINE_MS;
|
|
57
|
+
|
|
58
|
+
while (Date.now() < deadline) {
|
|
59
|
+
try {
|
|
60
|
+
const msg = await sc.expectMessage(
|
|
61
|
+
(m: ObservedMessage) => m.fromBot,
|
|
62
|
+
{ from: "bot", timeout: deadline - Date.now() },
|
|
63
|
+
);
|
|
64
|
+
const rel = Date.now() - startedAt;
|
|
65
|
+
console.log(
|
|
66
|
+
`[jtbd-pending-progress-html] +${(rel / 1000).toFixed(1)}s ` +
|
|
67
|
+
`${msg.edited ? "EDIT" : "FRESH"} msg=${msg.messageId} ` +
|
|
68
|
+
`${JSON.stringify(msg.text.slice(0, 120))}`,
|
|
69
|
+
);
|
|
70
|
+
if (!msg.edited && anchorMsgId == null) {
|
|
71
|
+
anchorMsgId = msg.messageId;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (
|
|
75
|
+
msg.edited &&
|
|
76
|
+
anchorMsgId === msg.messageId &&
|
|
77
|
+
SUFFIX_RE.test(msg.text)
|
|
78
|
+
) {
|
|
79
|
+
editText = msg.text;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
expect(
|
|
88
|
+
anchorMsgId,
|
|
89
|
+
"agent never sent its initial HTML reply — UAT env issue",
|
|
90
|
+
).not.toBeNull();
|
|
91
|
+
expect(
|
|
92
|
+
editText,
|
|
93
|
+
`no pending-progress edit observed within ${OVERALL_DEADLINE_MS / 1000}s — ` +
|
|
94
|
+
`model may not have dispatched async, or pending-progress is disabled`,
|
|
95
|
+
).not.toBeNull();
|
|
96
|
+
|
|
97
|
+
// ── THE #1698 REGRESSION GATE ─────────────────────────────────
|
|
98
|
+
// mtcute's Message.text returns the parsed text — formatting
|
|
99
|
+
// lives in `entities`. So a working parse_mode=HTML edit shows
|
|
100
|
+
// clean prose with no literal "<b>" / "<code>" substrings.
|
|
101
|
+
// Pre-fix the gateway dropped parse_mode on the cross-turn
|
|
102
|
+
// edit and Telegram stored the tags as plain characters.
|
|
103
|
+
expect(
|
|
104
|
+
editText,
|
|
105
|
+
`#1698 regression: pending-progress edit text contains literal "<b>" — ` +
|
|
106
|
+
`parse_mode was dropped and Telegram is storing the original HTML tags as plain.`,
|
|
107
|
+
).not.toContain("<b>");
|
|
108
|
+
expect(editText).not.toContain("</b>");
|
|
109
|
+
expect(editText).not.toContain("<code>");
|
|
110
|
+
expect(editText).not.toContain("</code>");
|
|
111
|
+
|
|
112
|
+
// Sanity — the model's prose is still visible (without tags).
|
|
113
|
+
expect(editText).toContain("Worker dispatched");
|
|
114
|
+
|
|
115
|
+
// Belt-and-braces — the suffix landed (proves the edit was
|
|
116
|
+
// pending-progress and not some other path).
|
|
117
|
+
expect(editText).toMatch(SUFFIX_RE);
|
|
118
|
+
} finally {
|
|
119
|
+
await sc.tearDown();
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
OVERALL_DEADLINE_MS + 30_000,
|
|
123
|
+
);
|
|
124
|
+
});
|