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.
- package/README.md +54 -61
- package/bin/timezone-hook.sh +9 -7
- package/dist/agent-scheduler/index.js +285 -45
- package/dist/auth-broker/index.js +13932 -0
- package/dist/cli/drive-write-pretool.mjs +5418 -0
- package/dist/cli/switchroom.js +8890 -5560
- package/dist/host-control/main.js +582 -43
- package/dist/vault/approvals/kernel-server.js +276 -47
- package/dist/vault/broker/server.js +333 -69
- package/examples/minimal.yaml +63 -0
- package/examples/personal-google-workspace-mcp/.env.example +34 -0
- package/examples/personal-google-workspace-mcp/README.md +194 -0
- package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
- package/examples/switchroom.yaml +220 -0
- package/package.json +6 -4
- package/profiles/_base/start.sh.hbs +3 -3
- package/profiles/_shared/agent-self-service.md.hbs +126 -0
- package/profiles/default/CLAUDE.md +10 -0
- package/profiles/default/CLAUDE.md.hbs +16 -0
- package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
- package/skills/buildkite-agent-runtime/SKILL.md +44 -11
- package/skills/buildkite-api/SKILL.md +31 -8
- package/skills/buildkite-cli/SKILL.md +27 -9
- package/skills/buildkite-migration/SKILL.md +22 -9
- package/skills/buildkite-pipelines/SKILL.md +26 -9
- package/skills/buildkite-secure-delivery/SKILL.md +23 -9
- package/skills/buildkite-test-engine/SKILL.md +25 -8
- package/skills/docx/SKILL.md +1 -1
- package/skills/file-bug/SKILL.md +34 -6
- package/skills/humanizer/SKILL.md +15 -0
- package/skills/humanizer-calibrate/SKILL.md +7 -1
- package/skills/mcp-builder/SKILL.md +1 -1
- package/skills/pdf/SKILL.md +1 -1
- package/skills/pptx/SKILL.md +1 -1
- package/skills/skill-creator/SKILL.md +21 -1
- package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
- package/skills/switchroom-cli/SKILL.md +63 -64
- package/skills/switchroom-health/SKILL.md +23 -10
- package/skills/switchroom-install/SKILL.md +3 -3
- package/skills/switchroom-manage/SKILL.md +26 -19
- package/skills/switchroom-runtime/SKILL.md +67 -15
- package/skills/switchroom-status/SKILL.md +26 -1
- package/skills/telegram-test-harness/SKILL.md +3 -0
- package/skills/webapp-testing/SKILL.md +31 -1
- package/skills/xlsx/SKILL.md +1 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +1 -1
- package/telegram-plugin/admin-commands/index.ts +9 -5
- package/telegram-plugin/auth-snapshot-format.ts +612 -0
- package/telegram-plugin/auto-fallback-fleet.ts +215 -0
- package/telegram-plugin/auto-fallback.ts +28 -301
- package/telegram-plugin/dist/gateway/gateway.js +17453 -15100
- package/telegram-plugin/fleet-fallback-gate.ts +105 -0
- package/telegram-plugin/gateway/approval-callback.test.ts +104 -0
- package/telegram-plugin/gateway/approval-callback.ts +31 -3
- package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
- package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
- package/telegram-plugin/gateway/auth-command.ts +905 -0
- package/telegram-plugin/gateway/auth-line.ts +123 -0
- package/telegram-plugin/gateway/auth-status-adapter.ts +101 -0
- package/telegram-plugin/gateway/boot-card.ts +23 -37
- package/telegram-plugin/gateway/boot-probes.ts +9 -12
- package/telegram-plugin/gateway/diff-preview-card.test.ts +192 -0
- package/telegram-plugin/gateway/diff-preview-card.ts +170 -0
- package/telegram-plugin/gateway/drive-write-approval.test.ts +312 -0
- package/telegram-plugin/gateway/drive-write-approval.ts +243 -0
- package/telegram-plugin/gateway/folder-picker-handler.test.ts +314 -0
- package/telegram-plugin/gateway/folder-picker-handler.ts +348 -0
- package/telegram-plugin/gateway/gateway.ts +1156 -938
- package/telegram-plugin/gateway/hostd-dispatch.ts +244 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +83 -2
- package/telegram-plugin/gateway/ipc-server.ts +69 -0
- package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +103 -12
- package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
- package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
- package/telegram-plugin/model-unavailable.ts +28 -12
- package/telegram-plugin/permission-title.ts +56 -0
- package/telegram-plugin/quota-check.ts +19 -41
- package/telegram-plugin/scripts/build.mjs +0 -1
- package/telegram-plugin/shared/bot-runtime.ts +5 -4
- package/telegram-plugin/silence-poke.ts +153 -1
- package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
- package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
- package/telegram-plugin/tests/auth-command-format2.test.ts +156 -0
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +429 -0
- package/telegram-plugin/tests/auth-status-adapter.test.ts +129 -0
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +211 -0
- package/telegram-plugin/tests/auto-fallback.test.ts +60 -358
- package/telegram-plugin/tests/boot-probes.test.ts +27 -22
- package/telegram-plugin/tests/fleet-fallback-gate.test.ts +197 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +30 -5
- package/telegram-plugin/tests/permission-title.test.ts +31 -0
- package/telegram-plugin/tests/quota-check.test.ts +5 -35
- package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +212 -2
- package/telegram-plugin/tests/silence-poke.test.ts +237 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +112 -0
- package/telegram-plugin/turn-flush-safety.ts +55 -1
- package/telegram-plugin/uat/SETUP.md +35 -1
- package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
- package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
- package/telegram-plugin/uat/runners/report.ts +150 -0
- package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
- package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
- package/telegram-plugin/uat/runners/scorer.ts +106 -0
- package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
- package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
- package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +7 -1
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +7 -1
- package/telegram-plugin/auth-dashboard.ts +0 -1104
- package/telegram-plugin/auth-slot-parser.ts +0 -497
- package/telegram-plugin/auto-fallback-dispatcher.ts +0 -68
- package/telegram-plugin/dist/foreman/foreman.js +0 -31358
- package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
- package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
- package/telegram-plugin/foreman/foreman.ts +0 -1165
- package/telegram-plugin/foreman/setup-flow.ts +0 -345
- package/telegram-plugin/foreman/setup-state.ts +0 -239
- package/telegram-plugin/foreman/state.ts +0 -203
- package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
- package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
- package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
- package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
- package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
- package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
- package/telegram-plugin/tests/auto-fallback-dispatcher.e2e.test.ts +0 -183
- package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
- package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
- package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
- package/telegram-plugin/tests/foreman-state.test.ts +0 -164
- package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
- package/telegram-plugin/tests/setup-flow.test.ts +0 -510
- package/telegram-plugin/tests/setup-state.test.ts +0 -146
|
@@ -1,345 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure /setup wizard state machine.
|
|
3
|
-
*
|
|
4
|
-
* Walks the user through creating a new agent entirely from Telegram:
|
|
5
|
-
* asked-slug → ask for the agent slug (e.g. "gymbro")
|
|
6
|
-
* asked-persona → ask for the persona display name (e.g. "Gym Bro")
|
|
7
|
-
* asked-model → ask which Claude model to use (or skip for default)
|
|
8
|
-
* asked-emoji → ask for a topic emoji (or skip)
|
|
9
|
-
* asked-bot-token → user creates a bot via BotFather and pastes the token
|
|
10
|
-
* confirming-allowlist → confirm the calling user_id is the allowed user
|
|
11
|
-
* reconciling → foreman provisions + starts the agent (orchestrator step)
|
|
12
|
-
* done
|
|
13
|
-
*
|
|
14
|
-
* This module is pure: no grammY, no SQLite, no network calls.
|
|
15
|
-
* foreman.ts interprets the returned actions and executes side-effects.
|
|
16
|
-
*
|
|
17
|
-
* Deferral notes in foreman.ts:
|
|
18
|
-
* // TODO(#<issue>): BotFather auto-flow — currently user creates bot manually
|
|
19
|
-
* // TODO(#<issue>): OAuth code paste step — currently manual terminal instruction
|
|
20
|
-
* // TODO(#<issue>): Skills selector — currently shows placeholder message
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import type { SetupFlowState, SetupFlowStep } from './setup-state.js'
|
|
24
|
-
|
|
25
|
-
// ─── Action types ────────────────────────────────────────────────────────
|
|
26
|
-
|
|
27
|
-
export type SetupFlowAction =
|
|
28
|
-
| { kind: 'ask-slug' }
|
|
29
|
-
| { kind: 'ask-persona'; slug: string }
|
|
30
|
-
| { kind: 'ask-model'; slug: string; persona: string }
|
|
31
|
-
| { kind: 'ask-emoji'; slug: string; persona: string; model: string | null }
|
|
32
|
-
| { kind: 'ask-profile'; slug: string; persona: string; model: string | null; emoji: string | null; profiles: string[] }
|
|
33
|
-
| { kind: 'ask-bot-token'; slug: string; persona: string; model: string | null; emoji: string | null; profile: string }
|
|
34
|
-
| { kind: 'confirm-allowlist'; slug: string; callerId: string }
|
|
35
|
-
// Pre-fix this was call-reconcile (single-step that did createAgent inline +
|
|
36
|
-
// told the user to run `switchroom auth code` from a terminal). Split per
|
|
37
|
-
// #189/#190 into call-create-agent (returns loginUrl) → asked-oauth-code
|
|
38
|
-
// (collects the code via Telegram) → call-complete-creation (runs the
|
|
39
|
-
// submit + starts the agent).
|
|
40
|
-
| { kind: 'call-create-agent'; slug: string; persona: string; model: string | null; emoji: string | null; profile: string; botToken: string; allowedUserId: string }
|
|
41
|
-
| { kind: 'ask-oauth-code'; slug: string; loginUrl: string | null }
|
|
42
|
-
| { kind: 'call-complete-creation'; slug: string; persona: string; code: string }
|
|
43
|
-
| { kind: 'done'; slug: string; botUsername: string | null }
|
|
44
|
-
| { kind: 'error'; message: string; stayInStep: boolean }
|
|
45
|
-
| { kind: 'cancel'; reason: string }
|
|
46
|
-
|
|
47
|
-
// ─── Validation helpers ───────────────────────────────────────────────────
|
|
48
|
-
|
|
49
|
-
/** Agent slug: same rules as assertSafeAgentName */
|
|
50
|
-
export function isValidSlug(slug: string): boolean {
|
|
51
|
-
return /^[a-z0-9][a-z0-9_-]{0,50}$/.test(slug)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Persona name: 1-80 printable chars, no control characters */
|
|
55
|
-
export function isValidPersonaName(name: string): boolean {
|
|
56
|
-
return name.length >= 1 && name.length <= 80 && !/[\x00-\x1f\x7f]/.test(name)
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Known short model aliases and full IDs we accept */
|
|
60
|
-
const KNOWN_MODEL_ALIASES = new Set(['sonnet', 'opus', 'haiku', 'inherit'])
|
|
61
|
-
|
|
62
|
-
/** Model: alphanumeric with . _ - / [ ] : only, or short alias */
|
|
63
|
-
export function isValidModel(model: string): boolean {
|
|
64
|
-
return KNOWN_MODEL_ALIASES.has(model.toLowerCase()) ||
|
|
65
|
-
/^[a-zA-Z0-9][a-zA-Z0-9._\-/[\]:]*$/.test(model)
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Emoji: one or two Unicode grapheme clusters (rough check) */
|
|
69
|
-
export function isValidEmoji(emoji: string): boolean {
|
|
70
|
-
const trimmed = emoji.trim()
|
|
71
|
-
return trimmed.length >= 1 && trimmed.length <= 16
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Skip keywords */
|
|
75
|
-
export function isSkip(text: string): boolean {
|
|
76
|
-
const t = text.trim().toLowerCase()
|
|
77
|
-
return t === 'skip' || t === 's' || t === '-'
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** Cancel keywords */
|
|
81
|
-
export function isCancel(text: string): boolean {
|
|
82
|
-
const t = text.trim().toLowerCase()
|
|
83
|
-
return t === '/cancel' || t === 'cancel' || t === 'abort'
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// ─── Flow entry point ────────────────────────────────────────────────────
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Start a new /setup wizard. Optionally pre-fills slug from inline arg.
|
|
90
|
-
*/
|
|
91
|
-
export function startSetupFlow(
|
|
92
|
-
inlineSlug: string | null,
|
|
93
|
-
): SetupFlowAction {
|
|
94
|
-
if (!inlineSlug) {
|
|
95
|
-
return { kind: 'ask-slug' }
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (!isValidSlug(inlineSlug)) {
|
|
99
|
-
return {
|
|
100
|
-
kind: 'error',
|
|
101
|
-
message: `"${inlineSlug}" is not a valid agent slug. Use lowercase letters, numbers, hyphens or underscores (max 51 chars).`,
|
|
102
|
-
stayInStep: false,
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return { kind: 'ask-persona', slug: inlineSlug }
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// ─── Step transition ──────────────────────────────────────────────────────
|
|
110
|
-
|
|
111
|
-
export interface SetupStepInput {
|
|
112
|
-
state: SetupFlowState | null
|
|
113
|
-
text: string
|
|
114
|
-
/** The Telegram user_id of the foreman caller (used for allowlist confirmation). */
|
|
115
|
-
callerId: string
|
|
116
|
-
/** Available profile names — passed through to the asked-profile step.
|
|
117
|
-
* Foreman calls listAvailableProfiles() and forwards the list. */
|
|
118
|
-
profiles: string[]
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Given the current state and user text, compute the next action.
|
|
123
|
-
* Returns 'cancel' with reason='user-cancelled' when the user types /cancel.
|
|
124
|
-
*/
|
|
125
|
-
export function handleSetupText(input: SetupStepInput): SetupFlowAction {
|
|
126
|
-
const { state, text, callerId, profiles } = input
|
|
127
|
-
const trimmed = text.trim()
|
|
128
|
-
|
|
129
|
-
if (!state) {
|
|
130
|
-
return { kind: 'cancel', reason: 'no-active-flow' }
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Global cancel at any step
|
|
134
|
-
if (isCancel(trimmed)) {
|
|
135
|
-
return { kind: 'cancel', reason: 'user-cancelled' }
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
switch (state.step) {
|
|
139
|
-
case 'asked-slug': {
|
|
140
|
-
if (!isValidSlug(trimmed)) {
|
|
141
|
-
return {
|
|
142
|
-
kind: 'error',
|
|
143
|
-
message: `"${trimmed}" is not a valid agent slug. Use lowercase letters, numbers, hyphens or underscores (max 51 chars). Try again:`,
|
|
144
|
-
stayInStep: true,
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
return { kind: 'ask-persona', slug: trimmed }
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
case 'asked-persona': {
|
|
151
|
-
const slug = state.slug ?? trimmed
|
|
152
|
-
if (!isValidPersonaName(trimmed)) {
|
|
153
|
-
return {
|
|
154
|
-
kind: 'error',
|
|
155
|
-
message: 'Persona name must be 1-80 printable characters. Try again:',
|
|
156
|
-
stayInStep: true,
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
return { kind: 'ask-model', slug, persona: trimmed }
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
case 'asked-model': {
|
|
163
|
-
const slug = state.slug ?? ''
|
|
164
|
-
const persona = state.persona ?? ''
|
|
165
|
-
if (isSkip(trimmed)) {
|
|
166
|
-
// Use default model
|
|
167
|
-
return { kind: 'ask-emoji', slug, persona, model: null }
|
|
168
|
-
}
|
|
169
|
-
if (!isValidModel(trimmed)) {
|
|
170
|
-
return {
|
|
171
|
-
kind: 'error',
|
|
172
|
-
message: `Unknown model "${trimmed}". Use <code>sonnet</code>, <code>opus</code>, <code>haiku</code>, a full model ID, or <code>skip</code> for the default:`,
|
|
173
|
-
stayInStep: true,
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
return { kind: 'ask-emoji', slug, persona, model: trimmed }
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
case 'asked-emoji': {
|
|
180
|
-
const slug = state.slug ?? ''
|
|
181
|
-
const persona = state.persona ?? ''
|
|
182
|
-
const model = state.model ?? null
|
|
183
|
-
const emoji = isSkip(trimmed) ? null : trimmed
|
|
184
|
-
if (!isSkip(trimmed) && !isValidEmoji(trimmed)) {
|
|
185
|
-
return {
|
|
186
|
-
kind: 'error',
|
|
187
|
-
message: 'Emoji must be 1-16 characters. Try again, or type <code>skip</code>:',
|
|
188
|
-
stayInStep: true,
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
// #190: gate on the profile selector before bot-token. The previous
|
|
192
|
-
// wizard skipped straight to bot-token and hard-coded profile='default'.
|
|
193
|
-
return { kind: 'ask-profile', slug, persona, model, emoji, profiles }
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
case 'asked-profile': {
|
|
197
|
-
// #190: validate against the live list passed in by the foreman.
|
|
198
|
-
if (!profiles.includes(trimmed)) {
|
|
199
|
-
return {
|
|
200
|
-
kind: 'error',
|
|
201
|
-
message: `Unknown profile "${escapeForMessage(trimmed)}". Choose one of: ${profiles.join(', ')}`,
|
|
202
|
-
stayInStep: true,
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
const slug = state.slug ?? ''
|
|
206
|
-
const persona = state.persona ?? ''
|
|
207
|
-
const model = state.model ?? null
|
|
208
|
-
const emoji = state.emoji ?? null
|
|
209
|
-
if (!slug || !persona) {
|
|
210
|
-
return { kind: 'cancel', reason: 'missing-slug-or-persona' }
|
|
211
|
-
}
|
|
212
|
-
return { kind: 'ask-bot-token', slug, persona, model, emoji, profile: trimmed }
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
case 'asked-bot-token': {
|
|
216
|
-
const slug = state.slug ?? ''
|
|
217
|
-
const persona = state.persona ?? ''
|
|
218
|
-
// Basic bot token shape check
|
|
219
|
-
if (!trimmed.includes(':') || trimmed.length < 20) {
|
|
220
|
-
return {
|
|
221
|
-
kind: 'error',
|
|
222
|
-
message: "That doesn't look like a BotFather token (expected <code>1234567890:AAH...</code>). Try again:",
|
|
223
|
-
stayInStep: true,
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
if (!slug || !persona) {
|
|
227
|
-
return { kind: 'cancel', reason: 'missing-slug-or-persona' }
|
|
228
|
-
}
|
|
229
|
-
return { kind: 'confirm-allowlist', slug, callerId }
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
case 'confirming-allowlist': {
|
|
233
|
-
const slug = state.slug ?? ''
|
|
234
|
-
const persona = state.persona ?? ''
|
|
235
|
-
const model = state.model ?? null
|
|
236
|
-
const emoji = state.emoji ?? null
|
|
237
|
-
// #190: profile is captured in asked-profile. Default to 'default' for
|
|
238
|
-
// legacy in-flight flows that started before the selector was added —
|
|
239
|
-
// they pre-date the schema migration and have profile=null.
|
|
240
|
-
const profile = state.profile ?? 'default'
|
|
241
|
-
const botToken = state.botToken ?? ''
|
|
242
|
-
const allowedUserId = trimmed.toLowerCase() === 'yes' || trimmed.toLowerCase() === 'y'
|
|
243
|
-
? callerId
|
|
244
|
-
: trimmed // let the user override with a different user_id
|
|
245
|
-
|
|
246
|
-
if (!allowedUserId) {
|
|
247
|
-
return {
|
|
248
|
-
kind: 'error',
|
|
249
|
-
message: 'Please reply <b>yes</b> to use your own user_id, or paste a different user_id:',
|
|
250
|
-
stayInStep: true,
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
// #189: was 'call-reconcile' (createAgent + send-to-terminal); now
|
|
254
|
-
// 'call-create-agent' which returns loginUrl so we can ask for the
|
|
255
|
-
// OAuth code in-wizard. completeCreation runs in the next state.
|
|
256
|
-
return { kind: 'call-create-agent', slug, persona, model, emoji, profile, botToken, allowedUserId }
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
case 'asked-oauth-code': {
|
|
260
|
-
// #189: collect the OAuth code pasted by the user and pass it to
|
|
261
|
-
// call-complete-creation. Same shape as the create-flow's equivalent.
|
|
262
|
-
const slug = state.slug ?? ''
|
|
263
|
-
const persona = state.persona ?? ''
|
|
264
|
-
if (!slug) return { kind: 'cancel', reason: 'missing-slug' }
|
|
265
|
-
if (trimmed.length < 4) {
|
|
266
|
-
return {
|
|
267
|
-
kind: 'error',
|
|
268
|
-
message: 'That code looks too short. Paste the full code from the browser:',
|
|
269
|
-
stayInStep: true,
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
return { kind: 'call-complete-creation', slug, persona, code: trimmed }
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
case 'reconciling':
|
|
276
|
-
// Should not receive text during reconciliation — foreman handles this step programmatically
|
|
277
|
-
return { kind: 'cancel', reason: 'unexpected-text-in-reconciling' }
|
|
278
|
-
|
|
279
|
-
case 'done':
|
|
280
|
-
return { kind: 'cancel', reason: 'flow-already-done' }
|
|
281
|
-
|
|
282
|
-
default: {
|
|
283
|
-
const _exhaustive: never = state.step
|
|
284
|
-
return { kind: 'cancel', reason: `unknown-step:${String(_exhaustive)}` }
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/** Tiny HTML-escape for inline error messages. The foreman renders these
|
|
290
|
-
* via switchroomReply with html: true, so user-supplied text needs the
|
|
291
|
-
* same escape pass the rest of the wizard does. */
|
|
292
|
-
function escapeForMessage(s: string): string {
|
|
293
|
-
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// ─── State factory helpers ────────────────────────────────────────────────
|
|
297
|
-
|
|
298
|
-
export function makeSetupInitialState(
|
|
299
|
-
chatId: string,
|
|
300
|
-
slug: string | null,
|
|
301
|
-
): SetupFlowState {
|
|
302
|
-
const now = Date.now()
|
|
303
|
-
return {
|
|
304
|
-
chatId,
|
|
305
|
-
step: slug ? 'asked-persona' : 'asked-slug',
|
|
306
|
-
slug,
|
|
307
|
-
persona: null,
|
|
308
|
-
model: null,
|
|
309
|
-
emoji: null,
|
|
310
|
-
profile: null,
|
|
311
|
-
botToken: null,
|
|
312
|
-
allowedUserId: null,
|
|
313
|
-
authSessionName: null,
|
|
314
|
-
loginUrl: null,
|
|
315
|
-
startedAt: now,
|
|
316
|
-
updatedAt: now,
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
export function advanceSetupState(
|
|
321
|
-
state: SetupFlowState,
|
|
322
|
-
updates: Partial<Omit<SetupFlowState, 'chatId' | 'startedAt'>>,
|
|
323
|
-
): SetupFlowState {
|
|
324
|
-
return {
|
|
325
|
-
...state,
|
|
326
|
-
...updates,
|
|
327
|
-
updatedAt: Date.now(),
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/** Human-readable step label for resume messages. */
|
|
332
|
-
export function setupStepLabel(step: SetupFlowStep): string {
|
|
333
|
-
switch (step) {
|
|
334
|
-
case 'asked-slug': return 'waiting for agent slug'
|
|
335
|
-
case 'asked-persona': return 'waiting for persona name'
|
|
336
|
-
case 'asked-model': return 'waiting for model choice'
|
|
337
|
-
case 'asked-emoji': return 'waiting for emoji'
|
|
338
|
-
case 'asked-profile': return 'waiting for profile selection'
|
|
339
|
-
case 'asked-bot-token': return 'waiting for BotFather token'
|
|
340
|
-
case 'confirming-allowlist': return 'waiting for allowlist confirmation'
|
|
341
|
-
case 'reconciling': return 'provisioning agent'
|
|
342
|
-
case 'asked-oauth-code': return 'waiting for OAuth code'
|
|
343
|
-
case 'done': return 'done'
|
|
344
|
-
}
|
|
345
|
-
}
|
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* /setup wizard conversation state — SQLite-backed per-chat state.
|
|
3
|
-
*
|
|
4
|
-
* Survives foreman restarts so a wizard started before a restart can resume.
|
|
5
|
-
*
|
|
6
|
-
* Location: ~/.switchroom/foreman/state.sqlite (same DB as create_flow)
|
|
7
|
-
* Override via SWITCHROOM_FOREMAN_DIR env var.
|
|
8
|
-
*
|
|
9
|
-
* Schema:
|
|
10
|
-
* CREATE TABLE IF NOT EXISTS setup_flow (
|
|
11
|
-
* chat_id TEXT PRIMARY KEY,
|
|
12
|
-
* step TEXT NOT NULL,
|
|
13
|
-
* slug TEXT,
|
|
14
|
-
* persona TEXT,
|
|
15
|
-
* model TEXT,
|
|
16
|
-
* emoji TEXT,
|
|
17
|
-
* bot_token TEXT,
|
|
18
|
-
* allowed_user_id TEXT,
|
|
19
|
-
* started_at INTEGER NOT NULL,
|
|
20
|
-
* updated_at INTEGER NOT NULL
|
|
21
|
-
* );
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import { Database } from 'bun:sqlite'
|
|
25
|
-
import { chmodSync, mkdirSync } from 'fs'
|
|
26
|
-
import { homedir } from 'os'
|
|
27
|
-
import { join } from 'path'
|
|
28
|
-
|
|
29
|
-
// ─── Types ────────────────────────────────────────────────────────────────
|
|
30
|
-
|
|
31
|
-
export type SetupFlowStep =
|
|
32
|
-
| 'asked-slug'
|
|
33
|
-
| 'asked-persona'
|
|
34
|
-
| 'asked-model'
|
|
35
|
-
| 'asked-emoji'
|
|
36
|
-
| 'asked-profile' // #190: skills/profile selector before bot-token
|
|
37
|
-
| 'asked-bot-token'
|
|
38
|
-
| 'confirming-allowlist'
|
|
39
|
-
| 'reconciling'
|
|
40
|
-
| 'asked-oauth-code' // #189: in-wizard OAuth code paste (replaces "go to terminal")
|
|
41
|
-
| 'done'
|
|
42
|
-
|
|
43
|
-
export interface SetupFlowState {
|
|
44
|
-
chatId: string
|
|
45
|
-
step: SetupFlowStep
|
|
46
|
-
slug: string | null
|
|
47
|
-
persona: string | null
|
|
48
|
-
model: string | null
|
|
49
|
-
emoji: string | null
|
|
50
|
-
/** #190: which profile to use when scaffolding. Defaults to 'default'
|
|
51
|
-
* for flows that started before the profile-selector step landed. */
|
|
52
|
-
profile: string | null
|
|
53
|
-
botToken: string | null
|
|
54
|
-
allowedUserId: string | null
|
|
55
|
-
/** #189: foreman captures these from createAgent's return value so the
|
|
56
|
-
* asked-oauth-code step can render the URL and the call-complete-creation
|
|
57
|
-
* action can find the right session. Null until call-create-agent runs. */
|
|
58
|
-
authSessionName: string | null
|
|
59
|
-
loginUrl: string | null
|
|
60
|
-
startedAt: number
|
|
61
|
-
updatedAt: number
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// ─── DB singleton ─────────────────────────────────────────────────────────
|
|
65
|
-
|
|
66
|
-
let _setupDb: Database | null = null
|
|
67
|
-
|
|
68
|
-
function getSetupDb(): Database {
|
|
69
|
-
if (_setupDb) return _setupDb
|
|
70
|
-
|
|
71
|
-
const foremanDir =
|
|
72
|
-
process.env.SWITCHROOM_FOREMAN_DIR ?? join(homedir(), '.switchroom', 'foreman')
|
|
73
|
-
|
|
74
|
-
mkdirSync(foremanDir, { recursive: true, mode: 0o700 })
|
|
75
|
-
|
|
76
|
-
const dbPath = join(foremanDir, 'state.sqlite')
|
|
77
|
-
_setupDb = new Database(dbPath)
|
|
78
|
-
try {
|
|
79
|
-
chmodSync(dbPath, 0o600)
|
|
80
|
-
} catch {
|
|
81
|
-
// best-effort
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
_setupDb.exec(`
|
|
85
|
-
CREATE TABLE IF NOT EXISTS setup_flow (
|
|
86
|
-
chat_id TEXT PRIMARY KEY,
|
|
87
|
-
step TEXT NOT NULL,
|
|
88
|
-
slug TEXT,
|
|
89
|
-
persona TEXT,
|
|
90
|
-
model TEXT,
|
|
91
|
-
emoji TEXT,
|
|
92
|
-
bot_token TEXT,
|
|
93
|
-
allowed_user_id TEXT,
|
|
94
|
-
started_at INTEGER NOT NULL,
|
|
95
|
-
updated_at INTEGER NOT NULL
|
|
96
|
-
);
|
|
97
|
-
`)
|
|
98
|
-
|
|
99
|
-
// #189/#190: idempotent column additions for installs that pre-date the
|
|
100
|
-
// profile + OAuth-code steps. ALTER TABLE ADD COLUMN is a no-op when the
|
|
101
|
-
// column already exists in SQLite ≥ 3.35; older versions throw — wrap in
|
|
102
|
-
// try/catch so the migration is forward-compatible.
|
|
103
|
-
for (const migration of [
|
|
104
|
-
`ALTER TABLE setup_flow ADD COLUMN profile TEXT`,
|
|
105
|
-
`ALTER TABLE setup_flow ADD COLUMN auth_session_name TEXT`,
|
|
106
|
-
`ALTER TABLE setup_flow ADD COLUMN login_url TEXT`,
|
|
107
|
-
]) {
|
|
108
|
-
try {
|
|
109
|
-
_setupDb.exec(migration)
|
|
110
|
-
} catch {
|
|
111
|
-
// Column already exists. Ignore.
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return _setupDb
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// ─── Row type ─────────────────────────────────────────────────────────────
|
|
119
|
-
|
|
120
|
-
interface SetupFlowRow {
|
|
121
|
-
chat_id: string
|
|
122
|
-
step: string
|
|
123
|
-
slug: string | null
|
|
124
|
-
persona: string | null
|
|
125
|
-
model: string | null
|
|
126
|
-
emoji: string | null
|
|
127
|
-
bot_token: string | null
|
|
128
|
-
allowed_user_id: string | null
|
|
129
|
-
started_at: number
|
|
130
|
-
updated_at: number
|
|
131
|
-
// #189/#190: nullable in legacy rows that pre-date the migration.
|
|
132
|
-
profile: string | null
|
|
133
|
-
auth_session_name: string | null
|
|
134
|
-
login_url: string | null
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function rowToState(row: SetupFlowRow): SetupFlowState {
|
|
138
|
-
return {
|
|
139
|
-
chatId: row.chat_id,
|
|
140
|
-
step: row.step as SetupFlowStep,
|
|
141
|
-
slug: row.slug,
|
|
142
|
-
persona: row.persona,
|
|
143
|
-
model: row.model,
|
|
144
|
-
emoji: row.emoji,
|
|
145
|
-
profile: row.profile,
|
|
146
|
-
botToken: row.bot_token,
|
|
147
|
-
allowedUserId: row.allowed_user_id,
|
|
148
|
-
authSessionName: row.auth_session_name,
|
|
149
|
-
loginUrl: row.login_url,
|
|
150
|
-
startedAt: row.started_at,
|
|
151
|
-
updatedAt: row.updated_at,
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// ─── Public API ───────────────────────────────────────────────────────────
|
|
156
|
-
|
|
157
|
-
/** Upsert the setup wizard state for a given chat. */
|
|
158
|
-
export function setSetupState(state: SetupFlowState): void {
|
|
159
|
-
const db = getSetupDb()
|
|
160
|
-
db.prepare(`
|
|
161
|
-
INSERT INTO setup_flow
|
|
162
|
-
(chat_id, step, slug, persona, model, emoji, profile, bot_token,
|
|
163
|
-
allowed_user_id, auth_session_name, login_url, started_at, updated_at)
|
|
164
|
-
VALUES
|
|
165
|
-
($chatId, $step, $slug, $persona, $model, $emoji, $profile, $botToken,
|
|
166
|
-
$allowedUserId, $authSessionName, $loginUrl, $startedAt, $updatedAt)
|
|
167
|
-
ON CONFLICT(chat_id) DO UPDATE SET
|
|
168
|
-
step = excluded.step,
|
|
169
|
-
slug = excluded.slug,
|
|
170
|
-
persona = excluded.persona,
|
|
171
|
-
model = excluded.model,
|
|
172
|
-
emoji = excluded.emoji,
|
|
173
|
-
profile = excluded.profile,
|
|
174
|
-
bot_token = excluded.bot_token,
|
|
175
|
-
allowed_user_id = excluded.allowed_user_id,
|
|
176
|
-
auth_session_name = excluded.auth_session_name,
|
|
177
|
-
login_url = excluded.login_url,
|
|
178
|
-
updated_at = excluded.updated_at
|
|
179
|
-
`).run({
|
|
180
|
-
$chatId: state.chatId,
|
|
181
|
-
$step: state.step,
|
|
182
|
-
$slug: state.slug,
|
|
183
|
-
$persona: state.persona,
|
|
184
|
-
$model: state.model,
|
|
185
|
-
$emoji: state.emoji,
|
|
186
|
-
$profile: state.profile,
|
|
187
|
-
$botToken: state.botToken,
|
|
188
|
-
$allowedUserId: state.allowedUserId,
|
|
189
|
-
$authSessionName: state.authSessionName,
|
|
190
|
-
$loginUrl: state.loginUrl,
|
|
191
|
-
$startedAt: state.startedAt,
|
|
192
|
-
$updatedAt: state.updatedAt,
|
|
193
|
-
})
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** Retrieve the setup wizard state for a given chat, or null if none. */
|
|
197
|
-
export function getSetupState(chatId: string): SetupFlowState | null {
|
|
198
|
-
const db = getSetupDb()
|
|
199
|
-
const row = db.prepare<SetupFlowRow, [string]>(`
|
|
200
|
-
SELECT chat_id, step, slug, persona, model, emoji, profile, bot_token,
|
|
201
|
-
allowed_user_id, auth_session_name, login_url, started_at, updated_at
|
|
202
|
-
FROM setup_flow
|
|
203
|
-
WHERE chat_id = ?
|
|
204
|
-
`).get(chatId)
|
|
205
|
-
|
|
206
|
-
return row ? rowToState(row) : null
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/** Remove the setup wizard state for a given chat. */
|
|
210
|
-
export function clearSetupState(chatId: string): void {
|
|
211
|
-
const db = getSetupDb()
|
|
212
|
-
db.prepare('DELETE FROM setup_flow WHERE chat_id = ?').run(chatId)
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* List all in-progress setup flows updated within the last `maxAgeMs` ms.
|
|
217
|
-
* Used at foreman startup to resume flows that survived a restart.
|
|
218
|
-
*/
|
|
219
|
-
export function listActiveSetupFlows(maxAgeMs = 60 * 60 * 1000): SetupFlowState[] {
|
|
220
|
-
const db = getSetupDb()
|
|
221
|
-
const cutoff = Date.now() - maxAgeMs
|
|
222
|
-
const rows = db.prepare<SetupFlowRow, [number]>(`
|
|
223
|
-
SELECT chat_id, step, slug, persona, model, emoji, profile, bot_token,
|
|
224
|
-
allowed_user_id, auth_session_name, login_url, started_at, updated_at
|
|
225
|
-
FROM setup_flow
|
|
226
|
-
WHERE step != 'done' AND updated_at > ?
|
|
227
|
-
ORDER BY updated_at DESC
|
|
228
|
-
`).all(cutoff)
|
|
229
|
-
|
|
230
|
-
return rows.map(rowToState)
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/** Reset the DB singleton (useful in tests to avoid sharing state). */
|
|
234
|
-
export function _resetSetupDbForTest(): void {
|
|
235
|
-
if (_setupDb) {
|
|
236
|
-
_setupDb.close()
|
|
237
|
-
_setupDb = null
|
|
238
|
-
}
|
|
239
|
-
}
|