switchroom 0.5.0 โ†’ 0.7.9

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.
Files changed (89) hide show
  1. package/README.md +142 -121
  2. package/bin/autoaccept.exp +29 -6
  3. package/dist/agent-scheduler/index.js +12261 -0
  4. package/dist/cli/autoaccept-poll.js +10 -0
  5. package/dist/cli/switchroom.js +27250 -25324
  6. package/dist/vault/approvals/kernel-server.js +12709 -0
  7. package/dist/vault/broker/server.js +15724 -0
  8. package/package.json +4 -3
  9. package/profiles/_base/start.sh.hbs +133 -0
  10. package/profiles/_shared/telegram-style.md.hbs +3 -3
  11. package/profiles/default/CLAUDE.md +3 -3
  12. package/profiles/default/CLAUDE.md.hbs +2 -2
  13. package/profiles/default/workspace/CLAUDE.md.hbs +9 -0
  14. package/skills/docx/VENDORED.md +1 -1
  15. package/skills/mcp-builder/VENDORED.md +1 -1
  16. package/skills/pdf/VENDORED.md +1 -1
  17. package/skills/pptx/VENDORED.md +1 -1
  18. package/skills/skill-creator/VENDORED.md +1 -1
  19. package/skills/switchroom-architecture/SKILL.md +8 -7
  20. package/skills/switchroom-cli/SKILL.md +23 -15
  21. package/skills/switchroom-health/SKILL.md +7 -7
  22. package/skills/switchroom-install/SKILL.md +36 -39
  23. package/skills/switchroom-manage/SKILL.md +4 -4
  24. package/skills/switchroom-status/SKILL.md +1 -1
  25. package/skills/webapp-testing/VENDORED.md +1 -1
  26. package/skills/xlsx/VENDORED.md +1 -1
  27. package/telegram-plugin/admin-commands/dispatch.test.ts +119 -1
  28. package/telegram-plugin/admin-commands/index.ts +71 -0
  29. package/telegram-plugin/ask-user.ts +1 -0
  30. package/telegram-plugin/card-event-log.ts +138 -0
  31. package/telegram-plugin/dist/bridge/bridge.js +178 -31
  32. package/telegram-plugin/dist/foreman/foreman.js +6875 -6526
  33. package/telegram-plugin/dist/gateway/gateway.js +13862 -11834
  34. package/telegram-plugin/dist/server.js +202 -40
  35. package/telegram-plugin/fleet-state.ts +25 -10
  36. package/telegram-plugin/foreman/foreman.ts +38 -3
  37. package/telegram-plugin/gateway/approval-callback.ts +126 -0
  38. package/telegram-plugin/gateway/approval-card.test.ts +90 -0
  39. package/telegram-plugin/gateway/approval-card.ts +127 -0
  40. package/telegram-plugin/gateway/approvals-commands.ts +126 -0
  41. package/telegram-plugin/gateway/boot-card.ts +31 -6
  42. package/telegram-plugin/gateway/boot-probes.ts +510 -72
  43. package/telegram-plugin/gateway/gateway.ts +822 -94
  44. package/telegram-plugin/gateway/ipc-protocol.ts +34 -1
  45. package/telegram-plugin/gateway/ipc-server.ts +35 -0
  46. package/telegram-plugin/gateway/startup-mutex.ts +110 -2
  47. package/telegram-plugin/hooks/hooks.json +19 -0
  48. package/telegram-plugin/hooks/tool-label-pretool.mjs +216 -0
  49. package/telegram-plugin/hooks/tool-label-stop.mjs +63 -0
  50. package/telegram-plugin/package.json +4 -1
  51. package/telegram-plugin/plugin-logger.ts +20 -1
  52. package/telegram-plugin/progress-card-driver.ts +202 -13
  53. package/telegram-plugin/progress-card.ts +2 -2
  54. package/telegram-plugin/quota-check.ts +1 -0
  55. package/telegram-plugin/registry/subagents-schema.ts +37 -0
  56. package/telegram-plugin/registry/subagents.test.ts +64 -0
  57. package/telegram-plugin/session-tail.ts +58 -5
  58. package/telegram-plugin/shared/bot-runtime.ts +48 -2
  59. package/telegram-plugin/subagent-watcher.ts +139 -7
  60. package/telegram-plugin/tests/_progress-card-harness.ts +4 -0
  61. package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +201 -0
  62. package/telegram-plugin/tests/boot-card-probe-target.test.ts +10 -34
  63. package/telegram-plugin/tests/boot-card-render.test.ts +6 -5
  64. package/telegram-plugin/tests/boot-probes.test.ts +564 -0
  65. package/telegram-plugin/tests/card-event-log.test.ts +145 -0
  66. package/telegram-plugin/tests/gateway-startup-mutex.test.ts +102 -0
  67. package/telegram-plugin/tests/ipc-server-validate-inject-inbound.test.ts +134 -0
  68. package/telegram-plugin/tests/progress-card-delay-842.test.ts +160 -0
  69. package/telegram-plugin/tests/quota-check.test.ts +37 -1
  70. package/telegram-plugin/tests/subagent-registry-bugs.test.ts +5 -0
  71. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +104 -1
  72. package/telegram-plugin/tests/subagent-watcher.test.ts +5 -0
  73. package/telegram-plugin/tests/tool-label-sidecar.test.ts +114 -0
  74. package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +5 -3
  75. package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +10 -0
  76. package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +58 -14
  77. package/telegram-plugin/tests/welcome-text.test.ts +57 -0
  78. package/telegram-plugin/tool-label-sidecar.ts +140 -0
  79. package/telegram-plugin/tool-labels.ts +55 -0
  80. package/telegram-plugin/two-zone-card.ts +27 -7
  81. package/telegram-plugin/uat/SETUP.md +160 -0
  82. package/telegram-plugin/uat/assertions.ts +140 -0
  83. package/telegram-plugin/uat/driver.ts +174 -0
  84. package/telegram-plugin/uat/harness.ts +161 -0
  85. package/telegram-plugin/uat/login.ts +134 -0
  86. package/telegram-plugin/uat/port-allocator.ts +71 -0
  87. package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +61 -0
  88. package/telegram-plugin/welcome-text.ts +44 -2
  89. package/bin/bridge-watchdog.sh +0 -967
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Scenario harness for UAT runs.
3
+ *
4
+ * Issue: https://github.com/switchroom/switchroom/issues/866
5
+ *
6
+ * `spinUp({ agent, topic })` is the single entry point a scenario
7
+ * uses. Phase 1 ships the type shape + the lifecycle skeleton; the
8
+ * actual `child_process.spawn` of the agent under test is stubbed
9
+ * with TODO markers so the reviewer can see exactly where Phase 2
10
+ * lands.
11
+ */
12
+
13
+ import { Driver } from "./driver.js";
14
+ import {
15
+ expectMessage,
16
+ expectPinnedCard,
17
+ expectReaction,
18
+ waitForCardPhase,
19
+ type PollOptions,
20
+ type PinnedCardSnapshot,
21
+ } from "./assertions.js";
22
+ import { allocatePort } from "./port-allocator.js";
23
+
24
+ export interface SpinUpOptions {
25
+ /** Agent name to install + run, e.g. `"clerk"`, `"test-harness"`. */
26
+ agent: string;
27
+ /**
28
+ * Forum topic slug for isolation. The harness creates the topic in
29
+ * the test supergroup and tears it down after the scenario.
30
+ */
31
+ topic: string;
32
+ }
33
+
34
+ export interface Scenario {
35
+ /** mtcute driver, already connected. */
36
+ driver: Driver;
37
+ /** Negative supergroup chat id; from `$SWITCHROOM_UAT_CHAT_ID`. */
38
+ chatId: number;
39
+ /** Topic id created for this scenario. */
40
+ threadId: number;
41
+
42
+ // Sugar over the assertion helpers, pre-bound to this scenario's
43
+ // chat + thread. Phase 1 returns a thin pass-through.
44
+ expectMessage: (
45
+ match: Parameters<typeof expectMessage>[2],
46
+ opts: PollOptions & { from?: "bot" | "user" },
47
+ ) => ReturnType<typeof expectMessage>;
48
+ expectReaction: (
49
+ messageId: number,
50
+ sequence: string[],
51
+ opts: PollOptions,
52
+ ) => ReturnType<typeof expectReaction>;
53
+ expectPinnedCard: (opts: PollOptions) => ReturnType<typeof expectPinnedCard>;
54
+ waitForCardPhase: (
55
+ card: PinnedCardSnapshot,
56
+ phase: "boot" | "working" | "done" | "error",
57
+ opts: PollOptions,
58
+ ) => ReturnType<typeof waitForCardPhase>;
59
+
60
+ /** Stop the agent process, delete the topic, disconnect the driver. */
61
+ tearDown: () => Promise<void>;
62
+ }
63
+
64
+ /**
65
+ * Spin up an isolated agent + scenario context.
66
+ *
67
+ * Phase 1: returns a stub Scenario whose tools throw helpful "not
68
+ * implemented" errors. The shape is correct so scenarios written
69
+ * against it will typecheck today and run for real once Phase 2
70
+ * lands.
71
+ */
72
+ export async function spinUp(opts: SpinUpOptions): Promise<Scenario> {
73
+ // TODO(#866): resolve secrets from vault.
74
+ // - `telegram-test-bot-token` for the agent under test
75
+ // - `telegram-uat-driver-session` for the mtcute driver
76
+ // - `TELEGRAM_API_ID` / `TELEGRAM_API_HASH` from env
77
+ // For now we throw early with a clear message so accidental runs
78
+ // before Phase 2 don't crash with confusing stack traces.
79
+ if (!process.env.SWITCHROOM_UAT_CHAT_ID) {
80
+ throw new Error(
81
+ "[uat/harness] SWITCHROOM_UAT_CHAT_ID is not set โ€” see uat/SETUP.md ยง2",
82
+ );
83
+ }
84
+ const chatId = Number.parseInt(process.env.SWITCHROOM_UAT_CHAT_ID, 10);
85
+ if (!Number.isFinite(chatId) || chatId >= 0) {
86
+ throw new Error(
87
+ `[uat/harness] SWITCHROOM_UAT_CHAT_ID must be a negative supergroup id (got ${process.env.SWITCHROOM_UAT_CHAT_ID})`,
88
+ );
89
+ }
90
+
91
+ // TODO(#866): allocate gateway port + ephemeral STATE_DIR.
92
+ // const port = await allocatePort();
93
+ // const stateDir = await mkdtemp(join(tmpdir(), `uat-${opts.agent}-`));
94
+ // process.env.STATE_DIR is per-process โ€” we instead pass STATE_DIR
95
+ // in the spawned child's env, never mutate ours.
96
+ const port = await allocatePort();
97
+ void port; // Phase 2: feed into agent child env
98
+
99
+ // TODO(#866): create the forum topic via Bot API
100
+ // (`createForumTopic`) using the test bot token; capture the
101
+ // returned `message_thread_id` and stash for tearDown's
102
+ // `deleteForumTopic`.
103
+ const threadId = -1; // sentinel; Phase 2 fills in
104
+
105
+ // TODO(#866): spawn the agent under test as a child process.
106
+ // const child = spawn(process.execPath, [agentEntry], {
107
+ // env: {
108
+ // ...process.env,
109
+ // STATE_DIR: stateDir,
110
+ // TELEGRAM_GATEWAY_PORT: String(port),
111
+ // SWITCHROOM_AGENT_NAME: opts.agent,
112
+ // BOT_TOKEN: <vault: telegram-test-bot-token>,
113
+ // },
114
+ // stdio: ["ignore", "pipe", "pipe"],
115
+ // });
116
+ // await waitForGatewayReady(port, { timeout: 30_000 });
117
+
118
+ // TODO(#866): connect mtcute driver.
119
+ // const driver = new Driver({ apiId, apiHash, session });
120
+ // await driver.connect();
121
+ const driver = new Driver({
122
+ apiId: 0,
123
+ apiHash: "",
124
+ session: "<resolved-from-vault>",
125
+ });
126
+
127
+ const scenario: Scenario = {
128
+ driver,
129
+ chatId,
130
+ threadId,
131
+ expectMessage: (match, pollOpts) =>
132
+ expectMessage(driver, chatId, match, {
133
+ ...pollOpts,
134
+ threadId,
135
+ }),
136
+ expectReaction: (messageId, sequence, pollOpts) =>
137
+ expectReaction(driver, chatId, messageId, sequence, pollOpts),
138
+ expectPinnedCard: (pollOpts) =>
139
+ expectPinnedCard(driver, chatId, { ...pollOpts, threadId }),
140
+ waitForCardPhase: (card, phase, pollOpts) =>
141
+ waitForCardPhase(driver, card, phase, pollOpts),
142
+ tearDown: async () => {
143
+ // TODO(#866): SIGTERM child, await exit (or SIGKILL after 5s),
144
+ // delete forum topic, rm -rf state dir, disconnect driver.
145
+ await driver.disconnect().catch(() => {
146
+ /* idempotent */
147
+ });
148
+ },
149
+ };
150
+
151
+ // Phase 1 marker so accidental runs fail loudly instead of silently
152
+ // sending nothing.
153
+ void opts;
154
+ throw new Error(
155
+ "[uat/harness] spinUp is scaffolded but not wired (Phase 1 stub) โ€” see TODO markers in uat/harness.ts",
156
+ );
157
+
158
+ // unreachable in Phase 1; left for shape:
159
+ // eslint-disable-next-line no-unreachable
160
+ return scenario;
161
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * One-time interactive login script for the UAT mtcute driver.
3
+ *
4
+ * Issue: https://github.com/switchroom/switchroom/issues/865
5
+ *
6
+ * Run via `bun run uat:login` from telegram-plugin/. Prompts for
7
+ * phone, login code, and (optionally) 2FA password on stdin. Captures
8
+ * the session string in memory and writes it to vault under
9
+ * `telegram-uat-driver-session`. The session string is **never
10
+ * printed** โ€” not to stdout, not to stderr, not to logs. If you see
11
+ * one in scrollback, file an incident.
12
+ *
13
+ * Required env:
14
+ * TELEGRAM_API_ID โ€” from https://my.telegram.org/apps
15
+ * TELEGRAM_API_HASH โ€” from https://my.telegram.org/apps
16
+ */
17
+
18
+ import { spawn } from "node:child_process";
19
+ import { createInterface } from "node:readline/promises";
20
+ import { stdin as input, stdout as output } from "node:process";
21
+ import { TelegramClient } from "@mtcute/node";
22
+
23
+ const VAULT_KEY = "telegram-uat-driver-session";
24
+
25
+ async function main(): Promise<void> {
26
+ const apiId = Number.parseInt(process.env.TELEGRAM_API_ID ?? "", 10);
27
+ const apiHash = process.env.TELEGRAM_API_HASH ?? "";
28
+ if (!Number.isFinite(apiId) || !apiHash) {
29
+ fail(
30
+ "TELEGRAM_API_ID and TELEGRAM_API_HASH must be set. See uat/SETUP.md ยง3.",
31
+ );
32
+ }
33
+
34
+ const rl = createInterface({ input, output, terminal: true });
35
+
36
+ // Confirm the operator understands the security posture before we
37
+ // mint a bearer-equivalent credential.
38
+ const ack = await rl.question(
39
+ [
40
+ "",
41
+ "About to mint a Telegram USER session string for the UAT driver.",
42
+ "This is bearer-equivalent to the user account. It will be stored",
43
+ "in vault under `" + VAULT_KEY + "` and NEVER printed.",
44
+ "",
45
+ "Type YES to proceed: ",
46
+ ].join("\n"),
47
+ );
48
+ if (ack.trim() !== "YES") {
49
+ fail("Aborted.");
50
+ }
51
+
52
+ const phone = (await rl.question("Phone number (E.164, e.g. +14155551234): ")).trim();
53
+ if (!phone.startsWith("+")) fail("Phone must start with '+'.");
54
+
55
+ const client = new TelegramClient({ apiId, apiHash });
56
+
57
+ // mtcute exposes a `start()` flow that takes async callbacks for
58
+ // each interactive step. Exact callback names may shift across
59
+ // versions โ€” verify against the pinned mtcute version before first
60
+ // run.
61
+ await client.start({
62
+ phone: async () => phone,
63
+ code: async () =>
64
+ (await rl.question("Login code (from Telegram app or SMS): ")).trim(),
65
+ password: async () =>
66
+ (await rl.question("2FA password (leave blank if none): ")),
67
+ });
68
+
69
+ // TODO(#865): mtcute v0.27 exports sessions via the
70
+ // `@mtcute/core/utils.js` `StringSessionStorage` adapter; the
71
+ // exact call is `await client.exportSession()` only when that
72
+ // storage is configured, otherwise sessions live in the SQLite
73
+ // file at `client.session`. Phase 2 wires the string-session
74
+ // storage so this script can mint a string. For now we throw
75
+ // before producing a value so the operator never gets a half-
76
+ // baked session in vault.
77
+ const session: string = await Promise.reject(
78
+ new Error(
79
+ "uat:login: Phase 1 stub โ€” Phase 2 wires StringSessionStorage. See uat/SETUP.md ยง3.",
80
+ ),
81
+ );
82
+
83
+ await client.destroy();
84
+ rl.close();
85
+
86
+ // Write to vault via the switchroom CLI's stdin path so the
87
+ // session never appears in argv (which would land in `ps` output).
88
+ await writeToVault(VAULT_KEY, session);
89
+
90
+ // Belt-and-suspenders: zero out the local copy.
91
+ scrub(session);
92
+
93
+ process.stdout.write(
94
+ `\nDone. Session stored in vault as \`${VAULT_KEY}\`.\n` +
95
+ "If you ever see the actual session string in your terminal, file an incident.\n",
96
+ );
97
+ }
98
+
99
+ function writeToVault(key: string, value: string): Promise<void> {
100
+ return new Promise((resolve, reject) => {
101
+ const proc = spawn("switchroom", ["vault", "set", key], {
102
+ stdio: ["pipe", "inherit", "inherit"],
103
+ });
104
+ proc.on("error", reject);
105
+ proc.on("exit", (code) => {
106
+ if (code === 0) resolve();
107
+ else reject(new Error(`switchroom vault set exited ${code}`));
108
+ });
109
+ proc.stdin.end(value + "\n");
110
+ });
111
+ }
112
+
113
+ function scrub(_s: string): void {
114
+ // JS strings are immutable โ€” best we can do is drop the reference
115
+ // and trust the GC. Documented here so a future hardening pass
116
+ // (e.g. SecureBuffer) has a hook.
117
+ }
118
+
119
+ function fail(msg: string): never {
120
+ process.stderr.write(`uat:login: ${msg}\n`);
121
+ process.exit(1);
122
+ }
123
+
124
+ main().catch((err) => {
125
+ // Defensive: if mtcute throws, the error MAY contain the session
126
+ // string in some adapters. Strip anything that looks like a base64
127
+ // blob > 64 chars before printing.
128
+ const sanitized = String(err?.message ?? err).replace(
129
+ /[A-Za-z0-9+/=_-]{64,}/g,
130
+ "<redacted>",
131
+ );
132
+ process.stderr.write(`uat:login failed: ${sanitized}\n`);
133
+ process.exit(1);
134
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Process-wide unique port allocator for the UAT harness.
3
+ *
4
+ * Why this exists: each scenario `spinUp`s an agent child process
5
+ * with its own gateway listening on `TELEGRAM_GATEWAY_PORT`. If two
6
+ * sibling scenarios race to pick a port we get silent collisions
7
+ * that masquerade as flaky assertions ("the bridge couldn't reach
8
+ * the gateway, so no replies showed up"). See SETUP.md ยง6 for the
9
+ * port-vs-unix-socket decision.
10
+ *
11
+ * The allocator is monotonic per process and probes the candidate
12
+ * by `bind()`ing a transient socket โ€” catches the case where some
13
+ * other process on the host has stolen a port out of our range.
14
+ */
15
+
16
+ import { createServer } from "node:net";
17
+
18
+ const DEFAULT_BASE_PORT = 47000;
19
+ const DEFAULT_MAX_PORT = 47999;
20
+
21
+ let cursor = DEFAULT_BASE_PORT;
22
+
23
+ /**
24
+ * Allocate the next free TCP port in the harness range.
25
+ *
26
+ * Throws if every port in [base, max] is taken โ€” almost certainly
27
+ * means a previous run leaked agent processes. Recovery: `pkill -f
28
+ * test-harness-uat` and try again.
29
+ */
30
+ export async function allocatePort(opts?: {
31
+ base?: number;
32
+ max?: number;
33
+ }): Promise<number> {
34
+ const base = opts?.base ?? DEFAULT_BASE_PORT;
35
+ const max = opts?.max ?? DEFAULT_MAX_PORT;
36
+
37
+ if (cursor < base) cursor = base;
38
+
39
+ const start = cursor;
40
+ for (;;) {
41
+ const port = cursor;
42
+ cursor = cursor + 1 > max ? base : cursor + 1;
43
+
44
+ if (await isPortFree(port)) {
45
+ return port;
46
+ }
47
+
48
+ if (cursor === start) {
49
+ throw new Error(
50
+ `[uat/port-allocator] no free ports in [${base}, ${max}]; ` +
51
+ "likely leaked agent child processes โ€” try `pkill -f test-harness-uat`",
52
+ );
53
+ }
54
+ }
55
+ }
56
+
57
+ /** Reset the allocator. Test-only. */
58
+ export function _resetAllocatorForTests(): void {
59
+ cursor = DEFAULT_BASE_PORT;
60
+ }
61
+
62
+ function isPortFree(port: number): Promise<boolean> {
63
+ return new Promise((resolve) => {
64
+ const probe = createServer();
65
+ probe.once("error", () => resolve(false));
66
+ probe.once("listening", () => {
67
+ probe.close(() => resolve(true));
68
+ });
69
+ probe.listen(port, "127.0.0.1");
70
+ });
71
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Smoke scenario โ€” clerk replies to a simple text message.
3
+ *
4
+ * Part of: https://github.com/switchroom/switchroom/issues/866
5
+ *
6
+ * Phase 1 status: this file exercises the harness shape (typecheck-
7
+ * clean, reads as the canonical example) but will FAIL at runtime
8
+ * because `harness.spinUp` is stubbed. Phase 2 wires the real
9
+ * lifecycle and this becomes the first green UAT scenario.
10
+ *
11
+ * The shape mirrors the canonical scenario in epic #863 so reviewers
12
+ * can map directly between the design doc and the code.
13
+ */
14
+
15
+ import { describe, it, expect } from "vitest";
16
+ import { spinUp } from "../harness.js";
17
+
18
+ describe("uat: clerk smoke", () => {
19
+ it("replies to a text message with the right reaction sequence + HTML reply", async () => {
20
+ const sc = await spinUp({ agent: "clerk", topic: "smoke-clerk-reply" });
21
+
22
+ try {
23
+ const sent = await sc.driver.sendText(
24
+ sc.chatId,
25
+ "summarize this short note",
26
+ { messageThreadId: sc.threadId },
27
+ );
28
+
29
+ // Status reactions should walk ๐Ÿ‘€ โ†’ ๐Ÿค” โ†’ ๐Ÿ”ฅ โ†’ ๐Ÿ‘ on the inbound
30
+ // message within 30s on a healthy run.
31
+ await sc.expectReaction(
32
+ sent.messageId,
33
+ ["๐Ÿ‘€", "๐Ÿค”", "๐Ÿ”ฅ", "๐Ÿ‘"],
34
+ { timeout: 30_000 },
35
+ );
36
+
37
+ // The progress card should be pinned within a few seconds.
38
+ const card = await sc.expectPinnedCard({ timeout: 5_000 });
39
+
40
+ // And ride to `done` within the model's normal turn budget.
41
+ const finalCard = await sc.waitForCardPhase(card, "done", {
42
+ timeout: 60_000,
43
+ });
44
+ expect(finalCard.text).toMatch(/Done|โœ…/);
45
+
46
+ // The actual reply prose lands as a fresh bot message, in HTML.
47
+ const reply = await sc.expectMessage(/./, {
48
+ from: "bot",
49
+ timeout: 60_000,
50
+ });
51
+ // Reviewer note: parse_mode isn't on the wire-level update;
52
+ // we proxy via "did the rendered HTML contain a `<` we
53
+ // recognize, or did markdown leak as plain `*`?". Phase 2
54
+ // will pick the right shape โ€” left as a TODO so this file
55
+ // typechecks today.
56
+ expect(reply.text.length).toBeGreaterThan(0);
57
+ } finally {
58
+ await sc.tearDown();
59
+ }
60
+ });
61
+ });
@@ -48,6 +48,21 @@ export type AgentAudit = {
48
48
  memoryBank?: string;
49
49
  };
50
50
 
51
+ /**
52
+ * One live probe row for the `/status` health block. Mirrors the
53
+ * `ProbeResult` shape used by the boot card without dragging the
54
+ * boot-probes module into welcome-text โ€” keeps welcome-text dependency-
55
+ * free for unit tests.
56
+ */
57
+ export type StatusProbeRow = {
58
+ /** ProbeStatus shape from boot-probes: ok / degraded / fail. */
59
+ status: 'ok' | 'degraded' | 'fail';
60
+ /** Display label, e.g. "Broker", "Scheduler". */
61
+ label: string;
62
+ /** Free-text detail, e.g. "running (pid 23) ยท last fire 4m ago". */
63
+ detail: string;
64
+ };
65
+
51
66
  export type AgentMetadata = {
52
67
  agentName: string;
53
68
  model: string | null;
@@ -59,6 +74,13 @@ export type AgentMetadata = {
59
74
  auth: AuthSummary | null;
60
75
  /** Live audit details โ€” present only when switchroom.yaml loaded cleanly. */
61
76
  audit?: AgentAudit;
77
+ /**
78
+ * Live probe results gathered at request time. Same probe set as the
79
+ * boot card. Unlike the boot card (silent-when-healthy), `/status`
80
+ * shows EVERY row including the green ones โ€” the user explicitly
81
+ * asked for current state, so terseness loses to completeness here.
82
+ */
83
+ live?: StatusProbeRow[];
62
84
  };
63
85
 
64
86
  // Tiny escaper โ€” duplicates the one in gateway.ts / server.ts so this
@@ -151,6 +173,12 @@ export function helpText(agentName: string): string {
151
173
  * Version. This is the on-demand reincarnation of the SessionStart
152
174
  * greeting card deleted in #142 PR 1.
153
175
  */
176
+ const STATUS_DOT: Record<StatusProbeRow['status'], string> = {
177
+ ok: '๐ŸŸข',
178
+ degraded: '๐ŸŸก',
179
+ fail: '๐Ÿ”ด',
180
+ };
181
+
154
182
  export function statusPairedText(params: {
155
183
  user: string;
156
184
  meta: AgentMetadata;
@@ -164,6 +192,19 @@ export function statusPairedText(params: {
164
192
  ];
165
193
  if (meta.status) lines.push(`Status: <code>${escapeHtml(meta.status)}</code>${meta.uptime ? ` ยท up ${escapeHtml(meta.uptime)}` : ""}`);
166
194
 
195
+ // Live health block โ€” every probe (green and otherwise) so the user
196
+ // can see at a glance what's working AND what isn't. This is the
197
+ // `/status`-specific opposite of the boot card's silent-when-healthy
198
+ // contract: the boot card is a quiet ack, /status is the dashboard.
199
+ if (meta.live && meta.live.length > 0) {
200
+ lines.push("");
201
+ lines.push("<b>Health</b>");
202
+ for (const row of meta.live) {
203
+ const dot = STATUS_DOT[row.status] ?? STATUS_DOT.fail;
204
+ lines.push(`${dot} <b>${escapeHtml(row.label)}</b> ${escapeHtml(row.detail)}`);
205
+ }
206
+ }
207
+
167
208
  const audit = meta.audit;
168
209
  if (audit) {
169
210
  // Blank separator before the audit block so the reply reads as two
@@ -312,7 +353,8 @@ export function switchroomHelpText(agentName: string): string {
312
353
  `<code>/memory &lt;query&gt;</code> โ€” search agent memory`,
313
354
  ``,
314
355
  `<b>Fleet management</b>`,
315
- `<code>/update</code> โ€” pull latest code, reconcile, restart everything`,
356
+ `<code>/upgradestatus</code> โ€” read-only: CLI version, image age, container age per service`,
357
+ `<code>/update</code> โ€” dry-run plan; <code>/update apply</code> โ€” actually pull images, reconcile, restart`,
316
358
  `<code>/restart [name|all]</code> โ€” bounce agent (drains in-flight turn by default)`,
317
359
  `<code>/version</code> โ€” show versions + running agent health summary`,
318
360
  ``,
@@ -336,7 +378,7 @@ export function switchroomHelpText(agentName: string): string {
336
378
  `<code>/inject &lt;slash&gt;</code> โ€” inject a Claude Code REPL slash command (e.g. <code>/inject /cost</code>; allowlisted)`,
337
379
  `<code>/commands</code> โ€” this help`,
338
380
  ``,
339
- `<i>Tip: <code>/update</code> picks up new code; <code>/restart</code> bounces a stuck agent; <code>/version</code> checks what's running.</i>`,
381
+ `<i>Tip: <code>/update</code> shows the plan; <code>/update apply</code> executes it; <code>/restart</code> bounces a stuck agent; <code>/version</code> checks what's running.</i>`,
340
382
  ].join("\n");
341
383
  }
342
384