switchroom 0.8.1 → 0.10.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 (105) hide show
  1. package/README.md +49 -57
  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/switchroom.js +15931 -12778
  6. package/dist/host-control/main.js +582 -43
  7. package/dist/vault/approvals/kernel-server.js +276 -47
  8. package/dist/vault/broker/server.js +333 -69
  9. package/examples/minimal.yaml +63 -0
  10. package/examples/personal-google-workspace-mcp/.env.example +34 -0
  11. package/examples/personal-google-workspace-mcp/README.md +194 -0
  12. package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
  13. package/examples/switchroom.yaml +220 -0
  14. package/package.json +6 -4
  15. package/profiles/_base/start.sh.hbs +3 -3
  16. package/profiles/_shared/agent-self-service.md.hbs +126 -0
  17. package/profiles/default/CLAUDE.md +10 -0
  18. package/profiles/default/CLAUDE.md.hbs +16 -0
  19. package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
  20. package/skills/buildkite-agent-runtime/SKILL.md +44 -11
  21. package/skills/buildkite-api/SKILL.md +31 -8
  22. package/skills/buildkite-cli/SKILL.md +27 -9
  23. package/skills/buildkite-migration/SKILL.md +22 -9
  24. package/skills/buildkite-pipelines/SKILL.md +26 -9
  25. package/skills/buildkite-secure-delivery/SKILL.md +23 -9
  26. package/skills/buildkite-test-engine/SKILL.md +25 -8
  27. package/skills/docx/SKILL.md +1 -1
  28. package/skills/file-bug/SKILL.md +34 -6
  29. package/skills/humanizer/SKILL.md +15 -0
  30. package/skills/humanizer-calibrate/SKILL.md +7 -1
  31. package/skills/mcp-builder/SKILL.md +1 -1
  32. package/skills/pdf/SKILL.md +1 -1
  33. package/skills/pptx/SKILL.md +1 -1
  34. package/skills/skill-creator/SKILL.md +21 -1
  35. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  36. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  37. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  38. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  39. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  40. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  41. package/skills/switchroom-cli/SKILL.md +63 -64
  42. package/skills/switchroom-health/SKILL.md +23 -10
  43. package/skills/switchroom-install/SKILL.md +3 -3
  44. package/skills/switchroom-manage/SKILL.md +26 -19
  45. package/skills/switchroom-runtime/SKILL.md +67 -15
  46. package/skills/switchroom-status/SKILL.md +26 -1
  47. package/skills/telegram-test-harness/SKILL.md +3 -0
  48. package/skills/webapp-testing/SKILL.md +31 -1
  49. package/skills/xlsx/SKILL.md +1 -1
  50. package/telegram-plugin/admin-commands/index.ts +7 -5
  51. package/telegram-plugin/dist/gateway/gateway.js +13042 -12844
  52. package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
  53. package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
  54. package/telegram-plugin/gateway/auth-command.ts +794 -0
  55. package/telegram-plugin/gateway/auth-line.ts +123 -0
  56. package/telegram-plugin/gateway/boot-card.ts +22 -36
  57. package/telegram-plugin/gateway/boot-probes.ts +3 -3
  58. package/telegram-plugin/gateway/gateway.ts +313 -798
  59. package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
  60. package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
  61. package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
  62. package/telegram-plugin/permission-title.ts +56 -0
  63. package/telegram-plugin/quota-check.ts +19 -41
  64. package/telegram-plugin/scripts/build.mjs +0 -1
  65. package/telegram-plugin/shared/bot-runtime.ts +5 -4
  66. package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
  67. package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
  68. package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
  69. package/telegram-plugin/tests/boot-probes.test.ts +11 -4
  70. package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
  71. package/telegram-plugin/tests/permission-title.test.ts +31 -0
  72. package/telegram-plugin/tests/quota-check.test.ts +5 -35
  73. package/telegram-plugin/uat/SETUP.md +31 -1
  74. package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
  75. package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
  76. package/telegram-plugin/uat/runners/report.ts +150 -0
  77. package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
  78. package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
  79. package/telegram-plugin/uat/runners/scorer.ts +106 -0
  80. package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
  81. package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
  82. package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +7 -1
  83. package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +7 -1
  84. package/telegram-plugin/auth-dashboard.ts +0 -1104
  85. package/telegram-plugin/auth-slot-parser.ts +0 -497
  86. package/telegram-plugin/dist/foreman/foreman.js +0 -31358
  87. package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
  88. package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
  89. package/telegram-plugin/foreman/foreman.ts +0 -1165
  90. package/telegram-plugin/foreman/setup-flow.ts +0 -345
  91. package/telegram-plugin/foreman/setup-state.ts +0 -239
  92. package/telegram-plugin/foreman/state.ts +0 -203
  93. package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
  94. package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
  95. package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
  96. package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
  97. package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
  98. package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
  99. package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
  100. package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
  101. package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
  102. package/telegram-plugin/tests/foreman-state.test.ts +0 -164
  103. package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
  104. package/telegram-plugin/tests/setup-flow.test.ts +0 -510
  105. package/telegram-plugin/tests/setup-state.test.ts +0 -146
@@ -1,202 +0,0 @@
1
- /**
2
- * Pure create-agent flow state machine — extracted from foreman.ts for
3
- * testability. No grammY imports, no SQLite imports, no side effects.
4
- *
5
- * Each function takes current state + input and returns an Action
6
- * (what the foreman should do next). foreman.ts interprets actions
7
- * by calling the actual SQLite / grammY / orchestrator APIs.
8
- *
9
- * Steps:
10
- * start → asked-name (when no name given)
11
- * → asked-profile (when name provided inline)
12
- * asked-name + text → asked-profile (if valid name)
13
- * asked-profile + text → asked-bot-token (if valid profile)
14
- * asked-bot-token + text → asked-oauth-code (after createAgent())
15
- * asked-oauth-code + text → done (after completeCreation())
16
- */
17
-
18
- import type { CreateFlowState, CreateFlowStep } from './state.js'
19
-
20
- // ─── Action types ────────────────────────────────────────────────────────
21
-
22
- export type CreateFlowAction =
23
- | { kind: 'ask-name' }
24
- | { kind: 'ask-profile'; profiles: string[] }
25
- | { kind: 'ask-bot-token'; name: string; profile: string }
26
- | { kind: 'call-create-agent'; name: string; profile: string; botToken: string }
27
- | { kind: 'ask-oauth-code'; loginUrl: string; name: string }
28
- | { kind: 'call-complete-creation'; name: string; code: string }
29
- | { kind: 'done'; name: string; botUsername: string | null }
30
- | { kind: 'error'; message: string; stayInStep: boolean }
31
- | { kind: 'cancel'; reason: string }
32
-
33
- // ─── Name validation (mirrors assertSafeAgentName) ───────────────────────
34
-
35
- export function isValidAgentName(name: string): boolean {
36
- return /^[a-z0-9][a-z0-9_-]{0,50}$/.test(name)
37
- }
38
-
39
- // ─── Flow entry point ────────────────────────────────────────────────────
40
-
41
- /**
42
- * Start or resume a /create-agent flow.
43
- *
44
- * @param inlineName Optional name from the command args (/create-agent gymbro)
45
- * @param profiles Available profile names (from listAvailableProfiles())
46
- * @returns Action to perform
47
- */
48
- export function startCreateFlow(
49
- inlineName: string | null,
50
- profiles: string[],
51
- ): CreateFlowAction {
52
- if (!inlineName) {
53
- return { kind: 'ask-name' }
54
- }
55
-
56
- if (!isValidAgentName(inlineName)) {
57
- return {
58
- kind: 'error',
59
- message: `"${inlineName}" is not a valid agent name. Names must be lowercase, alphanumeric, hyphens or underscores, max 51 chars.`,
60
- stayInStep: false,
61
- }
62
- }
63
-
64
- return { kind: 'ask-profile', profiles }
65
- }
66
-
67
- // ─── Step transition: handle inbound text for current step ───────────────
68
-
69
- export interface StepTransitionInput {
70
- /** Current persisted state (or null if no state yet). */
71
- state: CreateFlowState | null
72
- /** The text the user sent. */
73
- text: string
74
- /** Available profiles (for profile validation). */
75
- profiles: string[]
76
- }
77
-
78
- /**
79
- * Given the current state and user text, compute the next action.
80
- * The caller (foreman.ts) is responsible for persisting state changes
81
- * and executing the returned action.
82
- */
83
- export function handleFlowText(input: StepTransitionInput): CreateFlowAction {
84
- const { state, text, profiles } = input
85
- const trimmed = text.trim()
86
-
87
- if (!state) {
88
- // No active flow — ignore
89
- return { kind: 'cancel', reason: 'no-active-flow' }
90
- }
91
-
92
- switch (state.step) {
93
- case 'asked-name': {
94
- if (!isValidAgentName(trimmed)) {
95
- return {
96
- kind: 'error',
97
- message: `"${trimmed}" is not a valid agent name. Names must be lowercase, alphanumeric, hyphens or underscores, max 51 chars. Try again:`,
98
- stayInStep: true,
99
- }
100
- }
101
- return { kind: 'ask-profile', profiles }
102
- }
103
-
104
- case 'asked-profile': {
105
- if (!profiles.includes(trimmed)) {
106
- return {
107
- kind: 'error',
108
- message: `Unknown profile "${trimmed}". Choose one of: ${profiles.join(', ')}`,
109
- stayInStep: true,
110
- }
111
- }
112
- // Pre-#28 fix this fell back to `trimmed` (the profile name)
113
- // when state.name was missing — silently treating the profile
114
- // as the agent name. Now we cancel with missing-name instead,
115
- // matching the asked-bot-token step's behaviour on corrupt
116
- // state. The fallback wasn't exploitable (assertSafeAgentName
117
- // catches it downstream), but it's semantically wrong.
118
- if (!state.name) {
119
- return { kind: 'cancel', reason: 'missing-name' }
120
- }
121
- return { kind: 'ask-bot-token', name: state.name, profile: trimmed }
122
- }
123
-
124
- case 'asked-bot-token': {
125
- const name = state.name ?? ''
126
- const profile = state.profile ?? ''
127
- if (!name || !profile) {
128
- return { kind: 'cancel', reason: 'missing-name-or-profile' }
129
- }
130
- // Basic bot token shape check (foreman.ts validates via Telegram API)
131
- if (!trimmed.includes(':') || trimmed.length < 20) {
132
- return {
133
- kind: 'error',
134
- message: "That doesn't look like a BotFather token. It should be in the form <code>1234567890:AAH...</code> — try again:",
135
- stayInStep: true,
136
- }
137
- }
138
- return { kind: 'call-create-agent', name, profile, botToken: trimmed }
139
- }
140
-
141
- case 'asked-oauth-code': {
142
- const name = state.name ?? ''
143
- if (!name) return { kind: 'cancel', reason: 'missing-name' }
144
- // Codes are typically 8+ alphanumeric chars; pass through for server validation
145
- if (trimmed.length < 4) {
146
- return {
147
- kind: 'error',
148
- message: 'That code looks too short. Paste the full code from the browser:',
149
- stayInStep: true,
150
- }
151
- }
152
- return { kind: 'call-complete-creation', name, code: trimmed }
153
- }
154
-
155
- case 'done':
156
- return { kind: 'cancel', reason: 'flow-already-done' }
157
-
158
- default: {
159
- const _exhaustive: never = state.step
160
- return { kind: 'cancel', reason: `unknown-step:${_exhaustive}` }
161
- }
162
- }
163
- }
164
-
165
- // ─── State factory helpers (for foreman.ts to build new state objects) ───
166
-
167
- export function makeInitialState(chatId: string, name: string | null): CreateFlowState {
168
- const now = Date.now()
169
- return {
170
- chatId,
171
- step: name ? 'asked-profile' : 'asked-name',
172
- name,
173
- profile: null,
174
- botToken: null,
175
- authSessionName: null,
176
- loginUrl: null,
177
- startedAt: now,
178
- updatedAt: now,
179
- }
180
- }
181
-
182
- export function advanceState(
183
- state: CreateFlowState,
184
- updates: Partial<Omit<CreateFlowState, 'chatId' | 'startedAt'>>,
185
- ): CreateFlowState {
186
- return {
187
- ...state,
188
- ...updates,
189
- updatedAt: Date.now(),
190
- }
191
- }
192
-
193
- /** Compute the human-readable step label for recovery messages. */
194
- export function stepLabel(step: CreateFlowStep): string {
195
- switch (step) {
196
- case 'asked-name': return 'waiting for agent name'
197
- case 'asked-profile': return 'waiting for profile selection'
198
- case 'asked-bot-token': return 'waiting for BotFather token'
199
- case 'asked-oauth-code': return 'waiting for OAuth code'
200
- case 'done': return 'done'
201
- }
202
- }
@@ -1,493 +0,0 @@
1
- /**
2
- * Pure handler logic extracted from foreman.ts for testability.
3
- *
4
- * foreman.ts has process-level side effects (reads .env, connects Bot,
5
- * starts polling) that prevent direct import in tests. This module
6
- * exports the command handler implementations and their helpers so that
7
- * tests can exercise real code with mocked bot + ctx, rather than
8
- * re-implementing the logic locally.
9
- */
10
-
11
- import { execFileSync } from 'child_process'
12
- import { renameSync, existsSync } from 'fs'
13
- import { homedir } from 'os'
14
- import { join, resolve } from 'path'
15
- import {
16
- escapeHtmlForTg,
17
- preBlock,
18
- stripAnsi,
19
- formatSwitchroomOutput,
20
- } from '../shared/bot-runtime.js'
21
-
22
- // ─── Types ────────────────────────────────────────────────────────────────
23
-
24
- export type SwitchroomExecFn = (args: string[]) => string
25
- export type SwitchroomExecJsonFn = <T = unknown>(args: string[]) => T | null
26
-
27
- // ─── Agent name validation ────────────────────────────────────────────────
28
-
29
- /**
30
- * Throw if the agent name is not safe for use in journalctl unit names.
31
- * Mirrors AGENT_NAME_RE in src/agents/create-orchestrator.ts and the yaml
32
- * schema in src/config/schema.ts — all three MUST stay in sync. Max 51
33
- * chars (see operator-events.ts callback_data contract).
34
- */
35
- export function assertSafeAgentName(name: string): void {
36
- if (!/^[a-z0-9][a-z0-9_-]{0,50}$/.test(name)) {
37
- throw new Error(`invalid agent name: ${name}`)
38
- }
39
- }
40
-
41
- // ─── Tail-N parsing ───────────────────────────────────────────────────────
42
-
43
- export function parseTailN(args: string[]): number {
44
- let tailN = 50
45
- const tailIdx = args.indexOf('--tail')
46
- if (tailIdx !== -1 && args[tailIdx + 1]) {
47
- const parsed = parseInt(args[tailIdx + 1], 10)
48
- if (!isNaN(parsed) && parsed > 0) tailN = Math.min(parsed, 500)
49
- }
50
- return tailN
51
- }
52
-
53
- // ─── Text chunking ────────────────────────────────────────────────────────
54
-
55
- export function chunkText(text: string, maxLen = 4096): string[] {
56
- if (text.length <= maxLen) return [text]
57
- const chunks: string[] = []
58
- let pos = 0
59
- while (pos < text.length) {
60
- chunks.push(text.slice(pos, pos + maxLen))
61
- pos += maxLen
62
- }
63
- return chunks
64
- }
65
-
66
- // ─── /status handler impl ─────────────────────────────────────────────────
67
-
68
- export type AgentListEntry = {
69
- name: string
70
- status: string
71
- uptime: string
72
- template?: string | null
73
- topic_name?: string | null
74
- }
75
-
76
- export function statusIcon(status: string): string {
77
- if (status === 'active' || status === 'running') return '🟢'
78
- if (status === 'inactive' || status === 'stopped' || status === 'dead') return '🔴'
79
- if (status === 'failed') return '⚠️'
80
- return '⚪'
81
- }
82
-
83
- export function buildFleetSummary(
84
- switchroomExecJson: SwitchroomExecJsonFn,
85
- ): string {
86
- try {
87
- const data = switchroomExecJson<{ agents: AgentListEntry[] }>(['agent', 'list'])
88
- if (!data || data.agents.length === 0) return '<i>No agents defined</i>'
89
- const lines = ['<b>Fleet status</b>']
90
- for (const a of data.agents) {
91
- lines.push(
92
- `${statusIcon(a.status)} <b>${escapeHtmlForTg(a.name)}</b> · ${escapeHtmlForTg(a.status)} · ${escapeHtmlForTg(a.uptime)}`,
93
- )
94
- if (a.template || a.topic_name) {
95
- const meta = [a.template, a.topic_name]
96
- .filter(Boolean)
97
- .map((s) => escapeHtmlForTg(s!))
98
- .join(' → ')
99
- lines.push(` <i>${meta}</i>`)
100
- }
101
- }
102
- return lines.join('\n')
103
- } catch (err) {
104
- return `<b>agent list failed:</b>\n${preBlock(formatSwitchroomOutput((err as Error).message))}`
105
- }
106
- }
107
-
108
- // ─── /logs handler impl ───────────────────────────────────────────────────
109
-
110
- export const LOG_PAGE_BYTES = 3 * 1024 // 3 KB
111
-
112
- export interface LogsResult {
113
- /** One or more reply strings. Send them in order. */
114
- replies: Array<{ text: string; html: boolean }>
115
- }
116
-
117
- /**
118
- * Core /logs implementation — returns the reply payloads rather than
119
- * sending them directly, so the caller (foreman.ts) can use its own
120
- * switchroomReply and tests can inspect the output.
121
- *
122
- * @param match The text after "/logs " from ctx.match
123
- * @param execFile Injected execFileSync for testability
124
- */
125
- export function handleLogsCommand(
126
- match: string,
127
- execFile: typeof execFileSync = execFileSync,
128
- ): LogsResult {
129
- const args = match.trim().split(/\s+/).filter(Boolean)
130
-
131
- if (args.length === 0) {
132
- return { replies: [{ text: 'Usage: /logs &lt;agent&gt; [--tail N]', html: true }] }
133
- }
134
-
135
- const agentName = args[0]
136
- try {
137
- assertSafeAgentName(agentName)
138
- } catch {
139
- return { replies: [{ text: 'Invalid agent name.', html: true }] }
140
- }
141
-
142
- const tailN = parseTailN(args)
143
-
144
- let output: string
145
- try {
146
- output = stripAnsi(
147
- execFile(
148
- 'journalctl',
149
- [
150
- '--user',
151
- '-u',
152
- `switchroom-${agentName}`,
153
- '-n',
154
- String(tailN),
155
- '--no-pager',
156
- '--output=short-monotonic',
157
- ],
158
- {
159
- encoding: 'utf-8',
160
- timeout: 10000,
161
- env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
162
- stdio: ['ignore', 'pipe', 'pipe'],
163
- },
164
- ) as string,
165
- )
166
- } catch (err) {
167
- const msg = err as { stdout?: string; stderr?: string; message?: string }
168
- const detail = msg.stdout || msg.stderr || msg.message || 'unknown error'
169
- return {
170
- replies: [
171
- {
172
- text: `<b>logs failed for ${escapeHtmlForTg(agentName)}:</b>\n${preBlock(formatSwitchroomOutput(stripAnsi(detail)))}`,
173
- html: true,
174
- },
175
- ],
176
- }
177
- }
178
-
179
- const trimmed = output.trim()
180
- if (!trimmed) {
181
- return {
182
- replies: [
183
- {
184
- text: `No logs found for <code>${escapeHtmlForTg(agentName)}</code>.`,
185
- html: true,
186
- },
187
- ],
188
- }
189
- }
190
-
191
- if (Buffer.byteLength(trimmed, 'utf8') > LOG_PAGE_BYTES) {
192
- const chunks = chunkText(trimmed, 3800)
193
- return {
194
- replies: chunks.map((chunk, i) => {
195
- const label = chunks.length > 1 ? ` (${i + 1}/${chunks.length})` : ''
196
- return {
197
- text: preBlock(chunk) + (label ? `\n<i>${label}</i>` : ''),
198
- html: true,
199
- }
200
- }),
201
- }
202
- }
203
-
204
- return { replies: [{ text: preBlock(trimmed), html: true }] }
205
- }
206
-
207
- // ─── /restart handler impl ────────────────────────────────────────────────
208
-
209
- export interface RestartResult {
210
- ok: boolean
211
- text: string
212
- html: boolean
213
- }
214
-
215
- /**
216
- * Core /restart implementation.
217
- *
218
- * Shells out to `systemctl --user restart switchroom-<agent>` via execFileSync
219
- * (no shell, so agent name is safely passed as an arg — no injection risk).
220
- *
221
- * @param match Text after "/restart " from ctx.match
222
- * @param execFile Injected execFileSync for testability
223
- */
224
- export function handleRestartCommand(
225
- match: string,
226
- execFile: typeof execFileSync = execFileSync,
227
- ): RestartResult {
228
- const agentName = match.trim().split(/\s+/)[0] ?? ''
229
-
230
- if (!agentName) {
231
- return {
232
- ok: false,
233
- text: 'Usage: /restart &lt;agent&gt;',
234
- html: true,
235
- }
236
- }
237
-
238
- try {
239
- assertSafeAgentName(agentName)
240
- } catch {
241
- return { ok: false, text: 'Invalid agent name.', html: true }
242
- }
243
-
244
- try {
245
- execFile(
246
- 'systemctl',
247
- ['--user', 'restart', `switchroom-${agentName}`],
248
- {
249
- encoding: 'utf-8',
250
- timeout: 15000,
251
- stdio: ['ignore', 'pipe', 'pipe'],
252
- },
253
- )
254
- return {
255
- ok: true,
256
- text: `Restarted <code>switchroom-${escapeHtmlForTg(agentName)}</code>.`,
257
- html: true,
258
- }
259
- } catch (err) {
260
- const msg = err as { stderr?: string; stdout?: string; message?: string }
261
- const detail = stripAnsi(msg.stderr || msg.stdout || msg.message || 'unknown error').trim()
262
- return {
263
- ok: false,
264
- text: `<b>restart failed for ${escapeHtmlForTg(agentName)}:</b>\n${preBlock(formatSwitchroomOutput(detail))}`,
265
- html: true,
266
- }
267
- }
268
- }
269
-
270
- // ─── /delete (destroy) handler impl ──────────────────────────────────────
271
-
272
- export interface DeleteResult {
273
- replies: Array<{ text: string; html: boolean }>
274
- /** When true, foreman.ts should also send an inline keyboard for confirmation. */
275
- needsConfirm?: boolean
276
- /** Agent name (for the confirmation prompt). */
277
- agentForConfirm?: string
278
- }
279
-
280
- /**
281
- * Resolve the agents directory from environment or default location.
282
- * Exposed for testability.
283
- */
284
- export function resolveAgentsDirForDelete(): string {
285
- const switchroomDir = process.env.SWITCHROOM_AGENTS_DIR
286
- ?? join(homedir(), '.switchroom', 'agents')
287
- return switchroomDir
288
- }
289
-
290
- /**
291
- * Core /delete first-step implementation — returns a confirmation prompt.
292
- *
293
- * The actual deletion is performed by executeDeleteAgent() once the user
294
- * confirms via callback_query or "YES" text.
295
- */
296
- export function handleDeleteCommand(match: string): DeleteResult {
297
- const agentName = match.trim().split(/\s+/)[0] ?? ''
298
-
299
- if (!agentName) {
300
- return {
301
- replies: [{ text: 'Usage: /delete &lt;agent&gt;', html: true }],
302
- }
303
- }
304
-
305
- try {
306
- assertSafeAgentName(agentName)
307
- } catch {
308
- return { replies: [{ text: 'Invalid agent name.', html: true }] }
309
- }
310
-
311
- return {
312
- replies: [
313
- {
314
- text: `Are you sure you want to delete agent <b>${escapeHtmlForTg(agentName)}</b>?\n\nThis will stop and remove the systemd unit and archive the agent directory. Reply <b>YES</b> to confirm.`,
315
- html: true,
316
- },
317
- ],
318
- needsConfirm: true,
319
- agentForConfirm: agentName,
320
- }
321
- }
322
-
323
- /**
324
- * Execute agent deletion after confirmation.
325
- *
326
- * Archives the agent dir to `agents/_archived_<name>_<timestamp>/` before
327
- * running `switchroom agent destroy --yes <name>` so data is recoverable.
328
- *
329
- * @param agentName Validated agent name
330
- * @param switchroomExec Injected CLI exec for testability
331
- * @param execFile Injected execFileSync for testability (systemctl)
332
- * @param agentsDir Override agents dir (for tests)
333
- */
334
- export function executeDeleteAgent(
335
- agentName: string,
336
- switchroomExec: SwitchroomExecFn,
337
- execFile: typeof execFileSync = execFileSync,
338
- agentsDir: string = resolveAgentsDirForDelete(),
339
- ): DeleteResult {
340
- try {
341
- assertSafeAgentName(agentName)
342
- } catch {
343
- return { replies: [{ text: 'Invalid agent name.', html: true }] }
344
- }
345
-
346
- const agentDir = resolve(agentsDir, agentName)
347
- let archivePath: string | null = null
348
-
349
- // Step 1: Archive the dir if it exists
350
- if (existsSync(agentDir)) {
351
- const timestamp = Date.now()
352
- archivePath = resolve(agentsDir, `_archived_${agentName}_${timestamp}`)
353
- try {
354
- renameSync(agentDir, archivePath)
355
- } catch (err) {
356
- return {
357
- replies: [
358
- {
359
- text: `<b>Archive failed for ${escapeHtmlForTg(agentName)}:</b>\n${preBlock(formatSwitchroomOutput((err as Error).message))}`,
360
- html: true,
361
- },
362
- ],
363
- }
364
- }
365
- }
366
-
367
- // Step 2: Stop + remove systemd unit via CLI (--yes skips the interactive prompt)
368
- let cliOutput = ''
369
- let cliOk = true
370
- try {
371
- cliOutput = switchroomExec(['agent', 'destroy', '--yes', agentName])
372
- } catch (err) {
373
- cliOk = false
374
- const msg = err as { stderr?: string; stdout?: string; message?: string }
375
- cliOutput = stripAnsi(msg.stderr || msg.stdout || msg.message || 'unknown error').trim()
376
- }
377
-
378
- const lines: string[] = []
379
-
380
- if (archivePath) {
381
- lines.push(`Archived <code>${escapeHtmlForTg(agentName)}</code> to:`)
382
- lines.push(`<code>${escapeHtmlForTg(archivePath)}</code>`)
383
- lines.push('')
384
- }
385
-
386
- if (cliOk) {
387
- lines.push(`Agent <b>${escapeHtmlForTg(agentName)}</b> deleted.`)
388
- if (cliOutput.trim()) {
389
- lines.push(preBlock(formatSwitchroomOutput(stripAnsi(cliOutput))))
390
- }
391
- } else {
392
- lines.push(`<b>CLI destroy failed</b> (agent dir was archived; systemd unit may still exist):`)
393
- lines.push(preBlock(formatSwitchroomOutput(cliOutput)))
394
- }
395
-
396
- return { replies: [{ text: lines.join('\n'), html: true }] }
397
- }
398
-
399
- // ─── /version handler impl ────────────────────────────────────────────────
400
-
401
- export interface VersionResult {
402
- replies: Array<{ text: string; html: boolean }>
403
- }
404
-
405
- /**
406
- * Core /version implementation.
407
- *
408
- * Shells out to `switchroom version` and posts the output. Mirrors the
409
- * /version handler in server.ts and gateway.ts — kept as a pure function
410
- * here so tests can exercise it without a live bot.
411
- *
412
- * @param switchroomExec Injected CLI exec (combined stdout+stderr) for testability
413
- */
414
- export function handleVersionCommand(
415
- switchroomExec: SwitchroomExecFn,
416
- ): VersionResult {
417
- let output: string
418
- try {
419
- output = switchroomExec(['version'])
420
- } catch (err) {
421
- const msg = err as { stderr?: string; stdout?: string; message?: string }
422
- const detail = stripAnsi(msg.stderr || msg.stdout || msg.message || 'unknown error').trim()
423
- return {
424
- replies: [
425
- {
426
- text: `<b>version failed:</b>\n${preBlock(formatSwitchroomOutput(detail))}`,
427
- html: true,
428
- },
429
- ],
430
- }
431
- }
432
-
433
- const trimmed = stripAnsi(output).trim()
434
- if (!trimmed) {
435
- return { replies: [{ text: 'version: no output.', html: false }] }
436
- }
437
-
438
- return { replies: [{ text: preBlock(trimmed), html: true }] }
439
- }
440
-
441
- // ─── /update handler impl ─────────────────────────────────────────────────
442
-
443
- export interface UpdateResult {
444
- replies: Array<{ text: string; html: boolean }>
445
- }
446
-
447
- /**
448
- * Core /update implementation.
449
- *
450
- * Shells out to `switchroom update` via the CLI exec helper. Output is
451
- * paginated when > 3 KB.
452
- *
453
- * @param switchroomExec Injected CLI exec (combined stdout+stderr) for testability
454
- */
455
- export function handleUpdateCommand(
456
- switchroomExec: SwitchroomExecFn,
457
- ): UpdateResult {
458
- let output: string
459
- try {
460
- output = switchroomExec(['update'])
461
- } catch (err) {
462
- const msg = err as { stderr?: string; stdout?: string; message?: string }
463
- const detail = stripAnsi(msg.stderr || msg.stdout || msg.message || 'unknown error').trim()
464
- return {
465
- replies: [
466
- {
467
- text: `<b>update failed:</b>\n${preBlock(formatSwitchroomOutput(detail))}`,
468
- html: true,
469
- },
470
- ],
471
- }
472
- }
473
-
474
- const trimmed = stripAnsi(output).trim()
475
- if (!trimmed) {
476
- return { replies: [{ text: 'Update complete (no output).', html: false }] }
477
- }
478
-
479
- if (Buffer.byteLength(trimmed, 'utf8') > LOG_PAGE_BYTES) {
480
- const chunks = chunkText(trimmed, 3800)
481
- return {
482
- replies: chunks.map((chunk, i) => {
483
- const label = chunks.length > 1 ? ` (${i + 1}/${chunks.length})` : ''
484
- return {
485
- text: preBlock(chunk) + (label ? `\n<i>${label}</i>` : ''),
486
- html: true,
487
- }
488
- }),
489
- }
490
- }
491
-
492
- return { replies: [{ text: preBlock(trimmed), html: true }] }
493
- }