switchroom 0.13.33 → 0.13.36
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/bin/timezone-hook.sh +1 -1
- package/dist/agent-scheduler/index.js +8 -1
- package/dist/auth-broker/index.js +8 -1
- package/dist/cli/switchroom.js +176 -26
- package/dist/host-control/main.js +5222 -203
- package/dist/vault/approvals/kernel-server.js +9 -2
- package/dist/vault/broker/server.js +9 -2
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +234 -31
- package/telegram-plugin/docs/waiting-ux-spec.md +40 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +188 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +170 -15
- package/telegram-plugin/gateway/diff-preview-card.test.ts +2 -2
- package/telegram-plugin/gateway/diff-preview-card.ts +2 -2
- package/telegram-plugin/gateway/drive-write-approval.test.ts +70 -0
- package/telegram-plugin/gateway/drive-write-approval.ts +51 -2
- package/telegram-plugin/gateway/error-envelope-card.ts +64 -0
- package/telegram-plugin/gateway/gateway.ts +112 -15
- package/telegram-plugin/gateway/ipc-protocol.ts +10 -1
- package/telegram-plugin/gateway/oversize-card-body.test.ts +108 -0
- package/telegram-plugin/gateway/oversize-card-body.ts +114 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +46 -1
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +118 -41
- package/telegram-plugin/hooks/silent-end-scan.mjs +190 -0
- package/telegram-plugin/pending-work-progress.ts +37 -1
- package/telegram-plugin/tests/boot-clears-clean-shutdown-marker.test.ts +75 -0
- package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +79 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +134 -0
- package/telegram-plugin/tests/silent-end-integration.test.ts +268 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +242 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +314 -0
- package/telegram-plugin/tests/silent-end.test.ts +227 -38
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +51 -6
|
@@ -10948,7 +10948,7 @@ var init_dist = __esm(() => {
|
|
|
10948
10948
|
});
|
|
10949
10949
|
|
|
10950
10950
|
// src/config/schema.ts
|
|
10951
|
-
var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, HostControlConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
|
|
10951
|
+
var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
|
|
10952
10952
|
var init_schema = __esm(() => {
|
|
10953
10953
|
init_zod();
|
|
10954
10954
|
CodeRepoEntrySchema = exports_external.object({
|
|
@@ -11294,8 +11294,15 @@ var init_schema = __esm(() => {
|
|
|
11294
11294
|
weekly_budget_usd: exports_external.number().positive().optional().describe("Weekly USD spend budget. If unset, the greeting shows raw usage only."),
|
|
11295
11295
|
monthly_budget_usd: exports_external.number().positive().optional().describe("Monthly USD spend budget. If unset, the greeting shows raw usage only.")
|
|
11296
11296
|
});
|
|
11297
|
+
AutoReleaseCheckSchema = exports_external.object({
|
|
11298
|
+
enabled: exports_external.boolean().default(false).describe("When true, hostd polls the remote release tag every " + "`interval_minutes` and applies + restarts the fleet when a new " + "release is detected. Default false — opt-in."),
|
|
11299
|
+
interval_minutes: exports_external.number().int().min(5).max(1440).default(5).describe("Poll interval in minutes. Floor of 5m matches the agent-config " + "cron rate limit; ceiling of 1440m (24h) is a sanity cap."),
|
|
11300
|
+
apply_on_detect: exports_external.boolean().default(true).describe("When false, hostd logs `release_detected` but does NOT call " + "update_apply / restart all. Useful for dogfooding the detector " + "without rolling the fleet."),
|
|
11301
|
+
image_ref: exports_external.string().default("ghcr.io/switchroom/switchroom-agent:latest").describe("Image reference whose remote digest is compared to the local " + "image digest. Defaults to the agent image's :latest tag, which " + "is the canonical signal that a release has been promoted.")
|
|
11302
|
+
});
|
|
11297
11303
|
HostControlConfigSchema = exports_external.object({
|
|
11298
|
-
enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip — the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` — " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C §5.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3).")
|
|
11304
|
+
enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip — the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` — " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C §5.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3)."),
|
|
11305
|
+
auto_release_check: AutoReleaseCheckSchema.default({}).describe("Pull-based release-triggered fleet restart (#1743). hostd polls " + "the remote release tag on a fixed interval and applies + " + "restarts the fleet (graceful) when a new release is detected. " + "Opt-in: default enabled=false.")
|
|
11299
11306
|
});
|
|
11300
11307
|
HostdConfigSchema = exports_external.object({
|
|
11301
11308
|
config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
|
|
@@ -10948,7 +10948,7 @@ var init_zod = __esm(() => {
|
|
|
10948
10948
|
});
|
|
10949
10949
|
|
|
10950
10950
|
// src/config/schema.ts
|
|
10951
|
-
var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, HostControlConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
|
|
10951
|
+
var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
|
|
10952
10952
|
var init_schema = __esm(() => {
|
|
10953
10953
|
init_zod();
|
|
10954
10954
|
CodeRepoEntrySchema = exports_external.object({
|
|
@@ -11294,8 +11294,15 @@ var init_schema = __esm(() => {
|
|
|
11294
11294
|
weekly_budget_usd: exports_external.number().positive().optional().describe("Weekly USD spend budget. If unset, the greeting shows raw usage only."),
|
|
11295
11295
|
monthly_budget_usd: exports_external.number().positive().optional().describe("Monthly USD spend budget. If unset, the greeting shows raw usage only.")
|
|
11296
11296
|
});
|
|
11297
|
+
AutoReleaseCheckSchema = exports_external.object({
|
|
11298
|
+
enabled: exports_external.boolean().default(false).describe("When true, hostd polls the remote release tag every " + "`interval_minutes` and applies + restarts the fleet when a new " + "release is detected. Default false — opt-in."),
|
|
11299
|
+
interval_minutes: exports_external.number().int().min(5).max(1440).default(5).describe("Poll interval in minutes. Floor of 5m matches the agent-config " + "cron rate limit; ceiling of 1440m (24h) is a sanity cap."),
|
|
11300
|
+
apply_on_detect: exports_external.boolean().default(true).describe("When false, hostd logs `release_detected` but does NOT call " + "update_apply / restart all. Useful for dogfooding the detector " + "without rolling the fleet."),
|
|
11301
|
+
image_ref: exports_external.string().default("ghcr.io/switchroom/switchroom-agent:latest").describe("Image reference whose remote digest is compared to the local " + "image digest. Defaults to the agent image's :latest tag, which " + "is the canonical signal that a release has been promoted.")
|
|
11302
|
+
});
|
|
11297
11303
|
HostControlConfigSchema = exports_external.object({
|
|
11298
|
-
enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip — the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` — " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C §5.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3).")
|
|
11304
|
+
enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip — the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` — " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C §5.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3)."),
|
|
11305
|
+
auto_release_check: AutoReleaseCheckSchema.default({}).describe("Pull-based release-triggered fleet restart (#1743). hostd polls " + "the remote release tag on a fixed interval and applies + " + "restarts the fleet (graceful) when a new release is detected. " + "Opt-in: default enabled=false.")
|
|
11299
11306
|
});
|
|
11300
11307
|
HostdConfigSchema = exports_external.object({
|
|
11301
11308
|
config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
|
package/package.json
CHANGED
|
@@ -108,7 +108,7 @@ If no sub-agents are configured, do the work yourself.
|
|
|
108
108
|
|
|
109
109
|
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:
|
|
110
110
|
|
|
111
|
-
- **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
|
|
111
|
+
- **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.
|
|
112
112
|
- **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.
|
|
113
113
|
- **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.
|
|
114
114
|
- **`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.
|
|
@@ -23592,7 +23592,7 @@ var init_dist = __esm(() => {
|
|
|
23592
23592
|
});
|
|
23593
23593
|
|
|
23594
23594
|
// ../src/config/schema.ts
|
|
23595
|
-
var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, HostControlConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
|
|
23595
|
+
var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
|
|
23596
23596
|
var init_schema = __esm(() => {
|
|
23597
23597
|
init_zod();
|
|
23598
23598
|
CodeRepoEntrySchema = exports_external.object({
|
|
@@ -23938,8 +23938,15 @@ var init_schema = __esm(() => {
|
|
|
23938
23938
|
weekly_budget_usd: exports_external.number().positive().optional().describe("Weekly USD spend budget. If unset, the greeting shows raw usage only."),
|
|
23939
23939
|
monthly_budget_usd: exports_external.number().positive().optional().describe("Monthly USD spend budget. If unset, the greeting shows raw usage only.")
|
|
23940
23940
|
});
|
|
23941
|
+
AutoReleaseCheckSchema = exports_external.object({
|
|
23942
|
+
enabled: exports_external.boolean().default(false).describe("When true, hostd polls the remote release tag every " + "`interval_minutes` and applies + restarts the fleet when a new " + "release is detected. Default false \u2014 opt-in."),
|
|
23943
|
+
interval_minutes: exports_external.number().int().min(5).max(1440).default(5).describe("Poll interval in minutes. Floor of 5m matches the agent-config " + "cron rate limit; ceiling of 1440m (24h) is a sanity cap."),
|
|
23944
|
+
apply_on_detect: exports_external.boolean().default(true).describe("When false, hostd logs `release_detected` but does NOT call " + "update_apply / restart all. Useful for dogfooding the detector " + "without rolling the fleet."),
|
|
23945
|
+
image_ref: exports_external.string().default("ghcr.io/switchroom/switchroom-agent:latest").describe("Image reference whose remote digest is compared to the local " + "image digest. Defaults to the agent image's :latest tag, which " + "is the canonical signal that a release has been promoted.")
|
|
23946
|
+
});
|
|
23941
23947
|
HostControlConfigSchema = exports_external.object({
|
|
23942
|
-
enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip \u2014 the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` \u2014 " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C \u00a75.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3).")
|
|
23948
|
+
enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip \u2014 the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` \u2014 " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C \u00a75.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3)."),
|
|
23949
|
+
auto_release_check: AutoReleaseCheckSchema.default({}).describe("Pull-based release-triggered fleet restart (#1743). hostd polls " + "the remote release tag on a fixed interval and applies + " + "restarts the fleet (graceful) when a new release is detected. " + "Opt-in: default enabled=false.")
|
|
23943
23950
|
});
|
|
23944
23951
|
HostdConfigSchema = exports_external.object({
|
|
23945
23952
|
config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit \u00a73). Default false \u2014 the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
|
|
@@ -25285,6 +25292,39 @@ function parseDriveScope(scope) {
|
|
|
25285
25292
|
return { action, target: { kind: "doc", doc_id: rest } };
|
|
25286
25293
|
}
|
|
25287
25294
|
|
|
25295
|
+
// gateway/oversize-card-body.ts
|
|
25296
|
+
function truncateRawToFit(input) {
|
|
25297
|
+
const { raw, render, cap, sentinel } = input;
|
|
25298
|
+
const hardLimit = input.hardLimit ?? cap + 196;
|
|
25299
|
+
const fullBody = render(raw);
|
|
25300
|
+
if (fullBody.length <= cap) {
|
|
25301
|
+
return { body: fullBody, truncated: false };
|
|
25302
|
+
}
|
|
25303
|
+
let lo = 0;
|
|
25304
|
+
let hi = raw.length;
|
|
25305
|
+
let bestSliceLen = 0;
|
|
25306
|
+
while (lo <= hi) {
|
|
25307
|
+
const mid = lo + hi >>> 1;
|
|
25308
|
+
const candidate = raw.slice(0, mid) + sentinel;
|
|
25309
|
+
if (render(candidate).length <= cap) {
|
|
25310
|
+
bestSliceLen = mid;
|
|
25311
|
+
lo = mid + 1;
|
|
25312
|
+
} else {
|
|
25313
|
+
hi = mid - 1;
|
|
25314
|
+
}
|
|
25315
|
+
}
|
|
25316
|
+
let chosenRaw = raw.slice(0, bestSliceLen);
|
|
25317
|
+
const lastNl = chosenRaw.lastIndexOf(`
|
|
25318
|
+
`);
|
|
25319
|
+
if (lastNl > 0)
|
|
25320
|
+
chosenRaw = chosenRaw.slice(0, lastNl);
|
|
25321
|
+
let body = render(chosenRaw + sentinel);
|
|
25322
|
+
if (body.length > hardLimit) {
|
|
25323
|
+
body = body.slice(0, hardLimit - 1);
|
|
25324
|
+
}
|
|
25325
|
+
return { body, truncated: true };
|
|
25326
|
+
}
|
|
25327
|
+
|
|
25288
25328
|
// secret-detect/suppressor.ts
|
|
25289
25329
|
function isSuppressed(text, start, end) {
|
|
25290
25330
|
const left = Math.max(0, start - WINDOW);
|
|
@@ -29053,49 +29093,90 @@ var init_materialize_bot_token = __esm(() => {
|
|
|
29053
29093
|
// gateway/config-approval-handler.ts
|
|
29054
29094
|
var exports_config_approval_handler = {};
|
|
29055
29095
|
__export(exports_config_approval_handler, {
|
|
29096
|
+
truncateDiffForCard: () => truncateDiffForCard,
|
|
29056
29097
|
resolvePendingConfigApproval: () => resolvePendingConfigApproval,
|
|
29057
29098
|
parseConfigApprovalCallback: () => parseConfigApprovalCallback,
|
|
29058
29099
|
handleRequestConfigFinalize: () => handleRequestConfigFinalize,
|
|
29059
29100
|
handleRequestConfigApproval: () => handleRequestConfigApproval,
|
|
29060
29101
|
buildConfigApprovalCardBody: () => buildConfigApprovalCardBody,
|
|
29061
29102
|
_resetPendingConfigApprovalsForTest: () => _resetPendingConfigApprovalsForTest,
|
|
29062
|
-
_peekPendingConfigApprovalForTest: () => _peekPendingConfigApprovalForTest
|
|
29103
|
+
_peekPendingConfigApprovalForTest: () => _peekPendingConfigApprovalForTest,
|
|
29104
|
+
DIFF_SENTINEL: () => DIFF_SENTINEL
|
|
29063
29105
|
});
|
|
29106
|
+
function truncateDiffForCard(unifiedDiff, maxLines = 50, maxChars = 3000) {
|
|
29107
|
+
const sentinel = `
|
|
29108
|
+
[\u2026 diff continues, see attached file]`;
|
|
29109
|
+
const lines = unifiedDiff.split(`
|
|
29110
|
+
`);
|
|
29111
|
+
let out;
|
|
29112
|
+
if (lines.length <= maxLines) {
|
|
29113
|
+
out = unifiedDiff;
|
|
29114
|
+
} else {
|
|
29115
|
+
out = lines.slice(0, maxLines).join(`
|
|
29116
|
+
`);
|
|
29117
|
+
}
|
|
29118
|
+
if (out.length > maxChars) {
|
|
29119
|
+
const cap = out.slice(0, maxChars);
|
|
29120
|
+
const lastNl = cap.lastIndexOf(`
|
|
29121
|
+
`);
|
|
29122
|
+
out = lastNl > 0 ? cap.slice(0, lastNl) : cap;
|
|
29123
|
+
}
|
|
29124
|
+
return out === unifiedDiff ? out : out + sentinel;
|
|
29125
|
+
}
|
|
29126
|
+
function escapeHtml11(s) {
|
|
29127
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
29128
|
+
}
|
|
29129
|
+
function clipReason(reason) {
|
|
29130
|
+
if (reason.length <= REASON_MAX_CHARS)
|
|
29131
|
+
return reason;
|
|
29132
|
+
return reason.slice(0, REASON_MAX_CHARS - REASON_ELLIPSIS.length) + REASON_ELLIPSIS;
|
|
29133
|
+
}
|
|
29064
29134
|
function buildConfigApprovalCardBody(args) {
|
|
29065
|
-
const
|
|
29066
|
-
|
|
29067
|
-
` + `Agent: <code>${
|
|
29068
|
-
` + `Reason: ${
|
|
29069
|
-
|
|
29070
|
-
` + `<pre>${
|
|
29135
|
+
const safeReason = clipReason(args.reason);
|
|
29136
|
+
const render = (diff) => `\uD83D\uDEE0 <b>Config edit proposed</b>
|
|
29137
|
+
` + `Agent: <code>${escapeHtml11(args.agentName)}</code>
|
|
29138
|
+
` + `Reason: ${escapeHtml11(safeReason)}
|
|
29139
|
+
|
|
29140
|
+
` + `<pre>${escapeHtml11(diff)}</pre>`;
|
|
29141
|
+
return truncateRawToFit({
|
|
29142
|
+
raw: args.unifiedDiff,
|
|
29143
|
+
render,
|
|
29144
|
+
cap: RENDERED_BODY_CAP2,
|
|
29145
|
+
sentinel: DIFF_SENTINEL,
|
|
29146
|
+
hardLimit: TELEGRAM_SENDMESSAGE_LIMIT2
|
|
29147
|
+
});
|
|
29071
29148
|
}
|
|
29072
29149
|
async function handleRequestConfigApproval(client3, msg, deps) {
|
|
29073
|
-
const reply = (verdict, reason) => {
|
|
29150
|
+
const reply = (verdict, reason, denySource) => {
|
|
29074
29151
|
try {
|
|
29075
29152
|
client3.send({
|
|
29076
29153
|
type: "config_approval_resolved",
|
|
29077
29154
|
requestId: msg.requestId,
|
|
29078
29155
|
verdict,
|
|
29079
|
-
...reason ? { reason } : {}
|
|
29156
|
+
...reason ? { reason } : {},
|
|
29157
|
+
...denySource ? { denySource } : {}
|
|
29080
29158
|
});
|
|
29081
29159
|
} catch (err) {
|
|
29082
29160
|
deps.log?.(`config_approval_resolved send failed (requestId=${msg.requestId}): ${err.message}`);
|
|
29083
29161
|
}
|
|
29084
29162
|
};
|
|
29085
29163
|
if (msg.agentName !== deps.agentName) {
|
|
29086
|
-
reply("deny", `gateway serves '${deps.agentName}', not '${msg.agentName}'
|
|
29164
|
+
reply("deny", `gateway serves '${deps.agentName}', not '${msg.agentName}'`, "dispatch_failure");
|
|
29087
29165
|
return;
|
|
29088
29166
|
}
|
|
29089
29167
|
const target = deps.loadTargetChat();
|
|
29090
29168
|
if (target === null) {
|
|
29091
|
-
reply("deny", "no target chat available \u2014 operator not paired?");
|
|
29169
|
+
reply("deny", "no target chat available \u2014 operator not paired?", "dispatch_failure");
|
|
29092
29170
|
return;
|
|
29093
29171
|
}
|
|
29094
|
-
const
|
|
29172
|
+
const prelim = truncateDiffForCard(msg.unifiedDiff);
|
|
29173
|
+
const built = buildConfigApprovalCardBody({
|
|
29095
29174
|
agentName: msg.agentName,
|
|
29096
29175
|
reason: msg.reason,
|
|
29097
|
-
unifiedDiff:
|
|
29176
|
+
unifiedDiff: prelim
|
|
29098
29177
|
});
|
|
29178
|
+
const body = built.body;
|
|
29179
|
+
const oversize = prelim !== msg.unifiedDiff || built.truncated;
|
|
29099
29180
|
const replyMarkup = deps.buildKeyboard(msg.requestId);
|
|
29100
29181
|
const posted = await deps.postCard({
|
|
29101
29182
|
chatId: target.chatId,
|
|
@@ -29104,9 +29185,12 @@ async function handleRequestConfigApproval(client3, msg, deps) {
|
|
|
29104
29185
|
replyMarkup
|
|
29105
29186
|
});
|
|
29106
29187
|
if (posted === null) {
|
|
29107
|
-
reply("deny", "Telegram sendMessage failed");
|
|
29188
|
+
reply("deny", "Telegram sendMessage failed", "dispatch_failure");
|
|
29108
29189
|
return;
|
|
29109
29190
|
}
|
|
29191
|
+
if (oversize) {
|
|
29192
|
+
await maybePostAttachment(deps, target, msg);
|
|
29193
|
+
}
|
|
29110
29194
|
const entry = {
|
|
29111
29195
|
requestId: msg.requestId,
|
|
29112
29196
|
client: client3,
|
|
@@ -29172,8 +29256,21 @@ ${escapeHtml11(msg.detail)}` : ""}`;
|
|
|
29172
29256
|
deps.log?.(`config finalize card edit failed (requestId=${msg.requestId}): ${err.message}`);
|
|
29173
29257
|
}
|
|
29174
29258
|
}
|
|
29175
|
-
function
|
|
29176
|
-
|
|
29259
|
+
async function maybePostAttachment(deps, target, msg) {
|
|
29260
|
+
if (deps.postAttachment === undefined) {
|
|
29261
|
+
deps.log?.(`oversize config approval card but no postAttachment dep wired (requestId=${msg.requestId})`);
|
|
29262
|
+
return;
|
|
29263
|
+
}
|
|
29264
|
+
try {
|
|
29265
|
+
await deps.postAttachment({
|
|
29266
|
+
chatId: target.chatId,
|
|
29267
|
+
...target.threadId !== undefined ? { threadId: target.threadId } : {},
|
|
29268
|
+
filename: `config-edit-${msg.requestId}.patch`,
|
|
29269
|
+
content: msg.unifiedDiff
|
|
29270
|
+
});
|
|
29271
|
+
} catch (err) {
|
|
29272
|
+
deps.log?.(`config approval attachment failed (requestId=${msg.requestId}): ${err.message}`);
|
|
29273
|
+
}
|
|
29177
29274
|
}
|
|
29178
29275
|
function _resetPendingConfigApprovalsForTest() {
|
|
29179
29276
|
for (const entry of pending.values()) {
|
|
@@ -29200,7 +29297,8 @@ function parseConfigApprovalCallback(data) {
|
|
|
29200
29297
|
return null;
|
|
29201
29298
|
return { requestId, choice };
|
|
29202
29299
|
}
|
|
29203
|
-
var pending
|
|
29300
|
+
var pending, TELEGRAM_SENDMESSAGE_LIMIT2 = 4096, RENDERED_BODY_CAP2 = 3900, REASON_MAX_CHARS = 500, REASON_ELLIPSIS = "\u2026", DIFF_SENTINEL = `
|
|
29301
|
+
[\u2026 diff continues, see attached file]`;
|
|
29204
29302
|
var init_config_approval_handler = __esm(() => {
|
|
29205
29303
|
pending = new Map;
|
|
29206
29304
|
});
|
|
@@ -37484,6 +37582,10 @@ function tick2(now) {
|
|
|
37484
37582
|
clearPending(key, "timeout");
|
|
37485
37583
|
continue;
|
|
37486
37584
|
}
|
|
37585
|
+
if (activeDeps2.isActiveTurnNewerThan != null && activeDeps2.isActiveTurnNewerThan(key, s.activatedAt)) {
|
|
37586
|
+
clearPending(key, "stale_turn");
|
|
37587
|
+
continue;
|
|
37588
|
+
}
|
|
37487
37589
|
const sinceEdit = s.lastEditAt == null ? 0 : now - s.lastEditAt;
|
|
37488
37590
|
if (sinceEdit < EDIT_INTERVAL_MS)
|
|
37489
37591
|
continue;
|
|
@@ -43307,6 +43409,45 @@ var RequestSchema3 = exports_external.discriminatedUnion("op", [
|
|
|
43307
43409
|
ConfigProposeEditRequestSchema
|
|
43308
43410
|
]);
|
|
43309
43411
|
var ResultSchema = exports_external.enum(["started", "completed", "denied", "error"]);
|
|
43412
|
+
var ErrorFixSchema = exports_external.discriminatedUnion("kind", [
|
|
43413
|
+
exports_external.object({
|
|
43414
|
+
kind: exports_external.literal("flip_yaml_flag"),
|
|
43415
|
+
yaml_path: exports_external.string(),
|
|
43416
|
+
to: exports_external.unknown()
|
|
43417
|
+
}),
|
|
43418
|
+
exports_external.object({
|
|
43419
|
+
kind: exports_external.literal("request_vault_grant"),
|
|
43420
|
+
vault_key: exports_external.string()
|
|
43421
|
+
}),
|
|
43422
|
+
exports_external.object({
|
|
43423
|
+
kind: exports_external.literal("operator_action"),
|
|
43424
|
+
subkind: exports_external.enum(["policy_denied", "infra", "out_of_scope"]),
|
|
43425
|
+
operator_steps: exports_external.array(exports_external.string()).min(1).optional()
|
|
43426
|
+
}),
|
|
43427
|
+
exports_external.object({
|
|
43428
|
+
kind: exports_external.literal("retry_after"),
|
|
43429
|
+
retry_at: exports_external.string()
|
|
43430
|
+
}),
|
|
43431
|
+
exports_external.object({
|
|
43432
|
+
kind: exports_external.literal("quota_exceeded"),
|
|
43433
|
+
quota: exports_external.string(),
|
|
43434
|
+
current: exports_external.number(),
|
|
43435
|
+
limit: exports_external.number()
|
|
43436
|
+
}),
|
|
43437
|
+
exports_external.object({
|
|
43438
|
+
kind: exports_external.literal("bad_input"),
|
|
43439
|
+
field: exports_external.string().optional()
|
|
43440
|
+
})
|
|
43441
|
+
]);
|
|
43442
|
+
var ErrorEnvelopeSchema = exports_external.object({
|
|
43443
|
+
v: exports_external.literal(1),
|
|
43444
|
+
code: exports_external.string().regex(/^(E_[A-Z0-9_]+|VAULT-[A-Z-]+)$/),
|
|
43445
|
+
human: exports_external.string().min(1),
|
|
43446
|
+
why: exports_external.string().optional(),
|
|
43447
|
+
fix: ErrorFixSchema.optional(),
|
|
43448
|
+
docs: exports_external.string().url().optional(),
|
|
43449
|
+
request_id: exports_external.string().min(1)
|
|
43450
|
+
});
|
|
43310
43451
|
var ResponseEnvelope = {
|
|
43311
43452
|
v: exports_external.literal(1),
|
|
43312
43453
|
request_id: exports_external.string().min(1).max(128),
|
|
@@ -43316,7 +43457,8 @@ var ResponseEnvelope = {
|
|
|
43316
43457
|
audit_id: exports_external.string().min(1).optional(),
|
|
43317
43458
|
stdout_tail: exports_external.string().optional(),
|
|
43318
43459
|
stderr_tail: exports_external.string().optional(),
|
|
43319
|
-
error: exports_external.string().optional()
|
|
43460
|
+
error: exports_external.string().optional(),
|
|
43461
|
+
error_envelope: ErrorEnvelopeSchema.optional()
|
|
43320
43462
|
};
|
|
43321
43463
|
var ResponseSchema3 = exports_external.object(ResponseEnvelope);
|
|
43322
43464
|
function encodeRequest3(req) {
|
|
@@ -44076,6 +44218,10 @@ function validateInput(input) {
|
|
|
44076
44218
|
var DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
44077
44219
|
var MAX_TTL_MS = 30 * 60 * 1000;
|
|
44078
44220
|
var MIN_TTL_MS = 30 * 1000;
|
|
44221
|
+
var TELEGRAM_SENDMESSAGE_LIMIT = 4096;
|
|
44222
|
+
var RENDERED_BODY_CAP = 3900;
|
|
44223
|
+
var OVERSIZE_SENTINEL = `
|
|
44224
|
+
[\u2026 preview truncated; open in Drive for full context]`;
|
|
44079
44225
|
async function handleRequestDriveApproval(client3, msg, deps) {
|
|
44080
44226
|
const reply = (event) => {
|
|
44081
44227
|
try {
|
|
@@ -44151,20 +44297,36 @@ async function handleRequestDriveApproval(client3, msg, deps) {
|
|
|
44151
44297
|
});
|
|
44152
44298
|
return;
|
|
44153
44299
|
}
|
|
44300
|
+
let cardText = card.text;
|
|
44301
|
+
let truncatedForFit = false;
|
|
44302
|
+
if (cardText.length > RENDERED_BODY_CAP) {
|
|
44303
|
+
const fit = truncateRawToFit({
|
|
44304
|
+
raw: card.text,
|
|
44305
|
+
render: (slice) => slice,
|
|
44306
|
+
cap: RENDERED_BODY_CAP,
|
|
44307
|
+
sentinel: OVERSIZE_SENTINEL,
|
|
44308
|
+
hardLimit: TELEGRAM_SENDMESSAGE_LIMIT
|
|
44309
|
+
});
|
|
44310
|
+
cardText = fit.body;
|
|
44311
|
+
truncatedForFit = fit.truncated;
|
|
44312
|
+
}
|
|
44154
44313
|
const posted = await deps.postCard({
|
|
44155
44314
|
chatId: target.chatId,
|
|
44156
44315
|
...target.threadId !== undefined ? { threadId: target.threadId } : {},
|
|
44157
|
-
text:
|
|
44316
|
+
text: cardText,
|
|
44158
44317
|
replyMarkup: card.reply_markup
|
|
44159
44318
|
});
|
|
44160
44319
|
if (posted === null) {
|
|
44161
44320
|
reply({
|
|
44162
44321
|
correlationId: msg.correlationId,
|
|
44163
44322
|
ok: false,
|
|
44164
|
-
reason: "Telegram sendMessage failed"
|
|
44323
|
+
reason: truncatedForFit ? "Telegram sendMessage failed even after oversize-body truncation" : "Telegram sendMessage failed"
|
|
44165
44324
|
});
|
|
44166
44325
|
return;
|
|
44167
44326
|
}
|
|
44327
|
+
if (truncatedForFit) {
|
|
44328
|
+
deps.log?.(`drive_approval_posted oversize-truncated correlation=${msg.correlationId} original_len=${card.text.length} rendered_len=${cardText.length}`);
|
|
44329
|
+
}
|
|
44168
44330
|
deps.log?.(`drive_approval_posted ok correlation=${msg.correlationId} request_id=${registered.request_id} file=${fileId}`);
|
|
44169
44331
|
reply({
|
|
44170
44332
|
correlationId: msg.correlationId,
|
|
@@ -44188,10 +44350,10 @@ var REQUEST_ID_RE = /^[0-9a-f]{32}$/;
|
|
|
44188
44350
|
var PENDING_FILE_ID_SENTINEL = "pending-create";
|
|
44189
44351
|
function buildDiffPreviewCard(input) {
|
|
44190
44352
|
if (!REQUEST_ID_RE.test(input.suggestRequestId)) {
|
|
44191
|
-
throw new Error(`buildDiffPreviewCard: suggestRequestId must be
|
|
44353
|
+
throw new Error(`buildDiffPreviewCard: suggestRequestId must be 32 hex chars (got '${input.suggestRequestId}')`);
|
|
44192
44354
|
}
|
|
44193
44355
|
if (input.writeRequestId !== undefined && !REQUEST_ID_RE.test(input.writeRequestId)) {
|
|
44194
|
-
throw new Error(`buildDiffPreviewCard: writeRequestId must be
|
|
44356
|
+
throw new Error(`buildDiffPreviewCard: writeRequestId must be 32 hex chars (got '${input.writeRequestId}')`);
|
|
44195
44357
|
}
|
|
44196
44358
|
const preview = input.preview;
|
|
44197
44359
|
const bodyLines = [];
|
|
@@ -45787,6 +45949,11 @@ function readCleanShutdownMarker(path) {
|
|
|
45787
45949
|
return null;
|
|
45788
45950
|
}
|
|
45789
45951
|
}
|
|
45952
|
+
function clearCleanShutdownMarker(path) {
|
|
45953
|
+
try {
|
|
45954
|
+
unlinkSync8(path);
|
|
45955
|
+
} catch {}
|
|
45956
|
+
}
|
|
45790
45957
|
function shouldSuppressRecoveryBanner(marker, now, maxAgeMs = DEFAULT_MAX_AGE_MS) {
|
|
45791
45958
|
if (marker === null)
|
|
45792
45959
|
return false;
|
|
@@ -48504,10 +48671,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
48504
48671
|
}
|
|
48505
48672
|
|
|
48506
48673
|
// ../src/build-info.ts
|
|
48507
|
-
var VERSION = "0.13.
|
|
48508
|
-
var COMMIT_SHA = "
|
|
48509
|
-
var COMMIT_DATE = "2026-05-
|
|
48510
|
-
var LATEST_PR =
|
|
48674
|
+
var VERSION = "0.13.36";
|
|
48675
|
+
var COMMIT_SHA = "73e8bb05";
|
|
48676
|
+
var COMMIT_DATE = "2026-05-25T03:53:49Z";
|
|
48677
|
+
var LATEST_PR = 1785;
|
|
48511
48678
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
48512
48679
|
|
|
48513
48680
|
// gateway/boot-version.ts
|
|
@@ -48555,9 +48722,17 @@ function composeBootVersionString(inputs) {
|
|
|
48555
48722
|
var import_grammy6 = __toESM(require_mod2(), 1);
|
|
48556
48723
|
function classifyRejection(err, opts = {}) {
|
|
48557
48724
|
const isGrammy = opts.isGrammyError != null ? opts.isGrammyError(err) : err instanceof import_grammy6.GrammyError;
|
|
48725
|
+
const isHttp = opts.isHttpError != null ? opts.isHttpError(err) : err instanceof import_grammy6.HttpError;
|
|
48726
|
+
if (isHttp)
|
|
48727
|
+
return "log_only";
|
|
48558
48728
|
if (!isGrammy)
|
|
48559
48729
|
return "shutdown";
|
|
48560
48730
|
const e = err;
|
|
48731
|
+
if (e.error_code === 429)
|
|
48732
|
+
return "log_only";
|
|
48733
|
+
if (typeof e.error_code === "number" && e.error_code >= 500 && e.error_code < 600) {
|
|
48734
|
+
return "log_only";
|
|
48735
|
+
}
|
|
48561
48736
|
if (e.error_code !== 400)
|
|
48562
48737
|
return "shutdown";
|
|
48563
48738
|
const desc = (e.description ?? "").toLowerCase();
|
|
@@ -50405,7 +50580,11 @@ startTimer2({
|
|
|
50405
50580
|
...ctx.threadId != null ? { threadId: ctx.threadId } : {}
|
|
50406
50581
|
});
|
|
50407
50582
|
},
|
|
50408
|
-
emitMetric: (event) => emitRuntimeMetric(event)
|
|
50583
|
+
emitMetric: (event) => emitRuntimeMetric(event),
|
|
50584
|
+
isActiveTurnNewerThan: (key, activatedAt) => {
|
|
50585
|
+
const turnStartedAt = activeTurnStartedAt.get(key);
|
|
50586
|
+
return turnStartedAt != null && turnStartedAt > activatedAt;
|
|
50587
|
+
}
|
|
50409
50588
|
});
|
|
50410
50589
|
var inboundSpool = STATIC ? undefined : createInboundSpool({
|
|
50411
50590
|
path: join32(STATE_DIR, "inbound-spool.jsonl"),
|
|
@@ -50807,6 +50986,16 @@ ${reminder}
|
|
|
50807
50986
|
`);
|
|
50808
50987
|
}
|
|
50809
50988
|
},
|
|
50989
|
+
postAttachment: async (args) => {
|
|
50990
|
+
const input = new import_grammy9.InputFile(Buffer.from(args.content, "utf8"), args.filename);
|
|
50991
|
+
await robustApiCall(() => bot.api.sendDocument(args.chatId, input, {
|
|
50992
|
+
...args.threadId !== undefined ? { message_thread_id: args.threadId } : {}
|
|
50993
|
+
}), {
|
|
50994
|
+
chat_id: String(args.chatId),
|
|
50995
|
+
verb: "config-approval-attachment",
|
|
50996
|
+
...args.threadId !== undefined ? { threadId: args.threadId } : {}
|
|
50997
|
+
});
|
|
50998
|
+
},
|
|
50810
50999
|
log: (m) => process.stderr.write(`telegram gateway: config-approval \u2014 ${m}
|
|
50811
51000
|
`)
|
|
50812
51001
|
});
|
|
@@ -51122,7 +51311,9 @@ ${url}`;
|
|
|
51122
51311
|
});
|
|
51123
51312
|
noteOutbound(statusKey(chat_id, threadId), Date.now());
|
|
51124
51313
|
noteOutbound2(statusKey(chat_id, threadId), Date.now());
|
|
51125
|
-
|
|
51314
|
+
if (isFinalAnswerReply({ text: rawText, disableNotification })) {
|
|
51315
|
+
clearSilentEndState(statusKey(chat_id, threadId));
|
|
51316
|
+
}
|
|
51126
51317
|
if (previewMessageId != null && reply_to != null && replyMode !== "off") {
|
|
51127
51318
|
await deleteStalePreview(previewMessageId);
|
|
51128
51319
|
previewMessageId = null;
|
|
@@ -51160,6 +51351,7 @@ ${url}`;
|
|
|
51160
51351
|
logOutbound("edit", chat_id, decision.messageId, decision.mergedText.length, "silent-anchor-merge");
|
|
51161
51352
|
process.stderr.write(`telegram gateway: silent-reply auto-edit \u2014 ` + `chat=${chat_id} anchor=${decision.messageId} merged_len=${decision.mergedText.length}
|
|
51162
51353
|
`);
|
|
51354
|
+
clearPending(statusKey(chat_id, threadId), "reply_finalize");
|
|
51163
51355
|
noteOutbound3(statusKey(chat_id, threadId), {
|
|
51164
51356
|
messageId: decision.messageId,
|
|
51165
51357
|
text: decision.mergedText,
|
|
@@ -51300,6 +51492,7 @@ ${url}`;
|
|
|
51300
51492
|
if (sentIds.length === chunks.length && chunks.length > 0) {
|
|
51301
51493
|
const anchorMsgId = sentIds[chunks.length - 1];
|
|
51302
51494
|
if (typeof anchorMsgId === "number") {
|
|
51495
|
+
clearPending(statusKey(chat_id, threadId), "reply_finalize");
|
|
51303
51496
|
noteOutbound3(statusKey(chat_id, threadId), {
|
|
51304
51497
|
messageId: anchorMsgId,
|
|
51305
51498
|
text: chunks[chunks.length - 1],
|
|
@@ -51445,7 +51638,13 @@ async function executeStreamReply(args) {
|
|
|
51445
51638
|
const sKey = statusKey(streamChatId, streamThreadId);
|
|
51446
51639
|
noteOutbound(sKey, Date.now());
|
|
51447
51640
|
noteOutbound2(sKey, Date.now());
|
|
51448
|
-
|
|
51641
|
+
if (isFinalAnswerReply({
|
|
51642
|
+
text: args.text ?? "",
|
|
51643
|
+
disableNotification: args.disable_notification === true,
|
|
51644
|
+
done: args.done === true
|
|
51645
|
+
})) {
|
|
51646
|
+
clearSilentEndState(sKey);
|
|
51647
|
+
}
|
|
51449
51648
|
}
|
|
51450
51649
|
}
|
|
51451
51650
|
const result = await handleStreamReply({
|
|
@@ -51509,6 +51708,7 @@ async function executeStreamReply(args) {
|
|
|
51509
51708
|
outboundDedup.record(sChatId, sThreadId, args.text, Date.now(), currentTurn?.registryKey ?? null);
|
|
51510
51709
|
const streamFormat = args.format ?? (access.parseMode ?? "html");
|
|
51511
51710
|
const streamParseMode = streamFormat === "html" ? "HTML" : streamFormat === "markdownv2" ? "MarkdownV2" : undefined;
|
|
51711
|
+
clearPending(statusKey(sChatId, sThreadId), "reply_finalize");
|
|
51512
51712
|
noteOutbound3(statusKey(sChatId, sThreadId), {
|
|
51513
51713
|
messageId: result.messageId,
|
|
51514
51714
|
text: args.text,
|
|
@@ -51521,6 +51721,8 @@ async function executeStreamReply(args) {
|
|
|
51521
51721
|
done: args.done === true
|
|
51522
51722
|
})) {
|
|
51523
51723
|
turn.finalAnswerDelivered = true;
|
|
51724
|
+
const streamThreadIdForClear = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
|
|
51725
|
+
clearSilentEndState(statusKey(streamChatId, streamThreadIdForClear));
|
|
51524
51726
|
}
|
|
51525
51727
|
{
|
|
51526
51728
|
const sChat = args.chat_id;
|
|
@@ -57917,6 +58119,7 @@ var didOneTimeSetup = false;
|
|
|
57917
58119
|
process.stderr.write(`telegram gateway: boot.clean_shutdown_marker_stale age=${ageSec}s signal=${cleanMarker.signal}${reasonTag}
|
|
57918
58120
|
`);
|
|
57919
58121
|
}
|
|
58122
|
+
clearCleanShutdownMarker(GATEWAY_CLEAN_SHUTDOWN_MARKER_PATH);
|
|
57920
58123
|
}
|
|
57921
58124
|
if (marker) {
|
|
57922
58125
|
const ageMs = nowMs2 - marker.ts;
|
|
@@ -247,3 +247,43 @@ For posterity:
|
|
|
247
247
|
- **v2 rewrite** (this PR series, also numbered #553): same Phase 3
|
|
248
248
|
harness, plus three v2 helpers; new spec test file pins the
|
|
249
249
|
three-class contract.
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
## Silent-end contract (#1122 / #1161 / #1664 / #1741 / #1744)
|
|
253
|
+
|
|
254
|
+
The "silent-end" safety net catches turns that end without the agent delivering a final answer via `reply` / `stream_reply`. The hook re-prompts once; on the second consecutive silent end it gives up and sends a fallback message so the turn never just vanishes.
|
|
255
|
+
|
|
256
|
+
### What it is
|
|
257
|
+
|
|
258
|
+
A per-agent state file (`$TELEGRAM_STATE_DIR/silent-end-pending.json`, fallback `~/.claude/channels/telegram/silent-end-pending.json`) acts as a one-bit handshake between the gateway and the Stop hook (`telegram-plugin/hooks/silent-end-interrupt-stop.mjs`). When the file exists, the Stop hook blocks the session stop and injects a re-prompt; when absent, the stop is allowed.
|
|
259
|
+
|
|
260
|
+
### When the file is WRITTEN
|
|
261
|
+
|
|
262
|
+
The gateway writes the file at `turn_end` if and only if `turn.finalAnswerDelivered === false` — i.e. no `reply` or `stream_reply` call during this turn passed the `isFinalAnswerReply` predicate. See `gateway.ts` around L7267 (`recordUndeliveredTurnEnd`). The write is idempotent across re-prompt rounds: the same turnKey inherits the prior `retryCount`; a different turnKey resets to 0.
|
|
263
|
+
|
|
264
|
+
### When the file is CLEARED
|
|
265
|
+
|
|
266
|
+
The gateway calls `clearSilentEndState(turnKey)` on every reply that qualifies as the final answer. Three call sites:
|
|
267
|
+
|
|
268
|
+
1. `executeReply` (gateway.ts L4599-4611) — fires on every `reply` tool call. Clears iff `isFinalAnswerReply` returns true.
|
|
269
|
+
2. `executeStreamReply` first-emit branch (gateway.ts L5172-5195) — fires only on the FIRST emit per stream (gated by `!activeDraftStreams.has(sKey)`). Clears iff that first emit qualifies as final.
|
|
270
|
+
3. `executeStreamReply` final-answer site (gateway.ts L5335-5358, added in #1744 follow-up) — fires on every emit that qualifies as final, regardless of whether it is the first emit. This is the load-bearing site for streams whose first emit was ack-shaped but whose later emit (typically `done=true`) carries the real answer. Without site 3, such streams leak the state file.
|
|
271
|
+
|
|
272
|
+
The clear is fail-silent and turnKey-keyed: a clear for turnKey A does NOT unlink a state file written for turnKey B (see `silent-end.ts clearSilentEndState`). This makes calling it unconditionally on every final-answer emit safe.
|
|
273
|
+
|
|
274
|
+
### The gate predicate
|
|
275
|
+
|
|
276
|
+
A reply qualifies as the final answer when `isFinalAnswerReply` returns true. The predicate (in `final-answer-detect.ts`) is a logical OR of three signals:
|
|
277
|
+
|
|
278
|
+
- `done === true` (stream_reply terminal call), OR
|
|
279
|
+
- `disableNotification === false` (pacing-contract final-answer signal), OR
|
|
280
|
+
- `text.length >= 200` (length backstop for substantive replies mis-marked as interim).
|
|
281
|
+
|
|
282
|
+
A reply with `disable_notification: true`, short text, and no `done` is an interim ack — it never clears the state.
|
|
283
|
+
|
|
284
|
+
### Send sites and tests
|
|
285
|
+
|
|
286
|
+
- `executeReply` (gateway.ts:4340) — single send site, single clear (site 1 above).
|
|
287
|
+
- `executeStreamReply` (gateway.ts:5089) — two clear sites (sites 2 and 3 above) to cover both the first-emit and the later-emit final-answer paths.
|
|
288
|
+
- Unit coverage: `tests/silent-end.test.ts` (`#1741` block) — the executeReply gate as a unit.
|
|
289
|
+
- Integration coverage: `tests/silent-end-integration.test.ts` (#1744) — the stream first-emit-vs-later-emit branching, with the ack-then-final regression case pinned.
|