switchroom 0.13.5 → 0.13.8
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 +5 -0
- package/dist/auth-broker/index.js +5 -0
- package/dist/cli/switchroom.js +144 -64
- package/dist/host-control/main.js +402 -27
- package/dist/vault/approvals/kernel-server.js +6 -1
- package/dist/vault/broker/server.js +6 -1
- package/package.json +1 -1
- package/profiles/_shared/telegram-style.md.hbs +12 -11
- package/profiles/default/CLAUDE.md +12 -11
- package/telegram-plugin/dist/bridge/bridge.js +24 -0
- package/telegram-plugin/dist/gateway/gateway.js +49 -7
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/gateway/gateway.ts +46 -1
- package/telegram-plugin/model-unavailable.ts +4 -0
- package/telegram-plugin/session-tail.ts +53 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +9 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +43 -0
|
@@ -4,19 +4,20 @@ Telegram is a chat — replies should feel like one, not a terminal dump or a tr
|
|
|
4
4
|
|
|
5
5
|
**Every turn that responds to a user message MUST end with a `reply` (or `stream_reply` with `done=true`).** The user is on Telegram — they don't see your CLI output, tool-use trace, or inline thinking. The ONLY path for words to reach them is an MCP tool call. If you have a final answer, send it via `reply`. The text in your terminal is not the conversation.
|
|
6
6
|
|
|
7
|
-
**Conversational pacing.**
|
|
8
|
-
- **
|
|
9
|
-
- **
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
|
|
7
|
+
**Conversational pacing — a human is on the other side.** Match the rhythm of a capable colleague messaging you back. Five beats:
|
|
8
|
+
- **1 · Acknowledge first.** Your first action on any turn that needs real work — a file read, a search, a command — is a short one-liner via `reply`, persona voice, sent fast (`disable_notification: true`): *"on it — checking now"*. Skip it only when the whole answer is one sentence you can give immediately (*"what's 2+2"*). This is a beat, not filler — it's the line between a colleague and a black box.
|
|
9
|
+
- **2 · Then go quiet and work.** Heads-down is right — do **not** narrate every tool call. A typing indicator runs for you automatically; you don't keep it alive.
|
|
10
|
+
- **3 · Surface meaningful progress** at genuine inflection points — a hard step finished, a blocker, a pivot, dispatching a sub-agent, a notably slow wait, a finding worth knowing now. One short `reply`, `disable_notification: true` (no mid-turn ping).
|
|
11
|
+
- **4 · Hand back delegations with synthesis.** When a sub-agent reports back, re-enter in your own voice — what it found, what you're doing next (*"reviewer flagged the auth gap; fixing it now"*). Never let its raw report stand as your reply.
|
|
12
|
+
- **5 · Deliver the answer** as a fresh `reply` (omit `disable_notification` — pings once).
|
|
13
|
+
|
|
14
|
+
The one thing to avoid is **spam**: a reply on every tool call, on a cadence, or repeating yourself. Responsive and human, never a flood. A `<system-reminder>` containing `[silence-poke]` means you've gone quiet too long — send one short `reply` and carry on; skip it only if you're within ~5s of finishing.
|
|
14
15
|
|
|
15
16
|
**`stream_reply` vs `reply`.**
|
|
16
|
-
- **`reply`** is the default. Use for
|
|
17
|
+
- **`reply`** is the default. Use for acks, mid-turn updates, sub-agent handbacks, final answers. Pass `disable_notification: true` mid-turn.
|
|
17
18
|
- **`stream_reply`** is for content whose final answer benefits from streaming character-by-character (long prose, code blocks). First call sends fresh; subsequent calls edit (no ping until `done=true`). Don't use it just to "show progress" — that's what `reply` is for.
|
|
18
19
|
|
|
19
|
-
The status
|
|
20
|
+
The 👀→🤔→🔥→👍 status reaction and the typing indicator are *ambient* liveness — they tell the user the agent is alive and working, automatically. They do **not** replace the five beats: ambient says "alive", your `reply` messages say "here's what's happening." Different layers; both run.
|
|
20
21
|
|
|
21
22
|
**Reactions ON your replies.** Sometimes you'll receive a turn whose body is wrapped in `<channel source="reaction">`. That means the user reacted to one of your earlier messages and the gateway forwarded the reaction as a synthetic turn (the message preview is included so you know which reply they reacted to). 👎 / ❌ are stop signals — pause, reconsider the approach, ask what's off. 👍 / ✅ are acknowledgements — keep going if mid-task, no extra reply needed. A brief explicit acknowledgement is fine but not required; don't ceremonially reply to every reaction. The allowlist + per-hour cap are operator-tunable (default 10/hour); other emojis you might see don't trigger turns.
|
|
22
23
|
|
|
@@ -59,7 +60,7 @@ Don't use `accent` on routine conversational replies — it's for status communi
|
|
|
59
60
|
|
|
60
61
|
**When stickers / GIFs land well**: confirming a real milestone the user celebrated (✅ workout logged, 🎉 deal closed); softening genuinely awkward news; mirroring back a sticker or GIF the user just sent — once, not as a habit. Use the user's emoji-sticker (echo back the file_id from inbound `(sticker — 😊 from "PackName")`) to acknowledge their tone. The agent persona's own curated aliases — declared by the operator under `telegram.stickers` in switchroom.yaml — are the standard alphabet (`happy`, `thinking`, `done`, etc.); call `send_sticker(chat_id, sticker='happy')`. Errors list available aliases when an unknown one is asked for.
|
|
61
62
|
|
|
62
|
-
**When stickers / GIFs land badly**: in lieu of an actual answer, decorating routine acknowledgements ("got it 👍 [+sticker]"), peppering a long thread, or any time the user is task-focused. If you find yourself wanting to send one to lighten an otherwise empty reply,
|
|
63
|
+
**When stickers / GIFs land badly**: in lieu of an actual answer, decorating routine acknowledgements ("got it 👍 [+sticker]"), peppering a long thread, or any time the user is task-focused. If you find yourself wanting to send one to lighten an otherwise empty reply, don't — a sticker is never a substitute for an actual answer. Two stickers in a row is always wrong.
|
|
63
64
|
|
|
64
65
|
**Interrupt marker.** If a user asks how to stop you mid-turn, tell them: *"Start your message with `!` — it interrupts whatever I'm doing and treats the rest as a fresh request."* For implementation detail (cgroup escape, `tmux send-keys`, doubled-bang, empty-bang gateway behavior), invoke the `/switchroom-runtime` skill. The `!` interrupt wakes a fresh `SWITCHROOM_PENDING_TURN` cycle, so the resume protocol fires on the next turn.
|
|
65
66
|
|
|
@@ -67,4 +68,4 @@ Don't use `accent` on routine conversational replies — it's for status communi
|
|
|
67
68
|
|
|
68
69
|
**"Why did you restart?"** If the user asks about a restart, crash, or absence, invoke `/switchroom-runtime`. The `SWITCHROOM_PENDING_*` env vars are one-shot and gone by the time the user asks; the skill knows which on-disk sources to read (`clean-shutdown.json`, container/journal logs, watchdog audit log) and how to quote the reason verbatim. Never answer from memory.
|
|
69
70
|
|
|
70
|
-
**"status?" / "still there?" / "any update?" is a UX-failure signal**, not a feature request. The
|
|
71
|
+
**"status?" / "still there?" / "any update?" is a UX-failure signal**, not a feature request. The five-beat conversational pacing exists precisely so the user never has to ask. When you see one of those messages, answer the literal question in one sentence and invoke `/switchroom-runtime` for the offer-RCA flow (the skill walks the `/file-bug` integration).
|
|
@@ -41,19 +41,20 @@ Telegram is a chat — replies should feel like one, not a terminal dump or a tr
|
|
|
41
41
|
|
|
42
42
|
**Every turn that responds to a user message MUST end with a `reply` (or `stream_reply` with `done=true`).** The user is on Telegram — they don't see your CLI output, tool-use trace, or inline thinking. The ONLY path for words to reach them is an MCP tool call. If you have a final answer, send it via `reply`. The text in your terminal is not the conversation.
|
|
43
43
|
|
|
44
|
-
**Conversational pacing.**
|
|
45
|
-
- **
|
|
46
|
-
- **
|
|
47
|
-
- **
|
|
48
|
-
- **
|
|
49
|
-
- **
|
|
50
|
-
|
|
44
|
+
**Conversational pacing — a human is on the other side.** Match the rhythm of a capable colleague messaging you back. Five beats:
|
|
45
|
+
- **1 · Acknowledge first.** Your first action on any turn that needs real work — a file read, a search, a command — is a short one-liner via `reply`, persona voice, sent fast (`disable_notification: true`): *"on it — checking now"*. Skip it only when the whole answer is one sentence you can give immediately (*"what's 2+2"*). This is a beat, not filler — it's the line between a colleague and a black box.
|
|
46
|
+
- **2 · Then go quiet and work.** Heads-down is right — do **not** narrate every tool call. A typing indicator runs for you automatically; you don't keep it alive.
|
|
47
|
+
- **3 · Surface meaningful progress** at genuine inflection points — a hard step finished, a blocker, a pivot, dispatching a sub-agent, a notably slow wait, a finding worth knowing now. One short `reply`, `disable_notification: true` (no mid-turn ping).
|
|
48
|
+
- **4 · Hand back delegations with synthesis.** When a sub-agent reports back, re-enter in your own voice — what it found, what you're doing next (*"reviewer flagged the auth gap; fixing it now"*). Never let its raw report stand as your reply.
|
|
49
|
+
- **5 · Deliver the answer** as a fresh `reply` (omit `disable_notification` — pings once).
|
|
50
|
+
|
|
51
|
+
The one thing to avoid is **spam**: a reply on every tool call, on a cadence, or repeating yourself. Responsive and human, never a flood. A `<system-reminder>` containing `[silence-poke]` means you've gone quiet too long — send one short `reply` and carry on; skip it only if you're within ~5s of finishing.
|
|
51
52
|
|
|
52
53
|
**`stream_reply` vs `reply`.**
|
|
53
|
-
- **`reply`** is the default. Use for
|
|
54
|
+
- **`reply`** is the default. Use for acks, mid-turn updates, sub-agent handbacks, final answers. Pass `disable_notification: true` mid-turn.
|
|
54
55
|
- **`stream_reply`** is for content whose final answer benefits from streaming character-by-character (long prose, code blocks). First call sends fresh; subsequent calls edit (no ping until `done=true`). Don't use it just to "show progress" — that's what `reply` is for.
|
|
55
56
|
|
|
56
|
-
The status
|
|
57
|
+
The 👀→🤔→🔥→👍 status reaction and the typing indicator are *ambient* liveness — they tell the user the agent is alive and working, automatically. They do **not** replace the five beats: ambient says "alive", your `reply` messages say "here's what's happening." Different layers; both run.
|
|
57
58
|
|
|
58
59
|
**Reactions ON your replies.** Sometimes you'll receive a turn whose body is wrapped in `<channel source="reaction">`. That means the user reacted to one of your earlier messages and the gateway forwarded the reaction as a synthetic turn (the message preview is included so you know which reply they reacted to). 👎 / ❌ are stop signals — pause, reconsider the approach, ask what's off. 👍 / ✅ are acknowledgements — keep going if mid-task, no extra reply needed. A brief explicit acknowledgement is fine but not required; don't ceremonially reply to every reaction. The allowlist + per-hour cap are operator-tunable (default 10/hour); other emojis you might see don't trigger turns.
|
|
59
60
|
|
|
@@ -96,7 +97,7 @@ Don't use `accent` on routine conversational replies — it's for status communi
|
|
|
96
97
|
|
|
97
98
|
**When stickers / GIFs land well**: confirming a real milestone the user celebrated (✅ workout logged, 🎉 deal closed); softening genuinely awkward news; mirroring back a sticker or GIF the user just sent — once, not as a habit. Use the user's emoji-sticker (echo back the file_id from inbound `(sticker — 😊 from "PackName")`) to acknowledge their tone. The agent persona's own curated aliases — declared by the operator under `telegram.stickers` in switchroom.yaml — are the standard alphabet (`happy`, `thinking`, `done`, etc.); call `send_sticker(chat_id, sticker='happy')`. Errors list available aliases when an unknown one is asked for.
|
|
98
99
|
|
|
99
|
-
**When stickers / GIFs land badly**: in lieu of an actual answer, decorating routine acknowledgements ("got it 👍 [+sticker]"), peppering a long thread, or any time the user is task-focused. If you find yourself wanting to send one to lighten an otherwise empty reply,
|
|
100
|
+
**When stickers / GIFs land badly**: in lieu of an actual answer, decorating routine acknowledgements ("got it 👍 [+sticker]"), peppering a long thread, or any time the user is task-focused. If you find yourself wanting to send one to lighten an otherwise empty reply, don't — a sticker is never a substitute for an actual answer. Two stickers in a row is always wrong.
|
|
100
101
|
|
|
101
102
|
**Interrupt marker.** If a user asks how to stop you mid-turn, tell them: *"Start your message with `!` — it interrupts whatever I'm doing and treats the rest as a fresh request."* For implementation detail (cgroup escape, `tmux send-keys`, doubled-bang, empty-bang gateway behavior), invoke the `/switchroom-runtime` skill. The `!` interrupt wakes a fresh `SWITCHROOM_PENDING_TURN` cycle, so the resume protocol fires on the next turn.
|
|
102
103
|
|
|
@@ -104,7 +105,7 @@ Don't use `accent` on routine conversational replies — it's for status communi
|
|
|
104
105
|
|
|
105
106
|
**"Why did you restart?"** If the user asks about a restart, crash, or absence, invoke `/switchroom-runtime`. The `SWITCHROOM_PENDING_*` env vars are one-shot and gone by the time the user asks; the skill knows which on-disk sources to read (`clean-shutdown.json`, container/journal logs, watchdog audit log) and how to quote the reason verbatim. Never answer from memory.
|
|
106
107
|
|
|
107
|
-
**"status?" / "still there?" / "any update?" is a UX-failure signal**, not a feature request. The
|
|
108
|
+
**"status?" / "still there?" / "any update?" is a UX-failure signal**, not a feature request. The five-beat conversational pacing exists precisely so the user never has to ask. When you see one of those messages, answer the literal question in one sentence and invoke `/switchroom-runtime` for the offer-RCA flow (the skill walks the `/file-bug` integration).
|
|
108
109
|
|
|
109
110
|
## Memory — Hindsight is your single backend
|
|
110
111
|
|
|
@@ -23361,6 +23361,13 @@ function detectErrorInTranscriptLine(line) {
|
|
|
23361
23361
|
if (typeof obj !== "object" || obj == null)
|
|
23362
23362
|
return null;
|
|
23363
23363
|
const type = obj.type;
|
|
23364
|
+
if (obj.isApiErrorMessage === true) {
|
|
23365
|
+
const status = typeof obj.apiErrorStatus === "number" ? obj.apiErrorStatus : null;
|
|
23366
|
+
const errStr = typeof obj.error === "string" ? obj.error : "";
|
|
23367
|
+
const text = extractAssistantText(obj);
|
|
23368
|
+
const kind2 = status === 429 ? "quota-exhausted" : classifyClaudeError({ type: errStr, status, message: text });
|
|
23369
|
+
return { kind: kind2, raw: obj, detail: text || errStr || "api error" };
|
|
23370
|
+
}
|
|
23364
23371
|
const isErrorLine = type === "api_error" || type === "error";
|
|
23365
23372
|
const embeddedError = typeof obj.error === "object" && obj.error != null ? obj.error : null;
|
|
23366
23373
|
if (!isErrorLine && !embeddedError)
|
|
@@ -23376,6 +23383,23 @@ function extractDetailMessage(obj) {
|
|
|
23376
23383
|
const msg = obj.message;
|
|
23377
23384
|
return typeof msg === "string" && msg.length > 0 ? msg : null;
|
|
23378
23385
|
}
|
|
23386
|
+
function extractAssistantText(obj) {
|
|
23387
|
+
const message = obj.message;
|
|
23388
|
+
if (typeof message !== "object" || message == null)
|
|
23389
|
+
return "";
|
|
23390
|
+
const content = message.content;
|
|
23391
|
+
if (!Array.isArray(content))
|
|
23392
|
+
return "";
|
|
23393
|
+
const parts = [];
|
|
23394
|
+
for (const block of content) {
|
|
23395
|
+
if (typeof block === "object" && block != null && block.type === "text") {
|
|
23396
|
+
const t = block.text;
|
|
23397
|
+
if (typeof t === "string")
|
|
23398
|
+
parts.push(t);
|
|
23399
|
+
}
|
|
23400
|
+
}
|
|
23401
|
+
return parts.join(" ").trim();
|
|
23402
|
+
}
|
|
23379
23403
|
function startSessionTail(config2) {
|
|
23380
23404
|
const cwd = config2.cwd ?? process.cwd();
|
|
23381
23405
|
const claudeHome = config2.claudeHome ?? process.env.CLAUDE_CONFIG_DIR ?? join3(homedir2(), ".claude");
|
|
@@ -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, 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, HostControlConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
|
|
23596
23596
|
var init_schema = __esm(() => {
|
|
23597
23597
|
init_zod();
|
|
23598
23598
|
CodeRepoEntrySchema = exports_external.object({
|
|
@@ -23943,6 +23943,10 @@ var init_schema = __esm(() => {
|
|
|
23943
23943
|
HostControlConfigSchema = exports_external.object({
|
|
23944
23944
|
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).")
|
|
23945
23945
|
});
|
|
23946
|
+
HostdConfigSchema = exports_external.object({
|
|
23947
|
+
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."),
|
|
23948
|
+
config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit \u00a75). Default 3 cards/hour; min 1, " + "max 20. Implemented as a sqlite token bucket in PR 1c; the " + "field is wired here in PR 1a so operators can pin it before the " + "limiter is live. Above the cap, the verb returns " + "`E_RATE_LIMITED` without raising a card.")
|
|
23949
|
+
});
|
|
23946
23950
|
SwitchroomConfigSchema = exports_external.object({
|
|
23947
23951
|
switchroom: exports_external.object({
|
|
23948
23952
|
version: exports_external.literal(1).describe("Config schema version"),
|
|
@@ -23969,6 +23973,7 @@ var init_schema = __esm(() => {
|
|
|
23969
23973
|
google_workspace: GoogleWorkspaceConfigSchema.describe("RFC G canonical key. Top-level Google Workspace configuration \u2014 " + "OAuth client credentials, approver allowlist, and tier knob (`core` " + "| `extended` | `complete`, default `core`). Mutually exclusive with " + "`drive:` at the top level (loader fails fast if both are set)."),
|
|
23970
23974
|
quota: QuotaConfigSchema.optional().describe("Optional weekly/monthly USD spend budgets rendered in the session " + "greeting. Usage is read from ccusage at runtime; no network calls."),
|
|
23971
23975
|
host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (docs/rfcs/host-control-daemon.md). Omit the block " + "to accept defaults; set `enabled: false` only on legacy systemd-" + "mode installs (removal tracked as RFC C Phase 3)."),
|
|
23976
|
+
hostd: HostdConfigSchema.default({}).describe("hostd verb-level knobs (RFC admin-agent-config-edit). Distinct " + "from `host_control:` which governs whether the daemon runs at " + "all. Currently scopes the opt-in flag and rate cap for the new " + "`config_propose_edit` verb (PR 1a \u2014 disabled by default)."),
|
|
23972
23977
|
google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
|
|
23973
23978
|
message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
|
|
23974
23979
|
}).transform((v) => v.trim().toLowerCase()), exports_external.object({
|
|
@@ -39342,7 +39347,9 @@ function detectModelUnavailable(stderr) {
|
|
|
39342
39347
|
"quota exhausted",
|
|
39343
39348
|
"quota_exhausted",
|
|
39344
39349
|
"plan limit",
|
|
39345
|
-
"subscription limit"
|
|
39350
|
+
"subscription limit",
|
|
39351
|
+
"hit your limit",
|
|
39352
|
+
"hit the limit"
|
|
39346
39353
|
];
|
|
39347
39354
|
if (quotaSignals.some((s) => lower.includes(s))) {
|
|
39348
39355
|
const resetAt = parseResetTime(sample);
|
|
@@ -42801,6 +42808,15 @@ var AgentSmokeRequestSchema = exports_external.object({
|
|
|
42801
42808
|
deep: exports_external.boolean().optional()
|
|
42802
42809
|
})
|
|
42803
42810
|
});
|
|
42811
|
+
var ConfigProposeEditRequestSchema = exports_external.object({
|
|
42812
|
+
...RequestEnvelope,
|
|
42813
|
+
op: exports_external.literal("config_propose_edit"),
|
|
42814
|
+
args: exports_external.object({
|
|
42815
|
+
unified_diff: exports_external.string().min(1).max(MAX_FRAME_BYTES3 - 1024),
|
|
42816
|
+
reason: exports_external.string().min(1).max(500),
|
|
42817
|
+
target_path: exports_external.literal("/state/config/switchroom.yaml")
|
|
42818
|
+
})
|
|
42819
|
+
});
|
|
42804
42820
|
var RequestSchema3 = exports_external.discriminatedUnion("op", [
|
|
42805
42821
|
AgentRestartRequestSchema,
|
|
42806
42822
|
UpgradeStatusRequestSchema,
|
|
@@ -42813,7 +42829,8 @@ var RequestSchema3 = exports_external.discriminatedUnion("op", [
|
|
|
42813
42829
|
AgentLogsRequestSchema,
|
|
42814
42830
|
AgentExecRequestSchema,
|
|
42815
42831
|
DoctorRequestSchema,
|
|
42816
|
-
AgentSmokeRequestSchema
|
|
42832
|
+
AgentSmokeRequestSchema,
|
|
42833
|
+
ConfigProposeEditRequestSchema
|
|
42817
42834
|
]);
|
|
42818
42835
|
var ResultSchema = exports_external.enum(["started", "completed", "denied", "error"]);
|
|
42819
42836
|
var ResponseEnvelope = {
|
|
@@ -44023,6 +44040,10 @@ function chatKey(chatId, threadId) {
|
|
|
44023
44040
|
function chatKeyWithSuffix(chatId, threadId, suffix) {
|
|
44024
44041
|
return `${chatKey(chatId, threadId)}:${suffix}`;
|
|
44025
44042
|
}
|
|
44043
|
+
function chatIdOfChatKey(key) {
|
|
44044
|
+
const idx = key.indexOf(":");
|
|
44045
|
+
return idx === -1 ? key : key.slice(0, idx);
|
|
44046
|
+
}
|
|
44026
44047
|
|
|
44027
44048
|
// gateway/inbound-delivery-machine.ts
|
|
44028
44049
|
function initialState() {
|
|
@@ -47720,11 +47741,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
47720
47741
|
}
|
|
47721
47742
|
|
|
47722
47743
|
// ../src/build-info.ts
|
|
47723
|
-
var VERSION = "0.13.
|
|
47724
|
-
var COMMIT_SHA = "
|
|
47725
|
-
var COMMIT_DATE = "2026-05-
|
|
47744
|
+
var VERSION = "0.13.8";
|
|
47745
|
+
var COMMIT_SHA = "bb713414";
|
|
47746
|
+
var COMMIT_DATE = "2026-05-22T10:15:33+10:00";
|
|
47726
47747
|
var LATEST_PR = null;
|
|
47727
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
47748
|
+
var COMMITS_AHEAD_OF_TAG = 4;
|
|
47728
47749
|
|
|
47729
47750
|
// gateway/boot-version.ts
|
|
47730
47751
|
function formatRelativeAgo(iso) {
|
|
@@ -48681,6 +48702,7 @@ function purgeReactionTracking(key, endingTurn) {
|
|
|
48681
48702
|
activeStatusReactions.delete(key);
|
|
48682
48703
|
activeReactionMsgIds.delete(key);
|
|
48683
48704
|
activeTurnStartedAt.delete(key);
|
|
48705
|
+
stopTurnTypingLoop(chatIdOfChatKey(key));
|
|
48684
48706
|
if (msgInfo) {
|
|
48685
48707
|
const agentDir = resolveAgentDirFromEnv();
|
|
48686
48708
|
if (agentDir != null)
|
|
@@ -48982,6 +49004,22 @@ function stopTypingLoop(chat_id) {
|
|
|
48982
49004
|
typingRetryTimers.delete(chat_id);
|
|
48983
49005
|
}
|
|
48984
49006
|
}
|
|
49007
|
+
var turnTypingIntervals = new Map;
|
|
49008
|
+
function startTurnTypingLoop(chat_id) {
|
|
49009
|
+
stopTurnTypingLoop(chat_id);
|
|
49010
|
+
const send = () => {
|
|
49011
|
+
bot.api.sendChatAction(chat_id, "typing").catch(() => {});
|
|
49012
|
+
};
|
|
49013
|
+
send();
|
|
49014
|
+
turnTypingIntervals.set(chat_id, setInterval(send, 4000));
|
|
49015
|
+
}
|
|
49016
|
+
function stopTurnTypingLoop(chat_id) {
|
|
49017
|
+
const iv = turnTypingIntervals.get(chat_id);
|
|
49018
|
+
if (iv) {
|
|
49019
|
+
clearInterval(iv);
|
|
49020
|
+
turnTypingIntervals.delete(chat_id);
|
|
49021
|
+
}
|
|
49022
|
+
}
|
|
48985
49023
|
var typingWrapper = createTypingWrapper({
|
|
48986
49024
|
startTypingLoop,
|
|
48987
49025
|
stopTypingLoop,
|
|
@@ -52367,6 +52405,7 @@ ${preBlock(write.output)}`;
|
|
|
52367
52405
|
logStreamingEvent({ kind: "inbound_ack", chatId: chat_id, messageId: msgId, ackDelayMs: Date.now() - inboundReceivedAt });
|
|
52368
52406
|
reset(statusKey(chat_id, messageThreadId), Date.now());
|
|
52369
52407
|
startTurn(statusKey(chat_id, messageThreadId), Date.now());
|
|
52408
|
+
startTurnTypingLoop(chat_id);
|
|
52370
52409
|
emitRuntimeMetric({
|
|
52371
52410
|
kind: "turn_started",
|
|
52372
52411
|
chat_id,
|
|
@@ -56527,6 +56566,9 @@ async function shutdown(signal) {
|
|
|
56527
56566
|
for (const iv of [...typingIntervals.values()])
|
|
56528
56567
|
clearInterval(iv);
|
|
56529
56568
|
typingIntervals.clear();
|
|
56569
|
+
for (const iv of [...turnTypingIntervals.values()])
|
|
56570
|
+
clearInterval(iv);
|
|
56571
|
+
turnTypingIntervals.clear();
|
|
56530
56572
|
for (const t of [...typingRetryTimers.values()])
|
|
56531
56573
|
clearTimeout(t);
|
|
56532
56574
|
typingRetryTimers.clear();
|
|
@@ -17399,6 +17399,13 @@ function detectErrorInTranscriptLine(line) {
|
|
|
17399
17399
|
if (typeof obj !== "object" || obj == null)
|
|
17400
17400
|
return null;
|
|
17401
17401
|
const type = obj.type;
|
|
17402
|
+
if (obj.isApiErrorMessage === true) {
|
|
17403
|
+
const status = typeof obj.apiErrorStatus === "number" ? obj.apiErrorStatus : null;
|
|
17404
|
+
const errStr = typeof obj.error === "string" ? obj.error : "";
|
|
17405
|
+
const text = extractAssistantText(obj);
|
|
17406
|
+
const kind2 = status === 429 ? "quota-exhausted" : classifyClaudeError({ type: errStr, status, message: text });
|
|
17407
|
+
return { kind: kind2, raw: obj, detail: text || errStr || "api error" };
|
|
17408
|
+
}
|
|
17402
17409
|
const isErrorLine = type === "api_error" || type === "error";
|
|
17403
17410
|
const embeddedError = typeof obj.error === "object" && obj.error != null ? obj.error : null;
|
|
17404
17411
|
if (!isErrorLine && !embeddedError)
|
|
@@ -17414,6 +17421,23 @@ function extractDetailMessage(obj) {
|
|
|
17414
17421
|
const msg = obj.message;
|
|
17415
17422
|
return typeof msg === "string" && msg.length > 0 ? msg : null;
|
|
17416
17423
|
}
|
|
17424
|
+
function extractAssistantText(obj) {
|
|
17425
|
+
const message = obj.message;
|
|
17426
|
+
if (typeof message !== "object" || message == null)
|
|
17427
|
+
return "";
|
|
17428
|
+
const content = message.content;
|
|
17429
|
+
if (!Array.isArray(content))
|
|
17430
|
+
return "";
|
|
17431
|
+
const parts = [];
|
|
17432
|
+
for (const block of content) {
|
|
17433
|
+
if (typeof block === "object" && block != null && block.type === "text") {
|
|
17434
|
+
const t = block.text;
|
|
17435
|
+
if (typeof t === "string")
|
|
17436
|
+
parts.push(t);
|
|
17437
|
+
}
|
|
17438
|
+
}
|
|
17439
|
+
return parts.join(" ").trim();
|
|
17440
|
+
}
|
|
17417
17441
|
function startSessionTail(config2) {
|
|
17418
17442
|
const cwd = config2.cwd ?? process.cwd();
|
|
17419
17443
|
const claudeHome = config2.claudeHome ?? process.env.CLAUDE_CONFIG_DIR ?? join4(homedir3(), ".claude");
|
|
@@ -264,7 +264,7 @@ import { createInboundSpool } from './inbound-spool.js'
|
|
|
264
264
|
import { purgeStaleTurnsForChat } from './turn-state-purge.js'
|
|
265
265
|
import { decideInboundDelivery } from './inbound-delivery-gate.js'
|
|
266
266
|
import { createPendingPermissionBuffer } from './pending-permission-decisions.js'
|
|
267
|
-
import { chatKey, chatKeyWithSuffix } from './chat-key.js'
|
|
267
|
+
import { chatKey, chatKeyWithSuffix, chatIdOfChatKey } from './chat-key.js'
|
|
268
268
|
// Phase 2b PR 2 — shadow mode. Each event-site below calls shadowEmit()
|
|
269
269
|
// to record what the InboundDeliveryStateMachine PREDICTS the gateway
|
|
270
270
|
// should do. Behavior unchanged in this PR — the imperative code below
|
|
@@ -1310,6 +1310,13 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
|
|
|
1310
1310
|
activeStatusReactions.delete(key)
|
|
1311
1311
|
activeReactionMsgIds.delete(key)
|
|
1312
1312
|
activeTurnStartedAt.delete(key)
|
|
1313
|
+
// Human-feel UX: stop the turn-long `typing…` indicator started in
|
|
1314
|
+
// the turn-start block. `purgeReactionTracking` is the canonical
|
|
1315
|
+
// turn-end, so this is the single owner of the stop. (If an abnormal
|
|
1316
|
+
// abort skips purge, the stray loop self-heals: the next turn on this
|
|
1317
|
+
// chat calls `startTurnTypingLoop`, which stops the old interval
|
|
1318
|
+
// first.)
|
|
1319
|
+
stopTurnTypingLoop(chatIdOfChatKey(key as _ChatKey))
|
|
1313
1320
|
if (msgInfo) {
|
|
1314
1321
|
const agentDir = resolveAgentDirFromEnv()
|
|
1315
1322
|
if (agentDir != null) removeActiveReaction(agentDir, msgInfo.chatId, msgInfo.messageId)
|
|
@@ -1781,6 +1788,32 @@ function stopTypingLoop(chat_id: string): void {
|
|
|
1781
1788
|
if (retry) { clearTimeout(retry); typingRetryTimers.delete(chat_id) }
|
|
1782
1789
|
}
|
|
1783
1790
|
|
|
1791
|
+
// Turn-level `typing…` indicator. Deliberately a SEPARATE interval map
|
|
1792
|
+
// from `typingIntervals` (which the reply handler and the tool-use
|
|
1793
|
+
// typing wrapper share and freely stop). If the turn loop lived in the
|
|
1794
|
+
// shared map, a mid-turn reply's `finally { stopTypingLoop }` would
|
|
1795
|
+
// kill it and the chat would go dark for the rest of the turn — the
|
|
1796
|
+
// exact black-box gap this is here to close. A dedicated map makes the
|
|
1797
|
+
// turn loop structurally immune to those stops: only `stopTurnTypingLoop`
|
|
1798
|
+
// (called at the canonical turn-end) clears it. The redundant `typing`
|
|
1799
|
+
// pings while a reply is mid-flight are harmless — same action, and
|
|
1800
|
+
// sendChatAction is cheap.
|
|
1801
|
+
const turnTypingIntervals = new Map<string, ReturnType<typeof setInterval>>()
|
|
1802
|
+
|
|
1803
|
+
function startTurnTypingLoop(chat_id: string): void {
|
|
1804
|
+
stopTurnTypingLoop(chat_id)
|
|
1805
|
+
const send = () => {
|
|
1806
|
+
void bot.api.sendChatAction(chat_id, 'typing').catch(() => {})
|
|
1807
|
+
}
|
|
1808
|
+
send()
|
|
1809
|
+
turnTypingIntervals.set(chat_id, setInterval(send, 4000))
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
function stopTurnTypingLoop(chat_id: string): void {
|
|
1813
|
+
const iv = turnTypingIntervals.get(chat_id)
|
|
1814
|
+
if (iv) { clearInterval(iv); turnTypingIntervals.delete(chat_id) }
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1784
1817
|
const typingWrapper = createTypingWrapper({
|
|
1785
1818
|
startTypingLoop,
|
|
1786
1819
|
stopTypingLoop,
|
|
@@ -7563,6 +7596,16 @@ async function handleInbound(
|
|
|
7563
7596
|
// the framework can nudge the model if it goes quiet past the
|
|
7564
7597
|
// soft / firm thresholds.
|
|
7565
7598
|
silencePoke.startTurn(statusKey(chat_id, messageThreadId), Date.now())
|
|
7599
|
+
// Human-feel UX: hold a continuous `typing…` indicator for the
|
|
7600
|
+
// WHOLE turn, not just the split-second a reply is transmitted.
|
|
7601
|
+
// A person you message shows as typing the entire time they
|
|
7602
|
+
// compose; switchroom used to fire only one-shot ~5s pings, so
|
|
7603
|
+
// any turn that read a file or thought for a moment went dark
|
|
7604
|
+
// after 5s. Self-renews every 4s; stopped at the canonical
|
|
7605
|
+
// turn-end (`purgeReactionTracking → stopTurnTypingLoop`).
|
|
7606
|
+
// Deterministic, framework-owned, no prose — the mechanical
|
|
7607
|
+
// ambient layer of the pacing contract.
|
|
7608
|
+
startTurnTypingLoop(chat_id)
|
|
7566
7609
|
// #1122 KPI: emit turn_started so dashboards can compute funnel
|
|
7567
7610
|
// start counts + correlate to turn_ended for duration / TTFO.
|
|
7568
7611
|
emitRuntimeMetric({
|
|
@@ -14111,6 +14154,8 @@ async function shutdown(signal: string): Promise<void> {
|
|
|
14111
14154
|
|
|
14112
14155
|
for (const iv of [...typingIntervals.values()]) clearInterval(iv)
|
|
14113
14156
|
typingIntervals.clear()
|
|
14157
|
+
for (const iv of [...turnTypingIntervals.values()]) clearInterval(iv)
|
|
14158
|
+
turnTypingIntervals.clear()
|
|
14114
14159
|
for (const t of [...typingRetryTimers.values()]) clearTimeout(t)
|
|
14115
14160
|
typingRetryTimers.clear()
|
|
14116
14161
|
|
|
@@ -80,6 +80,10 @@ export function detectModelUnavailable(
|
|
|
80
80
|
'quota_exhausted',
|
|
81
81
|
'plan limit',
|
|
82
82
|
'subscription limit',
|
|
83
|
+
// Claude Code v2.1.x usage-limit wording: "You've hit your limit ·
|
|
84
|
+
// resets 8:50am (Australia/Melbourne)".
|
|
85
|
+
'hit your limit',
|
|
86
|
+
'hit the limit',
|
|
83
87
|
]
|
|
84
88
|
if (quotaSignals.some(s => lower.includes(s))) {
|
|
85
89
|
const resetAt = parseResetTime(sample)
|
|
@@ -423,6 +423,33 @@ export function detectErrorInTranscriptLine(
|
|
|
423
423
|
|
|
424
424
|
const type = obj.type as string | undefined
|
|
425
425
|
|
|
426
|
+
// Claude Code (v2.1.x) records a usage-limit / API error as a
|
|
427
|
+
// SYNTHETIC ASSISTANT MESSAGE, not an api_error / error line:
|
|
428
|
+
// { type: "assistant",
|
|
429
|
+
// message: { role: "assistant",
|
|
430
|
+
// content: [{ type: "text", text: "You've hit your limit · resets …" }] },
|
|
431
|
+
// error: "rate_limit", isApiErrorMessage: true, apiErrorStatus: 429 }
|
|
432
|
+
// It has no `api_error`/`error` top-type and no nested error OBJECT
|
|
433
|
+
// (`error` is a bare string), so the structured checks below miss it
|
|
434
|
+
// entirely. That silent miss is what kept fleet auto-fallback from
|
|
435
|
+
// ever firing on a quota hit — the exhaustion signal never reached
|
|
436
|
+
// the operator-event path. Detect this shape explicitly.
|
|
437
|
+
if (obj.isApiErrorMessage === true) {
|
|
438
|
+
const status =
|
|
439
|
+
typeof obj.apiErrorStatus === 'number' ? obj.apiErrorStatus : null
|
|
440
|
+
const errStr = typeof obj.error === 'string' ? obj.error : ''
|
|
441
|
+
const text = extractAssistantText(obj)
|
|
442
|
+
// A 429 in this shape is a subscription usage-limit hit (it carries
|
|
443
|
+
// a reset time) — classify it quota-exhausted so the operator event
|
|
444
|
+
// resolves to an auto-fallback-eligible kind. Other statuses fall
|
|
445
|
+
// through to the shared classifier.
|
|
446
|
+
const kind: OperatorEventKind =
|
|
447
|
+
status === 429
|
|
448
|
+
? 'quota-exhausted'
|
|
449
|
+
: classifyClaudeError({ type: errStr, status, message: text })
|
|
450
|
+
return { kind, raw: obj, detail: text || errStr || 'api error' }
|
|
451
|
+
}
|
|
452
|
+
|
|
426
453
|
// Explicit error line types from Claude Code JSONL
|
|
427
454
|
const isErrorLine = type === 'api_error' || type === 'error'
|
|
428
455
|
|
|
@@ -454,6 +481,32 @@ function extractDetailMessage(obj: Record<string, unknown> | null): string | nul
|
|
|
454
481
|
return typeof msg === 'string' && msg.length > 0 ? msg : null
|
|
455
482
|
}
|
|
456
483
|
|
|
484
|
+
/**
|
|
485
|
+
* Pull the human-readable text out of a synthetic assistant message
|
|
486
|
+
* (`message.content[].text`, joined). Used for the v2.1.x
|
|
487
|
+
* `isApiErrorMessage` shape, where the user-facing error string lives
|
|
488
|
+
* inside the assistant message rather than in an `error` object.
|
|
489
|
+
* Returns '' for any non-conforming shape — never throws.
|
|
490
|
+
*/
|
|
491
|
+
function extractAssistantText(obj: Record<string, unknown>): string {
|
|
492
|
+
const message = obj.message
|
|
493
|
+
if (typeof message !== 'object' || message == null) return ''
|
|
494
|
+
const content = (message as Record<string, unknown>).content
|
|
495
|
+
if (!Array.isArray(content)) return ''
|
|
496
|
+
const parts: string[] = []
|
|
497
|
+
for (const block of content) {
|
|
498
|
+
if (
|
|
499
|
+
typeof block === 'object'
|
|
500
|
+
&& block != null
|
|
501
|
+
&& (block as Record<string, unknown>).type === 'text'
|
|
502
|
+
) {
|
|
503
|
+
const t = (block as Record<string, unknown>).text
|
|
504
|
+
if (typeof t === 'string') parts.push(t)
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return parts.join(' ').trim()
|
|
508
|
+
}
|
|
509
|
+
|
|
457
510
|
// ─── The tail watcher ─────────────────────────────────────────────────────
|
|
458
511
|
|
|
459
512
|
/** Emitted to onOperatorEvent when the tail detects a Claude API error. */
|
|
@@ -42,6 +42,15 @@ describe('detectModelUnavailable — quota / billing strings', () => {
|
|
|
42
42
|
it('classifies "quota exhausted" verbatim', () => {
|
|
43
43
|
expect(detectModelUnavailable('quota exhausted on slot main')?.kind).toBe('quota_exhausted')
|
|
44
44
|
})
|
|
45
|
+
|
|
46
|
+
it("classifies Claude Code v2.1.x 'You've hit your limit' wording", () => {
|
|
47
|
+
// The exact text claude writes inside the synthetic
|
|
48
|
+
// isApiErrorMessage assistant message on a subscription quota hit.
|
|
49
|
+
const d = detectModelUnavailable(
|
|
50
|
+
"You've hit your limit · resets 8:50am (Australia/Melbourne)",
|
|
51
|
+
)
|
|
52
|
+
expect(d?.kind).toBe('quota_exhausted')
|
|
53
|
+
})
|
|
45
54
|
})
|
|
46
55
|
|
|
47
56
|
describe('detectModelUnavailable — overload / 429 / 5xx strings', () => {
|
|
@@ -70,6 +70,49 @@ describe('detectErrorInTranscriptLine — error detection', () => {
|
|
|
70
70
|
expect(detectErrorInTranscriptLine(line)).toBeNull()
|
|
71
71
|
})
|
|
72
72
|
|
|
73
|
+
// Regression — the fleet-auto-failover dead-zone. Claude Code v2.1.x
|
|
74
|
+
// records a usage-limit hit as a synthetic assistant message with
|
|
75
|
+
// isApiErrorMessage:true (no api_error type, no nested error OBJECT).
|
|
76
|
+
// Pre-fix, detectErrorInTranscriptLine missed it entirely → the
|
|
77
|
+
// operator-event path never ran → fleet auto-fallback never fired.
|
|
78
|
+
it('detects the v2.1.x synthetic-assistant-message usage-limit shape', () => {
|
|
79
|
+
// The exact on-disk line shape, verbatim from a real quota hit.
|
|
80
|
+
const line = JSON.stringify({
|
|
81
|
+
type: 'assistant',
|
|
82
|
+
message: {
|
|
83
|
+
role: 'assistant',
|
|
84
|
+
model: '<synthetic>',
|
|
85
|
+
content: [
|
|
86
|
+
{
|
|
87
|
+
type: 'text',
|
|
88
|
+
text: "You've hit your limit · resets 8:50am (Australia/Melbourne)",
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
error: 'rate_limit',
|
|
93
|
+
isApiErrorMessage: true,
|
|
94
|
+
apiErrorStatus: 429,
|
|
95
|
+
})
|
|
96
|
+
const result = detectErrorInTranscriptLine(line)
|
|
97
|
+
expect(result).not.toBeNull()
|
|
98
|
+
// A 429 in this shape is a subscription usage-limit hit → must
|
|
99
|
+
// classify quota-exhausted so the operator event resolves to an
|
|
100
|
+
// auto-fallback-eligible kind.
|
|
101
|
+
expect(result!.kind).toBe('quota-exhausted')
|
|
102
|
+
// The user-facing text must survive into `detail` (the model-
|
|
103
|
+
// unavailable card + the text-pattern path both rely on it).
|
|
104
|
+
expect(result!.detail).toContain('hit your limit')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('still returns null for a normal (non-error) assistant message', () => {
|
|
108
|
+
// No isApiErrorMessage flag → must NOT be treated as an error.
|
|
109
|
+
const line = JSON.stringify({
|
|
110
|
+
type: 'assistant',
|
|
111
|
+
message: { role: 'assistant', content: [{ type: 'text', text: 'Done.' }] },
|
|
112
|
+
})
|
|
113
|
+
expect(detectErrorInTranscriptLine(line)).toBeNull()
|
|
114
|
+
})
|
|
115
|
+
|
|
73
116
|
it('returns null for lines with null error field', () => {
|
|
74
117
|
const line = JSON.stringify({ type: 'assistant', error: null })
|
|
75
118
|
expect(detectErrorInTranscriptLine(line)).toBeNull()
|