switchroom 0.8.1 → 0.11.0

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 (137) hide show
  1. package/README.md +54 -61
  2. package/bin/timezone-hook.sh +9 -7
  3. package/dist/agent-scheduler/index.js +285 -45
  4. package/dist/auth-broker/index.js +13932 -0
  5. package/dist/cli/drive-write-pretool.mjs +5418 -0
  6. package/dist/cli/switchroom.js +8890 -5560
  7. package/dist/host-control/main.js +582 -43
  8. package/dist/vault/approvals/kernel-server.js +276 -47
  9. package/dist/vault/broker/server.js +333 -69
  10. package/examples/minimal.yaml +63 -0
  11. package/examples/personal-google-workspace-mcp/.env.example +34 -0
  12. package/examples/personal-google-workspace-mcp/README.md +194 -0
  13. package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
  14. package/examples/switchroom.yaml +220 -0
  15. package/package.json +6 -4
  16. package/profiles/_base/start.sh.hbs +3 -3
  17. package/profiles/_shared/agent-self-service.md.hbs +126 -0
  18. package/profiles/default/CLAUDE.md +10 -0
  19. package/profiles/default/CLAUDE.md.hbs +16 -0
  20. package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
  21. package/skills/buildkite-agent-runtime/SKILL.md +44 -11
  22. package/skills/buildkite-api/SKILL.md +31 -8
  23. package/skills/buildkite-cli/SKILL.md +27 -9
  24. package/skills/buildkite-migration/SKILL.md +22 -9
  25. package/skills/buildkite-pipelines/SKILL.md +26 -9
  26. package/skills/buildkite-secure-delivery/SKILL.md +23 -9
  27. package/skills/buildkite-test-engine/SKILL.md +25 -8
  28. package/skills/docx/SKILL.md +1 -1
  29. package/skills/file-bug/SKILL.md +34 -6
  30. package/skills/humanizer/SKILL.md +15 -0
  31. package/skills/humanizer-calibrate/SKILL.md +7 -1
  32. package/skills/mcp-builder/SKILL.md +1 -1
  33. package/skills/pdf/SKILL.md +1 -1
  34. package/skills/pptx/SKILL.md +1 -1
  35. package/skills/skill-creator/SKILL.md +21 -1
  36. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  37. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  38. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  39. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  40. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  41. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  42. package/skills/switchroom-cli/SKILL.md +63 -64
  43. package/skills/switchroom-health/SKILL.md +23 -10
  44. package/skills/switchroom-install/SKILL.md +3 -3
  45. package/skills/switchroom-manage/SKILL.md +26 -19
  46. package/skills/switchroom-runtime/SKILL.md +67 -15
  47. package/skills/switchroom-status/SKILL.md +26 -1
  48. package/skills/telegram-test-harness/SKILL.md +3 -0
  49. package/skills/webapp-testing/SKILL.md +31 -1
  50. package/skills/xlsx/SKILL.md +1 -1
  51. package/telegram-plugin/admin-commands/dispatch.test.ts +1 -1
  52. package/telegram-plugin/admin-commands/index.ts +9 -5
  53. package/telegram-plugin/auth-snapshot-format.ts +612 -0
  54. package/telegram-plugin/auto-fallback-fleet.ts +215 -0
  55. package/telegram-plugin/auto-fallback.ts +28 -301
  56. package/telegram-plugin/dist/gateway/gateway.js +17453 -15100
  57. package/telegram-plugin/fleet-fallback-gate.ts +105 -0
  58. package/telegram-plugin/gateway/approval-callback.test.ts +104 -0
  59. package/telegram-plugin/gateway/approval-callback.ts +31 -3
  60. package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
  61. package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
  62. package/telegram-plugin/gateway/auth-command.ts +905 -0
  63. package/telegram-plugin/gateway/auth-line.ts +123 -0
  64. package/telegram-plugin/gateway/auth-status-adapter.ts +101 -0
  65. package/telegram-plugin/gateway/boot-card.ts +23 -37
  66. package/telegram-plugin/gateway/boot-probes.ts +9 -12
  67. package/telegram-plugin/gateway/diff-preview-card.test.ts +192 -0
  68. package/telegram-plugin/gateway/diff-preview-card.ts +170 -0
  69. package/telegram-plugin/gateway/drive-write-approval.test.ts +312 -0
  70. package/telegram-plugin/gateway/drive-write-approval.ts +243 -0
  71. package/telegram-plugin/gateway/folder-picker-handler.test.ts +314 -0
  72. package/telegram-plugin/gateway/folder-picker-handler.ts +348 -0
  73. package/telegram-plugin/gateway/gateway.ts +1156 -938
  74. package/telegram-plugin/gateway/hostd-dispatch.ts +244 -0
  75. package/telegram-plugin/gateway/ipc-protocol.ts +83 -2
  76. package/telegram-plugin/gateway/ipc-server.ts +69 -0
  77. package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +103 -12
  78. package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
  79. package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
  80. package/telegram-plugin/model-unavailable.ts +28 -12
  81. package/telegram-plugin/permission-title.ts +56 -0
  82. package/telegram-plugin/quota-check.ts +19 -41
  83. package/telegram-plugin/scripts/build.mjs +0 -1
  84. package/telegram-plugin/shared/bot-runtime.ts +5 -4
  85. package/telegram-plugin/silence-poke.ts +153 -1
  86. package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
  87. package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
  88. package/telegram-plugin/tests/auth-command-format2.test.ts +156 -0
  89. package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
  90. package/telegram-plugin/tests/auth-snapshot-format.test.ts +429 -0
  91. package/telegram-plugin/tests/auth-status-adapter.test.ts +129 -0
  92. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +211 -0
  93. package/telegram-plugin/tests/auto-fallback.test.ts +60 -358
  94. package/telegram-plugin/tests/boot-probes.test.ts +27 -22
  95. package/telegram-plugin/tests/fleet-fallback-gate.test.ts +197 -0
  96. package/telegram-plugin/tests/model-unavailable.test.ts +30 -5
  97. package/telegram-plugin/tests/permission-title.test.ts +31 -0
  98. package/telegram-plugin/tests/quota-check.test.ts +5 -35
  99. package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +212 -2
  100. package/telegram-plugin/tests/silence-poke.test.ts +237 -0
  101. package/telegram-plugin/tests/turn-flush-safety.test.ts +112 -0
  102. package/telegram-plugin/turn-flush-safety.ts +55 -1
  103. package/telegram-plugin/uat/SETUP.md +35 -1
  104. package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
  105. package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
  106. package/telegram-plugin/uat/runners/report.ts +150 -0
  107. package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
  108. package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
  109. package/telegram-plugin/uat/runners/scorer.ts +106 -0
  110. package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
  111. package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
  112. package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +7 -1
  113. package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +7 -1
  114. package/telegram-plugin/auth-dashboard.ts +0 -1104
  115. package/telegram-plugin/auth-slot-parser.ts +0 -497
  116. package/telegram-plugin/auto-fallback-dispatcher.ts +0 -68
  117. package/telegram-plugin/dist/foreman/foreman.js +0 -31358
  118. package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
  119. package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
  120. package/telegram-plugin/foreman/foreman.ts +0 -1165
  121. package/telegram-plugin/foreman/setup-flow.ts +0 -345
  122. package/telegram-plugin/foreman/setup-state.ts +0 -239
  123. package/telegram-plugin/foreman/state.ts +0 -203
  124. package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
  125. package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
  126. package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
  127. package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
  128. package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
  129. package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
  130. package/telegram-plugin/tests/auto-fallback-dispatcher.e2e.test.ts +0 -183
  131. package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
  132. package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
  133. package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
  134. package/telegram-plugin/tests/foreman-state.test.ts +0 -164
  135. package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
  136. package/telegram-plugin/tests/setup-flow.test.ts +0 -510
  137. package/telegram-plugin/tests/setup-state.test.ts +0 -146
@@ -1,1165 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Foreman — always-on admin bot for the switchroom fleet.
4
- *
5
- * Unlike per-agent gateways, the foreman is not bound to a single agent.
6
- * It provides fleet-wide read-only and write visibility (Phase 3a + 3b).
7
- *
8
- * Configuration:
9
- * ~/.switchroom/foreman/.env TELEGRAM_BOT_TOKEN=<token>
10
- * ~/.switchroom/foreman/access.json { "allowFrom": ["<userId>"] }
11
- *
12
- * Phase 3a commands (read-only):
13
- * /start, /help — greeting + command list
14
- * /status, /list — fleet summary via `switchroom agent list --json`
15
- * /logs <agent> [--tail N] — journalctl output, paginated > 3 KB
16
- * /auth [agent] — fleet auth dashboard (per-agent, agent-name-parametric)
17
- *
18
- * Phase 3b commands (write):
19
- * /restart <agent> — systemctl --user restart switchroom-<agent>
20
- * /delete <agent> — 2-step confirm → archive dir + destroy unit
21
- * /update — switchroom update (paginated output)
22
- * /create-agent [name] — multi-turn flow: profile → bot token → OAuth
23
- * /setup [slug] — guided new-agent wizard (slug → persona → model → emoji → token → allowlist → start)
24
- */
25
-
26
- import { Bot, InlineKeyboard, type Context } from 'grammy'
27
- import { readFileSync, writeFileSync, chmodSync, mkdirSync } from 'fs'
28
- import { homedir } from 'os'
29
- import { join, resolve } from 'path'
30
- import { listWorktrees } from '../../src/worktree/list.js'
31
- import { installPluginLogger } from '../plugin-logger.js'
32
- import {
33
- escapeHtmlForTg,
34
- installTgPostLogger,
35
- isAllowedSender,
36
- makeSwitchroomExec,
37
- makeSwitchroomExecCombined,
38
- makeSwitchroomExecJson,
39
- makeSwitchroomReply,
40
- runPollingLoop,
41
- } from '../shared/bot-runtime.js'
42
- import {
43
- assertSafeAgentName,
44
- buildFleetSummary,
45
- handleLogsCommand,
46
- handleRestartCommand,
47
- handleDeleteCommand,
48
- executeDeleteAgent,
49
- handleUpdateCommand,
50
- handleVersionCommand,
51
- } from './foreman-handlers.js'
52
- import {
53
- buildDashboard,
54
- isQuotaHot,
55
- type DashboardState,
56
- type DashboardSlot,
57
- type SlotHealth,
58
- } from '../auth-dashboard.js'
59
- import { parseAuthSubCommand } from '../auth-slot-parser.js'
60
- import {
61
- getState,
62
- setState,
63
- clearState,
64
- listActiveFlows,
65
- } from './state.js'
66
- import {
67
- startCreateFlow,
68
- handleFlowText,
69
- makeInitialState,
70
- advanceState,
71
- stepLabel,
72
- } from './foreman-create-flow.js'
73
- import { listAvailableProfiles } from '../../src/agents/profiles.js'
74
- import { createAgent, completeCreation } from '../../src/agents/create-orchestrator.js'
75
- import { validateBotToken } from '../../src/setup/telegram-api.js'
76
- import { resolveAgentsDir, loadConfig } from '../../src/config/loader.js'
77
- import {
78
- getSetupState,
79
- setSetupState,
80
- clearSetupState,
81
- listActiveSetupFlows,
82
- } from './setup-state.js'
83
- import {
84
- startSetupFlow,
85
- handleSetupText,
86
- makeSetupInitialState,
87
- advanceSetupState,
88
- setupStepLabel,
89
- } from './setup-flow.js'
90
-
91
- // ─── Stderr logging ───────────────────────────────────────────────────────
92
- installPluginLogger()
93
-
94
- // ─── Config dir ───────────────────────────────────────────────────────────
95
- const FOREMAN_DIR = process.env.SWITCHROOM_FOREMAN_DIR
96
- ?? join(homedir(), '.switchroom', 'foreman')
97
- const ENV_FILE = join(FOREMAN_DIR, '.env')
98
- const ACCESS_FILE = join(FOREMAN_DIR, 'access.json')
99
-
100
- // ─── Load .env ────────────────────────────────────────────────────────────
101
- try {
102
- chmodSync(ENV_FILE, 0o600)
103
- for (const line of readFileSync(ENV_FILE, 'utf8').split('\n')) {
104
- const m = line.match(/^(\w+)=(.*)$/)
105
- if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2]
106
- }
107
- } catch (err) {
108
- const code = (err as NodeJS.ErrnoException)?.code
109
- if (code !== 'ENOENT') {
110
- process.stderr.write(
111
- `foreman: warning — failed to load ${ENV_FILE}: ${(err as Error).message}\n`,
112
- )
113
- }
114
- }
115
-
116
- // ─── Bot token ────────────────────────────────────────────────────────────
117
- // Issue #758: when bot_token is a `vault:` ref and no .env was written,
118
- // materialize it from the vault at startup (in-memory only).
119
- //
120
- // The outer try/catch is narrowed (post-#761 review) to ONLY catch
121
- // ERR_MODULE_NOT_FOUND from the dynamic import. Other errors (e.g. throws
122
- // from inside materializeBotToken that aren't BotTokenMaterializeError)
123
- // must propagate so we don't mask real bugs behind the legacy "set in .env"
124
- // hint.
125
- type MaterializeMod = typeof import('../../src/telegram/materialize-bot-token.js')
126
- let materializeMod: MaterializeMod | null = null
127
- try {
128
- materializeMod = await import('../../src/telegram/materialize-bot-token.js')
129
- } catch (err) {
130
- const code = (err as NodeJS.ErrnoException | undefined)?.code
131
- if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') {
132
- // Module missing — fall through with materializeMod=null.
133
- } else {
134
- throw err
135
- }
136
- }
137
-
138
- let TOKEN: string
139
- if (materializeMod !== null) {
140
- const { materializeBotToken, BotTokenMaterializeError } = materializeMod
141
- try {
142
- TOKEN = await materializeBotToken()
143
- } catch (err) {
144
- if (err instanceof BotTokenMaterializeError) {
145
- process.stderr.write(`foreman: ${err.message}\n`)
146
- process.exit(1)
147
- }
148
- throw err
149
- }
150
- } else if (process.env.TELEGRAM_BOT_TOKEN) {
151
- TOKEN = process.env.TELEGRAM_BOT_TOKEN
152
- } else {
153
- process.stderr.write(
154
- `foreman: TELEGRAM_BOT_TOKEN required\n` +
155
- ` set in ${ENV_FILE}\n` +
156
- ` format: TELEGRAM_BOT_TOKEN=123456789:AAH...\n` +
157
- ` (token-materialization helper not found)\n`,
158
- )
159
- process.exit(1)
160
- }
161
-
162
- // ─── Access list ──────────────────────────────────────────────────────────
163
- function loadAllowFrom(): string[] {
164
- try {
165
- const raw = JSON.parse(readFileSync(ACCESS_FILE, 'utf8')) as { allowFrom?: unknown }
166
- if (Array.isArray(raw.allowFrom)) {
167
- return (raw.allowFrom as unknown[]).map(String)
168
- }
169
- } catch {
170
- /* fall through — return empty */
171
- }
172
- return []
173
- }
174
-
175
- // ─── CLI exec helpers ─────────────────────────────────────────────────────
176
- const switchroomExec = makeSwitchroomExec()
177
- const switchroomExecCombined = makeSwitchroomExecCombined()
178
- const switchroomExecJson = makeSwitchroomExecJson()
179
-
180
- // ─── Bot ──────────────────────────────────────────────────────────────────
181
- const bot = new Bot(TOKEN)
182
- installTgPostLogger(bot)
183
-
184
- // No forum-topic routing in foreman — it's always a DM.
185
- const switchroomReply = makeSwitchroomReply(() => undefined)
186
-
187
- // ─── Auth guard middleware ────────────────────────────────────────────────
188
- bot.use(async (ctx, next) => {
189
- // Silently ignore any message that is not a private DM.
190
- // If the foreman bot is ever added to a group, this prevents fleet info
191
- // from leaking to all group members even when the sender is allowlisted.
192
- if (ctx.chat?.type !== 'private') return
193
- if (!ctx.from) return
194
- const allowFrom = loadAllowFrom()
195
- if (!isAllowedSender(ctx, allowFrom)) {
196
- process.stderr.write(`foreman: rejected message from user ${ctx.from.id}\n`)
197
- return
198
- }
199
- await next()
200
- })
201
-
202
- // ─── Helpers ─────────────────────────────────────────────────────────────
203
-
204
- /** Fetch auth dashboard state for a named agent. */
205
- function fetchForemanDashboardState(agent: string): DashboardState | null {
206
- type SlotListing = {
207
- slots: Array<{
208
- slot: string; active: boolean; health: string;
209
- quota_exhausted_until?: number | null;
210
- }>
211
- }
212
- let slots: DashboardSlot[] = []
213
- try {
214
- const listing = switchroomExecJson<SlotListing>(['auth', 'list', agent, '--json'])
215
- if (listing && Array.isArray(listing.slots)) {
216
- slots = listing.slots.map(s => ({
217
- slot: s.slot,
218
- active: s.active,
219
- health: (s.health as SlotHealth) ?? 'missing',
220
- quotaExhaustedUntil: s.quota_exhausted_until ?? null,
221
- fiveHourPct: null,
222
- sevenDayPct: null,
223
- }))
224
- }
225
- } catch {
226
- return null
227
- }
228
-
229
- let plan: string | null = null
230
- let rateLimitTier: string | null = null
231
- try {
232
- type AuthStatusResp = {
233
- agents: Array<{ name: string; subscription_type: string | null; rate_limit_tier?: string | null }>
234
- }
235
- const statusData = switchroomExecJson<AuthStatusResp>(['auth', 'status'])
236
- const thisAgent = statusData?.agents?.find(a => a.name === agent)
237
- if (thisAgent?.subscription_type) plan = thisAgent.subscription_type
238
- if (thisAgent?.rate_limit_tier) rateLimitTier = thisAgent.rate_limit_tier
239
- } catch { /* best-effort */ }
240
-
241
- return {
242
- agent,
243
- bankId: agent,
244
- plan,
245
- rateLimitTier,
246
- slots,
247
- quotaHot: isQuotaHot(slots),
248
- generatedAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
249
- }
250
- }
251
-
252
- // ─── /start ──────────────────────────────────────────────────────────────
253
- bot.command('start', async ctx => {
254
- await switchroomReply(ctx, [
255
- '<b>Foreman — switchroom fleet admin</b>',
256
- '',
257
- 'Read-only commands:',
258
- ' /status, /list — fleet summary',
259
- ' /logs &lt;agent&gt; [--tail N] — last N log lines (default 50)',
260
- ' /auth [agent] — auth dashboard',
261
- ' /version — show versions + running agent health',
262
- '',
263
- 'Write commands:',
264
- ' /restart &lt;agent&gt; — restart an agent',
265
- ' /delete &lt;agent&gt; — delete an agent (2-step confirm)',
266
- ' /update — update switchroom',
267
- ' /setup [slug] — guided new-agent wizard',
268
- ' /create-agent [name] — create a new agent (legacy multi-turn)',
269
- ].join('\n'), { html: true })
270
- })
271
-
272
- // ─── /help ───────────────────────────────────────────────────────────────
273
- bot.command('help', async ctx => {
274
- await switchroomReply(ctx, [
275
- '<b>Foreman commands</b>',
276
- '',
277
- '<b>Fleet info:</b>',
278
- '/status, /list — show fleet status',
279
- '/logs &lt;agent&gt; [--tail N] — show agent journal logs',
280
- '/auth [agent] — auth slot dashboard for an agent',
281
- '',
282
- '<b>Fleet management:</b>',
283
- '/restart &lt;agent&gt; — restart an agent via systemctl',
284
- '/delete &lt;agent&gt; — delete agent (confirms, then archives dir)',
285
- '/update — pull latest switchroom + reconcile agents',
286
- '/setup [slug] — guided wizard: slug → persona → model → emoji → token → start',
287
- '/create-agent [name] — legacy interactive new-agent wizard',
288
- '',
289
- '<b>Examples:</b>',
290
- '<code>/logs gymbro --tail 100</code>',
291
- '<code>/restart gymbro</code>',
292
- '<code>/setup gymbro</code>',
293
- ].join('\n'), { html: true })
294
- })
295
-
296
- // ─── /status + /list ──────────────────────────────────────────────────────
297
- bot.command(['status', 'list'], async ctx => {
298
- const summary = buildFleetSummary(switchroomExecJson)
299
- await switchroomReply(ctx, summary, { html: true })
300
- })
301
-
302
- // ─── /logs ───────────────────────────────────────────────────────────────
303
- bot.command('logs', async ctx => {
304
- const result = handleLogsCommand((ctx.match ?? '') as string)
305
- for (const reply of result.replies) {
306
- await switchroomReply(ctx, reply.text, { html: reply.html })
307
- }
308
- })
309
-
310
- // ─── /auth ────────────────────────────────────────────────────────────────
311
- bot.command('auth', async ctx => {
312
- const rawArgs = ((ctx.match ?? '') as string).trim()
313
-
314
- // Determine which agents to show
315
- let agentNames: string[]
316
-
317
- if (rawArgs) {
318
- // User specified an agent name. parseAuthSubCommand needs the
319
- // arguments split into parts and a fallback agent name. The 'status'
320
- // intent kind has no `agent` field — narrow before reading it.
321
- const parts = rawArgs.split(/\s+/)
322
- const parsed = parseAuthSubCommand(parts, parts[0] ?? '')
323
- const agentArg = ('agent' in parsed ? parsed.agent : undefined) || parts[0] || ''
324
- try { assertSafeAgentName(agentArg) } catch {
325
- await switchroomReply(ctx, 'Invalid agent name.', { html: true })
326
- return
327
- }
328
- agentNames = [agentArg]
329
- } else {
330
- // Enumerate all agents
331
- try {
332
- const data = switchroomExecJson<{ agents: Array<{ name: string }> }>(['agent', 'list'])
333
- agentNames = data?.agents?.map(a => a.name) ?? []
334
- } catch {
335
- agentNames = []
336
- }
337
- if (agentNames.length === 0) {
338
- await switchroomReply(ctx, '<i>No agents found. Try <code>/auth &lt;agentname&gt;</code>.</i>', { html: true })
339
- return
340
- }
341
- }
342
-
343
- // Render dashboard per agent
344
- for (const agent of agentNames) {
345
- const state = fetchForemanDashboardState(agent)
346
- if (!state) {
347
- await switchroomReply(ctx,
348
- `<b>/auth ${escapeHtmlForTg(agent)}</b> — no data (agent missing or CLI unreachable)`,
349
- { html: true },
350
- )
351
- continue
352
- }
353
- const { text, keyboard } = buildDashboard(state)
354
- await ctx.reply(text, { parse_mode: 'HTML', reply_markup: keyboard, link_preview_options: { is_disabled: true } })
355
- }
356
- })
357
-
358
- // ─── /restart ─────────────────────────────────────────────────────────────
359
- bot.command('restart', async ctx => {
360
- const result = handleRestartCommand((ctx.match ?? '') as string)
361
- await switchroomReply(ctx, result.text, { html: result.html })
362
- })
363
-
364
- // ─── /delete ──────────────────────────────────────────────────────────────
365
-
366
- /**
367
- * In-memory map of chatId → pending delete agent name.
368
- * Cleared when the user confirms or the conversation moves on.
369
- * For lightweight 2-step confirm — no SQLite needed since this is ephemeral.
370
- */
371
- const pendingDeletes = new Map<string, string>()
372
-
373
- bot.command('delete', async ctx => {
374
- const result = handleDeleteCommand((ctx.match ?? '') as string)
375
- for (const reply of result.replies) {
376
- await switchroomReply(ctx, reply.text, { html: reply.html })
377
- }
378
- if (result.needsConfirm && result.agentForConfirm) {
379
- const chatId = String(ctx.chat!.id)
380
- pendingDeletes.set(chatId, result.agentForConfirm)
381
- }
382
- })
383
-
384
- // ─── /update ──────────────────────────────────────────────────────────────
385
- bot.command('update', async ctx => {
386
- await switchroomReply(ctx, 'Running <code>switchroom update</code>…', { html: true })
387
- const result = handleUpdateCommand(switchroomExecCombined)
388
- for (const reply of result.replies) {
389
- await switchroomReply(ctx, reply.text, { html: reply.html })
390
- }
391
- })
392
-
393
- // ─── /version ─────────────────────────────────────────────────────────────
394
- bot.command('version', async ctx => {
395
- const result = handleVersionCommand(switchroomExecCombined)
396
- for (const reply of result.replies) {
397
- await switchroomReply(ctx, reply.text, { html: reply.html })
398
- }
399
- })
400
-
401
- // ─── /worktrees ───────────────────────────────────────────────────────────
402
- bot.command('worktrees', async ctx => {
403
- try {
404
- const { worktrees } = listWorktrees()
405
- if (worktrees.length === 0) {
406
- await switchroomReply(ctx, 'No active worktrees.', { html: false })
407
- return
408
- }
409
- const lines = ['<b>Active worktrees</b>', '']
410
- for (const w of worktrees) {
411
- const ageMin = Math.floor(w.ageSeconds / 60)
412
- const hbMin = Math.floor(w.heartbeatAgeSeconds / 60)
413
- const owner = w.ownerAgent ? ` (${escapeHtmlForTg(w.ownerAgent)})` : ''
414
- lines.push(
415
- `• <code>${escapeHtmlForTg(w.repoName)}</code>${owner} — branch <code>${escapeHtmlForTg(w.branch)}</code>, age ${ageMin}m, hb ${hbMin}m`,
416
- )
417
- }
418
- await switchroomReply(ctx, lines.join('\n'), { html: true })
419
- } catch (err) {
420
- await switchroomReply(ctx, `<b>worktrees failed:</b> ${escapeHtmlForTg((err as Error).message)}`, { html: true })
421
- }
422
- })
423
-
424
- // ─── /setup ───────────────────────────────────────────────────────────────
425
- //
426
- // Guided wizard: slug → persona name → model → emoji → bot token → allowlist
427
- // confirmation → reconcile (createAgent) + start.
428
- //
429
- // Deferral notes:
430
- // // TODO(#188): BotFather auto-flow — currently user creates bot manually
431
- // // TODO(#189): OAuth code paste step — currently shows manual terminal instruction
432
- // // TODO(#190): Skills selector — currently shows placeholder message
433
-
434
- bot.command(['setup', 'createagent'], async ctx => {
435
- const chatId = String(ctx.chat!.id)
436
- const inlineSlug = ((ctx.match ?? '') as string).trim().split(/\s+/)[0] || null
437
-
438
- // If there's already an active setup flow, remind the user
439
- const existing = getSetupState(chatId)
440
- if (existing && existing.step !== 'done') {
441
- await switchroomReply(ctx, [
442
- `A setup wizard is already in progress for <b>${escapeHtmlForTg(existing.slug ?? '?')}</b> (${setupStepLabel(existing.step)}).`,
443
- '',
444
- 'Continue by sending your answer, or type <code>cancel</code> to abort.',
445
- ].join('\n'), { html: true })
446
- return
447
- }
448
-
449
- const action = startSetupFlow(inlineSlug)
450
-
451
- if (action.kind === 'error') {
452
- await switchroomReply(ctx, action.message, { html: true })
453
- return
454
- }
455
-
456
- if (action.kind === 'ask-slug') {
457
- const state = makeSetupInitialState(chatId, null)
458
- setSetupState(state)
459
- await switchroomReply(ctx, [
460
- '<b>New agent wizard</b>',
461
- '',
462
- 'Step 1/5: What slug (short name) should this agent use?',
463
- '<i>e.g. <code>gymbro</code> — lowercase, hyphens/underscores OK, max 51 chars</i>',
464
- '',
465
- 'Type <code>cancel</code> at any time to abort.',
466
- ].join('\n'), { html: true })
467
- return
468
- }
469
-
470
- if (action.kind === 'ask-persona') {
471
- const state = makeSetupInitialState(chatId, inlineSlug)
472
- setSetupState(state)
473
- await switchroomReply(ctx, [
474
- `<b>New agent wizard</b> — slug: <code>${escapeHtmlForTg(inlineSlug!)}</code>`,
475
- '',
476
- 'Step 2/5: What should this agent\'s persona name be?',
477
- '<i>e.g. <code>Gym Bro</code> — displayed in greetings and topics</i>',
478
- ].join('\n'), { html: true })
479
- return
480
- }
481
- })
482
-
483
- // ─── /cancel (setup wizard abort) ────────────────────────────────────────
484
-
485
- bot.command('cancel', async ctx => {
486
- const chatId = String(ctx.chat!.id)
487
- const setupState = getSetupState(chatId)
488
- if (setupState && setupState.step !== 'done') {
489
- clearSetupState(chatId)
490
- await switchroomReply(ctx, 'Setup wizard cancelled. Type /setup to start a new one.', { html: false })
491
- return
492
- }
493
- // No active setup flow — check create-agent flow
494
- const createState = getState(chatId)
495
- if (createState && createState.step !== 'done') {
496
- clearState(chatId)
497
- await switchroomReply(ctx, 'Create-agent flow cancelled.', { html: false })
498
- return
499
- }
500
- await switchroomReply(ctx, 'No active wizard to cancel.', { html: false })
501
- })
502
-
503
- // ─── /create-agent ────────────────────────────────────────────────────────
504
-
505
- bot.command('create_agent', async ctx => {
506
- await handleCreateAgentCommand(ctx, (ctx.match ?? '') as string)
507
- })
508
- // Also register with hyphen (Telegram normalises _ and - differently per client)
509
- bot.command('create-agent', async ctx => {
510
- await handleCreateAgentCommand(ctx, (ctx.match ?? '') as string)
511
- })
512
-
513
- async function handleCreateAgentCommand(ctx: Context, match: string): Promise<void> {
514
- const chatId = String(ctx.chat!.id)
515
- const inlineName = match.trim().split(/\s+/)[0] || null
516
-
517
- let profiles: string[]
518
- try {
519
- profiles = listAvailableProfiles()
520
- } catch {
521
- await switchroomReply(ctx, 'Could not load profiles. Is switchroom installed correctly?', { html: false })
522
- return
523
- }
524
-
525
- const action = startCreateFlow(inlineName, profiles)
526
-
527
- if (action.kind === 'error') {
528
- await switchroomReply(ctx, action.message, { html: true })
529
- return
530
- }
531
-
532
- if (action.kind === 'ask-name') {
533
- const state = makeInitialState(chatId, null)
534
- setState(state)
535
- await switchroomReply(ctx, 'What should the new agent be named? (lowercase, hyphens/underscores OK)', { html: false })
536
- return
537
- }
538
-
539
- if (action.kind === 'ask-profile') {
540
- const state = makeInitialState(chatId, inlineName)
541
- setState(state)
542
- const kb = new InlineKeyboard()
543
- for (const p of profiles) {
544
- kb.text(p, `cf:profile:${p}`).row()
545
- }
546
- await ctx.reply(
547
- `Choose a profile for <b>${escapeHtmlForTg(inlineName!)}</b>:`,
548
- { parse_mode: 'HTML', reply_markup: kb },
549
- )
550
- return
551
- }
552
- }
553
-
554
- // ─── Create-agent: callback_query for profile selection ───────────────────
555
-
556
- bot.on('callback_query:data', async ctx => {
557
- // Defense-in-depth: the global bot.use middleware already fires a
558
- // `ctx.chat?.type !== 'private'` check, but callback_query updates from
559
- // inline messages can arrive without a ctx.chat (callback_query.message
560
- // is populated but ctx.chat may be undefined in edge cases). The global
561
- // guard does `undefined !== 'private'` = true = ALLOW, so re-check here
562
- // explicitly. If this isn't a private chat, silently drop.
563
- if (ctx.chat?.type !== 'private') {
564
- await ctx.answerCallbackQuery().catch(() => {})
565
- return
566
- }
567
- const data = ctx.callbackQuery.data
568
- const chatId = String(ctx.chat?.id ?? ctx.callbackQuery.from.id)
569
-
570
- if (data.startsWith('cf:profile:')) {
571
- const profile = data.slice('cf:profile:'.length)
572
- await ctx.answerCallbackQuery()
573
-
574
- const state = getState(chatId)
575
- if (!state || (state.step !== 'asked-name' && state.step !== 'asked-profile')) {
576
- await ctx.reply('No active create-agent flow. Use /create-agent to start.')
577
- return
578
- }
579
-
580
- const profiles = listAvailableProfiles()
581
- if (!profiles.includes(profile)) {
582
- await ctx.reply('Unknown profile. Use /create-agent to restart.')
583
- return
584
- }
585
-
586
- const updated = advanceState(state, { step: 'asked-bot-token', profile })
587
- setState(updated)
588
-
589
- await ctx.reply(
590
- `Profile <b>${escapeHtmlForTg(profile)}</b> selected.\n\nPaste the BotFather token for the new agent's Telegram bot:\n<i>(Note: this token will be visible in this chat)</i>`,
591
- { parse_mode: 'HTML' },
592
- )
593
- return
594
- }
595
-
596
- // Unknown callback — ignore
597
- await ctx.answerCallbackQuery()
598
- })
599
-
600
- // ─── Inbound text router for multi-turn flows ─────────────────────────────
601
-
602
- bot.on('message:text', async ctx => {
603
- if (ctx.chat?.type !== 'private') return
604
- const chatId = String(ctx.chat.id)
605
- const text = ctx.message.text ?? ''
606
-
607
- // 1. Check for pending delete confirmation
608
- const pendingDelete = pendingDeletes.get(chatId)
609
- if (pendingDelete && text.trim().toUpperCase() === 'YES') {
610
- pendingDeletes.delete(chatId)
611
- const result = executeDeleteAgent(pendingDelete, switchroomExec)
612
- for (const reply of result.replies) {
613
- await switchroomReply(ctx, reply.text, { html: reply.html })
614
- }
615
- return
616
- }
617
- if (pendingDelete) {
618
- // Any non-YES text cancels the pending delete
619
- pendingDeletes.delete(chatId)
620
- await switchroomReply(ctx, 'Deletion cancelled.', { html: false })
621
- return
622
- }
623
- // No pendingDelete on this chat. If the user's text is `YES` or `YES.`,
624
- // they probably typed it expecting to confirm a delete that was queued
625
- // before a foreman restart (pendingDeletes is in-memory; #28 item 7).
626
- // Pre-fix this fell through and eventually rendered "Unknown command",
627
- // which left the user wondering whether the delete went through. Surface
628
- // a clear "no pending delete" message instead.
629
- if (/^yes\.?$/i.test(text.trim())) {
630
- await switchroomReply(
631
- ctx,
632
- 'There is no pending delete to confirm — the foreman may have restarted since you ran <code>/delete</code>. Re-run <code>/delete &lt;agent&gt;</code> if you still want to delete.',
633
- { html: true },
634
- )
635
- return
636
- }
637
-
638
- // 2. Check for active /setup wizard flow
639
- const setupState = getSetupState(chatId)
640
- if (setupState && setupState.step !== 'done') {
641
- await handleSetupFlowText(ctx, chatId, text, setupState)
642
- return
643
- }
644
-
645
- // 3. Check for active create-agent flow
646
- const flowState = getState(chatId)
647
- if (flowState && flowState.step !== 'done') {
648
- await handleCreateFlowText(ctx, chatId, text, flowState)
649
- return
650
- }
651
-
652
- // 4. Unknown text
653
- await switchroomReply(ctx, 'Unknown command. Try /help.', { html: true })
654
- })
655
-
656
- // ─── Setup wizard: text handler ───────────────────────────────────────────
657
-
658
- async function handleSetupFlowText(
659
- ctx: Context,
660
- chatId: string,
661
- text: string,
662
- setupState: NonNullable<ReturnType<typeof getSetupState>>,
663
- ): Promise<void> {
664
- const callerId = String(ctx.from?.id ?? '')
665
- // #190: pass live profiles into the wizard so the asked-profile step can
666
- // validate against the operator's actual profile set.
667
- const profiles = listAvailableProfiles()
668
- const action = handleSetupText({ state: setupState, text, callerId, profiles })
669
-
670
- switch (action.kind) {
671
- // ── Slug step ──────────────────────────────────────────────────────────
672
- case 'ask-persona': {
673
- const updated = advanceSetupState(setupState, { step: 'asked-persona', slug: action.slug })
674
- setSetupState(updated)
675
- await switchroomReply(ctx, [
676
- `Slug: <code>${escapeHtmlForTg(action.slug)}</code>`,
677
- '',
678
- 'Step 2/6: What persona name should this agent have?',
679
- '<i>e.g. <code>Gym Bro</code> — displayed in greetings</i>',
680
- ].join('\n'), { html: true })
681
- return
682
- }
683
-
684
- // ── Persona step ───────────────────────────────────────────────────────
685
- case 'ask-model': {
686
- const updated = advanceSetupState(setupState, {
687
- step: 'asked-model',
688
- slug: action.slug,
689
- persona: action.persona,
690
- })
691
- setSetupState(updated)
692
- await switchroomReply(ctx, [
693
- `Persona: <b>${escapeHtmlForTg(action.persona)}</b>`,
694
- '',
695
- 'Step 3/6: Which Claude model should this agent use?',
696
- 'Options: <code>sonnet</code>, <code>opus</code>, <code>haiku</code>, or a full model ID.',
697
- 'Type <code>skip</code> to use the profile default.',
698
- ].join('\n'), { html: true })
699
- return
700
- }
701
-
702
- // ── Model step ─────────────────────────────────────────────────────────
703
- case 'ask-emoji': {
704
- const updated = advanceSetupState(setupState, {
705
- step: 'asked-emoji',
706
- model: action.model,
707
- })
708
- setSetupState(updated)
709
- const modelNote = action.model
710
- ? `Model: <code>${escapeHtmlForTg(action.model)}</code>`
711
- : 'Model: <i>profile default</i>'
712
- await switchroomReply(ctx, [
713
- modelNote,
714
- '',
715
- 'Step 4/6: What emoji should represent this agent\'s Telegram topic?',
716
- 'Type <code>skip</code> to use the default.',
717
- ].join('\n'), { html: true })
718
- return
719
- }
720
-
721
- // ── Emoji step → Profile selector (#190) ───────────────────────────────
722
- case 'ask-profile': {
723
- const updated = advanceSetupState(setupState, {
724
- step: 'asked-profile',
725
- emoji: action.emoji,
726
- })
727
- setSetupState(updated)
728
- const emojiNote = action.emoji
729
- ? `Emoji: ${action.emoji}`
730
- : 'Emoji: <i>default</i>'
731
- await switchroomReply(ctx, [
732
- emojiNote,
733
- '',
734
- `Step 5/6: Choose a profile for this agent.`,
735
- `Available: ${action.profiles.map(p => `<code>${escapeHtmlForTg(p)}</code>`).join(', ')}`,
736
- '',
737
- '<i>The profile sets the agent\'s base persona, system prompt, and skill bundle. Pick <code>default</code> if unsure.</i>',
738
- ].join('\n'), { html: true })
739
- return
740
- }
741
-
742
- // ── Profile step ───────────────────────────────────────────────────────
743
- case 'ask-bot-token': {
744
- const updated = advanceSetupState(setupState, {
745
- step: 'asked-bot-token',
746
- profile: action.profile,
747
- })
748
- setSetupState(updated)
749
- // TODO(#188): BotFather auto-flow — currently user creates bot manually
750
- await switchroomReply(ctx, [
751
- `Profile: <code>${escapeHtmlForTg(action.profile)}</code>`,
752
- '',
753
- 'Step 6/6: Paste the BotFather token for the new agent\'s bot.',
754
- '',
755
- '<b>To create a bot:</b>',
756
- '1. Open @BotFather in Telegram',
757
- '2. Send <code>/newbot</code> and follow the prompts',
758
- '3. Copy and paste the token here',
759
- '',
760
- '<i>Note: the token will be briefly visible in this chat.</i>',
761
- ].join('\n'), { html: true })
762
- return
763
- }
764
-
765
- // ── Bot-token step ─────────────────────────────────────────────────────
766
- case 'confirm-allowlist': {
767
- const botToken = text.trim()
768
- const updated = advanceSetupState(setupState, {
769
- step: 'confirming-allowlist',
770
- botToken,
771
- })
772
- setSetupState(updated)
773
- await switchroomReply(ctx, [
774
- 'Token received.',
775
- '',
776
- `Your Telegram user ID is <code>${escapeHtmlForTg(action.callerId)}</code>.`,
777
- '',
778
- 'Reply <b>yes</b> to set this as the only allowed user for the new agent,',
779
- 'or paste a different user ID.',
780
- ].join('\n'), { html: true })
781
- return
782
- }
783
-
784
- // ── Allowlist confirmation step → provision agent (#189: split path) ──
785
- //
786
- // Pre-fix this was a single 'call-reconcile' that ran createAgent inline
787
- // and told the user to run `switchroom auth code` from a terminal. Per
788
- // #189 + #190, the flow now splits into three coordinated steps:
789
- //
790
- // call-create-agent → run createAgent (returns loginUrl)
791
- // ask-oauth-code → render the URL prompt; user pastes back
792
- // call-complete-creation → run completeCreation + start the agent
793
- //
794
- // Mirrors the create-flow's existing call-create-agent/asked-oauth-code/
795
- // call-complete-creation triad. The two flows have parallel state
796
- // machines and parallel orchestrator handlers.
797
- case 'call-create-agent': {
798
- const { slug, persona, model, emoji, profile, botToken, allowedUserId } = action
799
- void model; void emoji; void allowedUserId // captured in state earlier; createAgent reads from yaml
800
- const updated = advanceSetupState(setupState, {
801
- step: 'reconciling',
802
- allowedUserId,
803
- })
804
- setSetupState(updated)
805
-
806
- await switchroomReply(ctx, `Validating token…`, { html: false })
807
-
808
- // Validate token first
809
- let botInfo: { username: string } | null = null
810
- try {
811
- botInfo = await validateBotToken(botToken)
812
- } catch (err) {
813
- const updatedBack = advanceSetupState(updated, { step: 'asked-bot-token' })
814
- setSetupState(updatedBack)
815
- await switchroomReply(ctx, [
816
- `Token rejected by Telegram — ${escapeHtmlForTg((err as Error).message)}`,
817
- '',
818
- 'Please get a fresh token from @BotFather and paste it here:',
819
- ].join('\n'), { html: true })
820
- return
821
- }
822
-
823
- const botUsername = botInfo?.username ?? null
824
- await switchroomReply(ctx, `Token OK (@${escapeHtmlForTg(botUsername ?? '?')}). Provisioning agent <b>${escapeHtmlForTg(slug)}</b>…`, { html: true })
825
-
826
- try {
827
- const result = await createAgent({
828
- name: slug,
829
- profile,
830
- telegramBotToken: botToken,
831
- rollbackOnFail: true,
832
- })
833
-
834
- if (result.loginUrl) {
835
- // #189: in-wizard OAuth path. Stash the auth session + URL on the
836
- // wizard state and ask the user to paste the code in chat.
837
- const oauthState = advanceSetupState(updated, {
838
- step: 'asked-oauth-code',
839
- authSessionName: result.sessionName,
840
- loginUrl: result.loginUrl,
841
- })
842
- setSetupState(oauthState)
843
- const kb = new InlineKeyboard().url('Open OAuth URL', result.loginUrl)
844
- await ctx.reply(
845
- [
846
- `<b>${escapeHtmlForTg(persona)}</b> (@${escapeHtmlForTg(botUsername ?? slug)}) is scaffolded!`,
847
- '',
848
- '<b>Step 6 of 6 — OAuth:</b>',
849
- 'Open the URL below, log in to Claude, then paste the code back here.',
850
- ].join('\n'),
851
- { parse_mode: 'HTML', reply_markup: kb },
852
- )
853
- } else {
854
- // No loginUrl returned — agent doesn't need OAuth (rare). Mark done.
855
- const doneState = advanceSetupState(updated, { step: 'done' })
856
- setSetupState(doneState)
857
- clearSetupState(chatId)
858
- await switchroomReply(ctx, [
859
- `<b>${escapeHtmlForTg(persona)}</b> (@${escapeHtmlForTg(botUsername ?? slug)}) is scaffolded and ready!`,
860
- '',
861
- '<i>Skills can be added later via yaml or future /skills command.</i>',
862
- ].join('\n'), { html: true })
863
- }
864
- } catch (err) {
865
- // Rollback happened inside createAgent — reset to bot-token step
866
- const updatedBack = advanceSetupState(updated, { step: 'asked-bot-token' })
867
- setSetupState(updatedBack)
868
- await switchroomReply(ctx, [
869
- `<b>Provisioning failed:</b> ${escapeHtmlForTg((err as Error).message)}`,
870
- '',
871
- 'To retry, paste a bot token again. Or type <code>cancel</code> to abort.',
872
- ].join('\n'), { html: true })
873
- }
874
- return
875
- }
876
-
877
- // ── ask-oauth-code is currently dispatched only by call-create-agent
878
- // above (which goes straight to the InlineKeyboard render before
879
- // setting state). The handler below covers any future path that
880
- // emits it as a discrete action — currently dead-code-style
881
- // safety, but keeping the case keeps the union check exhaustive.
882
- case 'ask-oauth-code': {
883
- const updated = advanceSetupState(setupState, {
884
- step: 'asked-oauth-code',
885
- loginUrl: action.loginUrl,
886
- })
887
- setSetupState(updated)
888
- const promptLines = action.loginUrl
889
- ? ['Open this URL to log in, then paste the code back here:']
890
- : ['Auth session started. Paste the OAuth code back here:']
891
- if (action.loginUrl) {
892
- const kb = new InlineKeyboard().url('Open OAuth URL', action.loginUrl)
893
- await ctx.reply(promptLines.join('\n'), { reply_markup: kb })
894
- } else {
895
- await switchroomReply(ctx, promptLines.join('\n'), { html: false })
896
- }
897
- return
898
- }
899
-
900
- // ── OAuth-code paste step → run completeCreation + start the agent ────
901
- case 'call-complete-creation': {
902
- const { slug, persona, code } = action
903
- await switchroomReply(ctx, 'Submitting OAuth code…', { html: false })
904
- try {
905
- const result = await completeCreation(slug, code)
906
- if (result.outcome.kind === 'success' && result.started) {
907
- const doneState = advanceSetupState(setupState, { step: 'done' })
908
- setSetupState(doneState)
909
- clearSetupState(chatId)
910
- await switchroomReply(ctx, [
911
- `<b>${escapeHtmlForTg(persona)}</b> is online! DM its bot to say hi.`,
912
- '',
913
- '<i>Skills can be added later via yaml or future /skills command.</i>',
914
- ].join('\n'), { html: true })
915
- } else if (result.outcome.kind === 'success') {
916
- const doneState = advanceSetupState(setupState, { step: 'done' })
917
- setSetupState(doneState)
918
- clearSetupState(chatId)
919
- await switchroomReply(ctx, [
920
- `Auth succeeded but agent start failed.`,
921
- '',
922
- `Try: <code>switchroom agent start ${escapeHtmlForTg(slug)}</code>`,
923
- ].join('\n'), { html: true })
924
- } else {
925
- // Bad code — stay in asked-oauth-code so the user can paste again.
926
- await switchroomReply(
927
- ctx,
928
- `Code rejected (${result.outcome.kind}). Paste the code again, or type <code>cancel</code> to abort:`,
929
- { html: true },
930
- )
931
- }
932
- } catch (err) {
933
- await switchroomReply(ctx, [
934
- `<b>completeCreation failed:</b> ${escapeHtmlForTg((err as Error).message)}`,
935
- '',
936
- 'Type <code>cancel</code> to abort, or paste the code again to retry:',
937
- ].join('\n'), { html: true })
938
- }
939
- return
940
- }
941
-
942
- // ── Error (validation failure, stayInStep re-prompt) ──────────────────
943
- case 'error': {
944
- if (!action.stayInStep) {
945
- clearSetupState(chatId)
946
- }
947
- await switchroomReply(ctx, action.message, { html: true })
948
- return
949
- }
950
-
951
- // ── Cancel ─────────────────────────────────────────────────────────────
952
- case 'cancel': {
953
- clearSetupState(chatId)
954
- if (action.reason === 'user-cancelled') {
955
- await switchroomReply(ctx, 'Setup wizard cancelled. Type /setup to start over.', { html: false })
956
- } else {
957
- await switchroomReply(ctx, `Setup wizard stopped (${action.reason}). Type /setup to start over.`, { html: false })
958
- }
959
- return
960
- }
961
-
962
- // ── Done (shouldn't reach here via text) ──────────────────────────────
963
- case 'done': {
964
- clearSetupState(chatId)
965
- await switchroomReply(ctx, 'Setup already complete. Type /setup to create another agent.', { html: false })
966
- return
967
- }
968
- }
969
- }
970
-
971
- async function handleCreateFlowText(
972
- ctx: Context,
973
- chatId: string,
974
- text: string,
975
- flowState: NonNullable<ReturnType<typeof getState>>,
976
- ): Promise<void> {
977
- const profiles = listAvailableProfiles()
978
- const action = handleFlowText({ state: flowState, text, profiles })
979
-
980
- switch (action.kind) {
981
- case 'ask-name':
982
- await switchroomReply(ctx, 'What should the new agent be named?', { html: false })
983
- return
984
-
985
- case 'ask-profile': {
986
- // Update name in state if we just got it
987
- const updatedName = text.trim()
988
- const newState = advanceState(flowState, { step: 'asked-profile', name: updatedName })
989
- setState(newState)
990
- const kb = new InlineKeyboard()
991
- for (const p of profiles) {
992
- kb.text(p, `cf:profile:${p}`).row()
993
- }
994
- await ctx.reply(
995
- `Choose a profile for <b>${escapeHtmlForTg(updatedName)}</b>:`,
996
- { parse_mode: 'HTML', reply_markup: kb },
997
- )
998
- return
999
- }
1000
-
1001
- case 'ask-bot-token': {
1002
- const newState = advanceState(flowState, { step: 'asked-bot-token', profile: action.profile })
1003
- setState(newState)
1004
- await switchroomReply(ctx, `Profile <b>${escapeHtmlForTg(action.profile)}</b> selected.\n\nPaste the BotFather token for <b>${escapeHtmlForTg(action.name)}</b>'s Telegram bot:\n<i>(Note: this token will be visible in this chat)</i>`, { html: true })
1005
- return
1006
- }
1007
-
1008
- case 'call-create-agent': {
1009
- const { name, profile, botToken } = action
1010
- // Pre-#28 fix this called validateBotToken here AND createAgent
1011
- // (via validateBotTokenMatchesAgent at create-orchestrator.ts:150)
1012
- // would call it again — two sequential Telegram getMe() requests in
1013
- // the happy path. We now trust the orchestrator's check and surface
1014
- // its error if it fails. The /setup flow at line 723 keeps its own
1015
- // pre-check because it uses the returned botInfo.username for UX.
1016
- await switchroomReply(ctx, `Creating agent <b>${escapeHtmlForTg(name)}</b>…`, { html: true })
1017
- try {
1018
- const result = await createAgent({
1019
- name,
1020
- profile,
1021
- telegramBotToken: botToken,
1022
- // Clean up scaffold/systemd/yaml on mid-flow failure so the user
1023
- // can retry /create-agent with the same name without conflicts.
1024
- rollbackOnFail: true,
1025
- })
1026
- const newState = advanceState(flowState, {
1027
- step: 'asked-oauth-code',
1028
- name,
1029
- profile,
1030
- botToken,
1031
- authSessionName: result.sessionName,
1032
- loginUrl: result.loginUrl ?? null,
1033
- })
1034
- setState(newState)
1035
-
1036
- if (result.loginUrl) {
1037
- const kb = new InlineKeyboard().url('Open OAuth URL', result.loginUrl)
1038
- await ctx.reply(
1039
- 'Open this URL to log in, then paste the code back here:',
1040
- { reply_markup: kb },
1041
- )
1042
- } else {
1043
- await switchroomReply(ctx, 'Auth session started. Paste the OAuth code back here:', { html: false })
1044
- }
1045
- } catch (err) {
1046
- await switchroomReply(ctx, `<b>createAgent failed:</b> ${escapeHtmlForTg((err as Error).message)}`, { html: true })
1047
- clearState(chatId)
1048
- }
1049
- return
1050
- }
1051
-
1052
- case 'call-complete-creation': {
1053
- const { name, code } = action
1054
- await switchroomReply(ctx, 'Submitting OAuth code…', { html: false })
1055
- try {
1056
- const result = await completeCreation(name, code)
1057
- if (result.outcome.kind === 'success' && result.started) {
1058
- clearState(chatId)
1059
- await switchroomReply(ctx, `<b>${escapeHtmlForTg(name)}</b> is online! DM its bot to say hi.`, { html: true })
1060
- } else if (result.outcome.kind === 'success') {
1061
- clearState(chatId)
1062
- await switchroomReply(ctx, `Auth succeeded but agent start failed. Try: <code>switchroom agent start ${escapeHtmlForTg(name)}</code>`, { html: true })
1063
- } else {
1064
- // Bad code — stay in asked-oauth-code step
1065
- await switchroomReply(ctx, `Code rejected (${result.outcome.kind}). Paste the code again, or use /create-agent to restart:`, { html: false })
1066
- }
1067
- } catch (err) {
1068
- await switchroomReply(ctx, `<b>completeCreation failed:</b> ${escapeHtmlForTg((err as Error).message)}`, { html: true })
1069
- clearState(chatId)
1070
- }
1071
- return
1072
- }
1073
-
1074
- case 'error': {
1075
- await switchroomReply(ctx, action.message, { html: true })
1076
- if (!action.stayInStep) {
1077
- clearState(chatId)
1078
- }
1079
- return
1080
- }
1081
-
1082
- case 'cancel':
1083
- case 'done':
1084
- // No active flow — fall through to unknown command
1085
- await switchroomReply(ctx, 'Unknown command. Try /help.', { html: true })
1086
- return
1087
- }
1088
- }
1089
-
1090
- // ─── Startup ──────────────────────────────────────────────────────────────
1091
- process.on('unhandledRejection', err => {
1092
- process.stderr.write(`foreman: unhandled rejection: ${err}\n`)
1093
- })
1094
- process.on('uncaughtException', err => {
1095
- process.stderr.write(`foreman: uncaught exception: ${err}\n`)
1096
- })
1097
-
1098
- void runPollingLoop(bot, {
1099
- onReady: (username) => {
1100
- process.stderr.write(`foreman: ready as @${username}\n`)
1101
- },
1102
- onOneTimeSetup: async (username) => {
1103
- process.stderr.write(`foreman: one-time setup done @${username}\n`)
1104
- // Register bot commands so they show in the Telegram UI
1105
- try {
1106
- await bot.api.setMyCommands([
1107
- { command: 'start', description: 'Start / intro' },
1108
- { command: 'help', description: 'Command list' },
1109
- { command: 'status', description: 'Fleet status' },
1110
- { command: 'list', description: 'Fleet status (alias)' },
1111
- { command: 'logs', description: 'Agent logs: /logs <agent> [--tail N]' },
1112
- { command: 'auth', description: 'Auth dashboard: /auth [agent]' },
1113
- { command: 'restart', description: 'Restart agent: /restart <agent>' },
1114
- { command: 'delete', description: 'Delete agent (with confirm): /delete <agent>' },
1115
- { command: 'update', description: 'Update switchroom' },
1116
- { command: 'version', description: 'Show versions + running agent health' },
1117
- { command: 'worktrees', description: 'List active git worktrees claimed by sub-agents' },
1118
- { command: 'create_agent', description: 'Create new agent: /create-agent [name]' },
1119
- { command: 'setup', description: 'New agent wizard: /setup [slug]' },
1120
- { command: 'cancel', description: 'Cancel active wizard' },
1121
- ])
1122
- } catch (err) {
1123
- process.stderr.write(`foreman: setMyCommands failed: ${err}\n`)
1124
- }
1125
-
1126
- // Resume any in-progress setup wizard flows that survived a restart
1127
- try {
1128
- const activeSetupFlows = listActiveSetupFlows(60 * 60 * 1000) // 1 hour
1129
- for (const flow of activeSetupFlows) {
1130
- try {
1131
- await bot.api.sendMessage(
1132
- flow.chatId,
1133
- `Picking up /setup wizard for <b>${escapeHtmlForTg(flow.slug ?? '?')}</b> (${setupStepLabel(flow.step)})…\n\nType your response to continue, or /cancel to abort.`,
1134
- { parse_mode: 'HTML' },
1135
- )
1136
- } catch (err) {
1137
- process.stderr.write(`foreman: failed to resume setup flow for chat ${flow.chatId}: ${err}\n`)
1138
- }
1139
- }
1140
- } catch (err) {
1141
- process.stderr.write(`foreman: failed to list active setup flows: ${err}\n`)
1142
- }
1143
-
1144
- // Resume any in-progress create-agent flows that survived a restart
1145
- try {
1146
- const activeFlows = listActiveFlows(60 * 60 * 1000) // 1 hour
1147
- for (const flow of activeFlows) {
1148
- try {
1149
- await bot.api.sendMessage(
1150
- flow.chatId,
1151
- `Picking up create-agent flow for <b>${escapeHtmlForTg(flow.name ?? '?')}</b> (${stepLabel(flow.step)})…\n\nType your response to continue, or /create-agent to restart.`,
1152
- { parse_mode: 'HTML' },
1153
- )
1154
- } catch (err) {
1155
- process.stderr.write(`foreman: failed to resume flow for chat ${flow.chatId}: ${err}\n`)
1156
- }
1157
- }
1158
- } catch (err) {
1159
- process.stderr.write(`foreman: failed to list active flows: ${err}\n`)
1160
- }
1161
- },
1162
- on409: (attempt, delayMs) => {
1163
- process.stderr.write(`foreman: 409 Conflict attempt=${attempt} retry_in_ms=${delayMs}\n`)
1164
- },
1165
- })