loopat 0.1.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 (58) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +194 -0
  3. package/bin/loopat.mjs +65 -0
  4. package/package.json +52 -0
  5. package/server/package.json +22 -0
  6. package/server/src/api-tokens.ts +161 -0
  7. package/server/src/api-v1-openapi.ts +363 -0
  8. package/server/src/api-v1.ts +681 -0
  9. package/server/src/auth.ts +309 -0
  10. package/server/src/bootstrap.ts +113 -0
  11. package/server/src/chat.ts +390 -0
  12. package/server/src/claude-binary.ts +68 -0
  13. package/server/src/compose.ts +474 -0
  14. package/server/src/config.ts +783 -0
  15. package/server/src/files.ts +173 -0
  16. package/server/src/git-crypt-key.ts +36 -0
  17. package/server/src/git-host.ts +104 -0
  18. package/server/src/github.ts +161 -0
  19. package/server/src/index.ts +3204 -0
  20. package/server/src/kanban.ts +810 -0
  21. package/server/src/loop-stats.ts +225 -0
  22. package/server/src/loop-status.ts +67 -0
  23. package/server/src/loops.ts +1832 -0
  24. package/server/src/mcp-oauth.ts +516 -0
  25. package/server/src/onboarding.ts +105 -0
  26. package/server/src/paths.ts +190 -0
  27. package/server/src/personal-keys.ts +60 -0
  28. package/server/src/plugin-installer.ts +287 -0
  29. package/server/src/podman.ts +1216 -0
  30. package/server/src/presets.ts +30 -0
  31. package/server/src/profiles.ts +177 -0
  32. package/server/src/providers.ts +45 -0
  33. package/server/src/serve.ts +275 -0
  34. package/server/src/session.ts +1496 -0
  35. package/server/src/system-prompt.ts +90 -0
  36. package/server/src/term.ts +211 -0
  37. package/server/src/tiers.ts +762 -0
  38. package/server/src/vaults.ts +189 -0
  39. package/server/src/workspace.ts +501 -0
  40. package/server/templates/.claude-plugin/marketplace.json +13 -0
  41. package/server/templates/CLAUDE.md +78 -0
  42. package/server/templates/loop-kinds/distill/CLAUDE.md +46 -0
  43. package/server/templates/plugins/loopat/.claude-plugin/plugin.json +5 -0
  44. package/server/templates/plugins/loopat/skills/onboarding/SKILL.md +266 -0
  45. package/server/templates/plugins/loopat/skills/promote/SKILL.md +53 -0
  46. package/server/templates/sandbox/Containerfile +113 -0
  47. package/web/dist/assets/CodeEditor-BGODueTo.js +49 -0
  48. package/web/dist/assets/Editor-DMS25Vve.js +1 -0
  49. package/web/dist/assets/Markdown-CnHbW7WK.js +5 -0
  50. package/web/dist/assets/MilkdownEditor-nqo9_0v5.js +123 -0
  51. package/web/dist/assets/Terminal-BrP-ENHg.css +1 -0
  52. package/web/dist/assets/Terminal-CYWvxYam.js +174 -0
  53. package/web/dist/assets/index-DM5eO-Tv.js +163 -0
  54. package/web/dist/assets/index-DxIFezwv.css +1 -0
  55. package/web/dist/assets/w3c-keyname-BOAvb0qz.js +1 -0
  56. package/web/dist/favicon.svg +1 -0
  57. package/web/dist/index.html +14 -0
  58. package/web/dist/logo.png +0 -0
@@ -0,0 +1,1496 @@
1
+ import { query, type Query, type SDKMessage, type SDKUserMessage, type PermissionMode as SdkPermissionMode, type StopHookInput } from "@anthropic-ai/claude-agent-sdk"
2
+ import type { WSContext } from "hono/ws"
3
+ import { appendFile, readFile, readdir, rm, writeFile, mkdir } from "node:fs/promises"
4
+ import { createWriteStream, mkdirSync, existsSync } from "node:fs"
5
+ import { randomUUID } from "node:crypto"
6
+ import { join } from "node:path"
7
+ import { loopClaudeDir, loopDir, loopHistoryPath, personalSkillsDir, workspaceTeamSkillsDir } from "./paths"
8
+ import { resolveClaudeBinary } from "./claude-binary"
9
+ import { loadConfig, loadPersonalConfig, parseDefault, type ProviderConfig } from "./config"
10
+ import { buildLoopatAppend } from "./system-prompt"
11
+ import { composeLoopClaudeConfig, writeLoopSettings } from "./compose"
12
+ import { ensureLoopPluginsInstalled, lookupPluginInstallPath, BUILTIN_LOOPAT_PLUGIN_PATH } from "./plugin-installer"
13
+ import { effectiveDriver, getLoop, loopEphemeralPorts, patchLoopMeta } from "./loops"
14
+ import { spawn as nodeSpawn } from "node:child_process"
15
+ import { ensureContainer, buildPodmanExecArgs, markActive, markInactive, V_LOOP_WORKDIR, V_LOOP_CLAUDE } from "./podman"
16
+ import { updateLoopStatus } from "./loop-status"
17
+
18
+ // Tests override LOOPAT_CLAUDE_BIN to point at a mock binary (a script that
19
+ // reads stream-json from stdin and writes canned messages back) so we can
20
+ // exercise the full chat pipeline without burning real API credits.
21
+ // Resolved lazily — each spawn re-reads the env var so the full-suite test
22
+ // run, where module load order isn't guaranteed, sees the test's override
23
+ // even if session.ts was imported earlier with the env var unset.
24
+ function getClaudeBinary(): string {
25
+ return process.env.LOOPAT_CLAUDE_BIN || resolveClaudeBinary()
26
+ }
27
+ const DEBUG = !!process.env.LOOPAT_DEBUG || !!process.env.LOOPAT_DEBUG_SPAWN
28
+
29
+ function parseSkillDescription(content: string): string | undefined {
30
+ const fm = content.match(/^---\s*\n([\s\S]*?)\n---/)
31
+ if (!fm) return undefined
32
+ const desc = fm[1].match(/^description:\s*(.+)$/m)
33
+ return desc ? desc[1].trim() : undefined
34
+ }
35
+
36
+ async function readSkillDescription(skillsDir: string, skillName: string): Promise<string> {
37
+ try {
38
+ const content = await readFile(join(skillsDir, skillName, "SKILL.md"), "utf-8")
39
+ return parseSkillDescription(content) ?? ""
40
+ } catch {
41
+ return ""
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Pick a provider from personal + workspace configs given a candidate list,
47
+ * applying the priority order:
48
+ * 1. explicit candidates (caller-supplied: WS override, loop.meta.config)
49
+ * 2. personal config's `default` field
50
+ * 3. workspace config's `default` field
51
+ * 4. enumeration (personal first, then workspace)
52
+ *
53
+ * `requireKey=true` skips providers with empty apiKey and keeps walking.
54
+ * Returns null when no match found. Pure function; tests use it directly.
55
+ */
56
+ export function pickProvider(
57
+ pCfg: { default: string; providers: Record<string, ProviderConfig> },
58
+ wCfg: { default?: string; providers?: Record<string, ProviderConfig> },
59
+ candidateNames: (string | null | undefined)[],
60
+ requireKey: boolean,
61
+ ): { name: string; provider: ProviderConfig } | null {
62
+ const names = [
63
+ ...candidateNames,
64
+ pCfg.default ? parseDefault(pCfg.default).providerName : undefined,
65
+ wCfg.default ? parseDefault(wCfg.default).providerName : undefined,
66
+ ...Object.keys(pCfg.providers),
67
+ ...Object.keys(wCfg.providers ?? {}),
68
+ ].filter(Boolean) as string[]
69
+ const seen = new Set<string>()
70
+ for (const name of names) {
71
+ if (seen.has(name)) continue
72
+ seen.add(name)
73
+ const p = pCfg.providers[name] ?? wCfg.providers?.[name]
74
+ if (p && (!requireKey || p.apiKey)) return { name, provider: p }
75
+ }
76
+ return null
77
+ }
78
+
79
+ /**
80
+ * Mirror cli's ff(): explicit override wins; otherwise [1m] tag → 1M;
81
+ * any claude opus-4-7/4-6/sonnet-4/sonnet-4-6 → still defaults to 200K
82
+ * unless tagged [1m] (1M is opt-in via beta on those). Fallback 200K.
83
+ */
84
+ function resolveContextWindow(p: ProviderConfig, modelId?: string): number {
85
+ // Per-model override takes precedence
86
+ const model = modelId ? p.models.find(m => m.id === modelId) : undefined
87
+ if (model?.maxContextTokens && model.maxContextTokens > 0) return model.maxContextTokens
88
+ // Provider-level fallback
89
+ if (p.maxContextTokens && p.maxContextTokens > 0) return p.maxContextTokens
90
+ if (/\[1m\]/i.test(model?.id ?? p.models[0]?.id ?? "")) return 1_000_000
91
+ return 200_000
92
+ }
93
+
94
+ /** Subset of SDK PermissionMode that the frontend sends. */
95
+ const VALID_MODES = ["default", "acceptEdits", "bypassPermissions", "plan", "dontAsk", "auto"] as const
96
+ type FrontendPermissionMode = (typeof VALID_MODES)[number]
97
+
98
+ function isValidMode(m: unknown): m is FrontendPermissionMode {
99
+ return typeof m === "string" && (VALID_MODES as readonly string[]).includes(m)
100
+ }
101
+
102
+ function maskEnv(env: Record<string, string | undefined>): Record<string, string> {
103
+ const out: Record<string, string> = {}
104
+ for (const [k, v] of Object.entries(env)) {
105
+ if (v === undefined) continue
106
+ if (/key|token|secret|password/i.test(k)) {
107
+ out[k] = v ? `<set len=${v.length}>` : "<empty>"
108
+ } else {
109
+ out[k] = v
110
+ }
111
+ }
112
+ return out
113
+ }
114
+
115
+ function pushIterable<T>() {
116
+ const queue: T[] = []
117
+ let resolver: ((v: IteratorResult<T>) => void) | null = null
118
+ let done = false
119
+
120
+ const iter: AsyncIterableIterator<T> = {
121
+ [Symbol.asyncIterator]() {
122
+ return this
123
+ },
124
+ next(): Promise<IteratorResult<T>> {
125
+ if (queue.length > 0) {
126
+ return Promise.resolve({ value: queue.shift()!, done: false })
127
+ }
128
+ if (done) {
129
+ return Promise.resolve({ value: undefined as any, done: true })
130
+ }
131
+ return new Promise((r) => {
132
+ resolver = r
133
+ })
134
+ },
135
+ return(value?: any): Promise<IteratorResult<T>> {
136
+ done = true
137
+ return Promise.resolve({ value, done: true })
138
+ },
139
+ }
140
+
141
+ return {
142
+ push(v: T) {
143
+ if (done) return
144
+ if (resolver) {
145
+ const r = resolver
146
+ resolver = null
147
+ r({ value: v, done: false })
148
+ } else {
149
+ queue.push(v)
150
+ }
151
+ },
152
+ end() {
153
+ done = true
154
+ if (resolver) {
155
+ const r = resolver
156
+ resolver = null
157
+ r({ value: undefined as any, done: true })
158
+ }
159
+ },
160
+ iter,
161
+ }
162
+ }
163
+
164
+ async function hasPriorSdkSession(loopId: string): Promise<boolean> {
165
+ const projectsDir = join(loopClaudeDir(loopId), "projects")
166
+ try {
167
+ const projects = await readdir(projectsDir)
168
+ for (const p of projects) {
169
+ const files = await readdir(join(projectsDir, p))
170
+ if (files.some((f) => f.endsWith(".jsonl"))) return true
171
+ }
172
+ } catch {}
173
+ return false
174
+ }
175
+
176
+ type SubscriberState = { pending: any[] | null }
177
+
178
+ interface AskQuestionPending {
179
+ toolUseID: string
180
+ questions: Array<{
181
+ question: string
182
+ header: string
183
+ options: Array<{ label: string; description: string }>
184
+ multiSelect: boolean
185
+ }>
186
+ resolve: (result: { behavior: 'allow'; updatedInput: Record<string, unknown> }) => void
187
+ reject: (err: Error) => void
188
+ }
189
+
190
+ interface PermissionPending {
191
+ toolUseID: string
192
+ toolName: string
193
+ promptMsg: Record<string, unknown>
194
+ resolve: (result: PermissionResult) => void
195
+ reject: (err: Error) => void
196
+ }
197
+
198
+ type PermissionResult = { behavior: 'allow'; updatedInput: Record<string, unknown> } | { behavior: 'deny'; message: string }
199
+
200
+ /** Tools that are always safe (read-only) — auto-allowed in every mode. */
201
+ const SAFE_TOOLS = new Set([
202
+ "Read", "Grep", "Glob", "WebSearch", "WebFetch",
203
+ "TaskOutput", "CronList", "TodoWrite",
204
+ "EnterPlanMode", "ExitPlanMode",
205
+ ])
206
+
207
+ /** Tools that edit files — auto-allowed in acceptEdits mode. */
208
+ const EDIT_TOOLS = new Set(["Write", "Edit", "NotebookEdit"])
209
+
210
+ const IDLE_TIMEOUT_MS = Number(process.env.LOOPAT_SESSION_IDLE_MS) || 5 * 60 * 1000
211
+
212
+ type QueuedMessage = { text: string; permissionMode?: SdkPermissionMode }
213
+
214
+ export type LoopSessionMessageListener = (msg: any) => void
215
+
216
+ class LoopSession {
217
+ id: string
218
+ private q: Query | null = null
219
+ private input = pushIterable<SDKUserMessage>()
220
+ private subscribers = new Map<WSContext, SubscriberState>()
221
+ private history: SDKMessage[] = []
222
+ private historyLoaded: Promise<void>
223
+ private pendingQuestions = new Map<string, AskQuestionPending>()
224
+ private pendingPermissions = new Map<string, PermissionPending>()
225
+ private messageListeners = new Set<LoopSessionMessageListener>()
226
+ private providerOverride: string | null = null
227
+ private currentPermissionMode: SdkPermissionMode = "bypassPermissions"
228
+ private currentGoal: string | null = null
229
+ private goalSetAt: string | null = null
230
+ private goalStatus: "active" | "completed" | null = null
231
+ /** Set of CC task_ids spawned while this goal was active. Used to gauge progress. */
232
+ private goalTaskIds = new Set<string>()
233
+ private idleTimer: ReturnType<typeof setTimeout> | null = null
234
+ private consuming = false
235
+ private generating = false
236
+ private messageQueue: QueuedMessage[] = []
237
+ private queueProcessing = false
238
+
239
+ constructor(id: string) {
240
+ this.id = id
241
+ this.historyLoaded = this.loadHistoryFromDisk()
242
+ }
243
+
244
+ private cancelIdleCleanup() {
245
+ if (this.idleTimer) {
246
+ clearTimeout(this.idleTimer)
247
+ this.idleTimer = null
248
+ }
249
+ }
250
+
251
+ private scheduleIdleCleanup() {
252
+ if (this.idleTimer) return
253
+ if (this.subscribers.size > 0) return
254
+ if (this.consuming) return // never interrupt an active generation
255
+ const tag = this.id.slice(0, 8)
256
+ this.idleTimer = setTimeout(() => {
257
+ this.idleTimer = null
258
+ if (this.subscribers.size === 0) {
259
+ console.log(`[loop:${tag}] idle timeout — destroying session`)
260
+ this.destroy()
261
+ }
262
+ }, IDLE_TIMEOUT_MS)
263
+ }
264
+
265
+ private async resolveProvider(meta: { createdBy: string; driver?: string; config?: { vault?: string } }, candidateNames: (string | null | undefined)[], requireKey: boolean): Promise<{ name: string; provider: ProviderConfig } | null> {
266
+ const pCfg = await loadPersonalConfig(effectiveDriver(meta), meta.config?.vault)
267
+ const wCfg = await loadConfig()
268
+ return pickProvider(pCfg, wCfg, candidateNames, requireKey)
269
+ }
270
+
271
+ /**
272
+ * Set the active provider. Takes effect on the next user message — the
273
+ * current claude-binary child (if any) is interrupted and torn down so
274
+ * `ensureStarted` re-spawns it with the new provider's env (baseUrl /
275
+ * apiKey / model). Conversation history is preserved via `--continue`,
276
+ * which reads the existing SDK jsonl on disk, so the swap is transparent
277
+ * to the user beyond the brief pause.
278
+ *
279
+ * Always returns true — provider switching is unconditional. The setter
280
+ * is fire-and-forget; the interrupt runs in the background, and the next
281
+ * sendUserText awaits the freshly-null `q` and re-enters ensureStarted.
282
+ *
283
+ * The pushIterable is also reset: the old `Query` is still holding the
284
+ * old iter, so a fresh push would race the dying-but-not-dead loop. The
285
+ * new query takes a brand-new iter; the orphaned iter is GC'd when the
286
+ * old Query's internal loop unwinds.
287
+ */
288
+ setProvider(name: string | null) {
289
+ this.providerOverride = name
290
+ this.restartOnNextMessage()
291
+ return true
292
+ }
293
+
294
+ /** Set the active goal. Broadcasts to all subscribers and restarts the
295
+ * query so the next system prompt includes the goal. Pass null to clear. */
296
+ setGoal(goal: string | null, setAt?: string, status?: "active" | "completed") {
297
+ this.currentGoal = goal
298
+ this.goalSetAt = setAt ?? (goal ? new Date().toISOString() : null)
299
+ this.goalStatus = goal ? (status ?? "active") : null
300
+ if (goal) {
301
+ this.goalTaskIds = new Set()
302
+ }
303
+ this.broadcast({
304
+ type: "goal",
305
+ goal,
306
+ setAt: this.goalSetAt,
307
+ status: this.goalStatus,
308
+ })
309
+ if (this.q) {
310
+ // Re-compose: the next ensureStarted picks up the goal via buildLoopatAppend.
311
+ this.restartOnNextMessage()
312
+ }
313
+ }
314
+
315
+ /** Mark the current goal as completed. Called by the user or by detecting
316
+ * an AI self-report. */
317
+ completeGoal() {
318
+ if (!this.currentGoal || this.goalStatus !== "active") return
319
+ this.goalStatus = "completed"
320
+ this.broadcast({
321
+ type: "goal",
322
+ goal: this.currentGoal,
323
+ setAt: this.goalSetAt,
324
+ status: "completed",
325
+ })
326
+ }
327
+
328
+ getGoal(): string | null {
329
+ return this.currentGoal
330
+ }
331
+
332
+ /**
333
+ * Interrupt the current `query()` and clear `this.q`, so the next user
334
+ * message triggers a fresh `ensureStarted()` — picking up changes to env
335
+ * vars, provider config, **mcpServers**, etc. Conversation history is
336
+ * preserved because the SDK reads its session JSONL from disk on respawn
337
+ * (`continue: true` when `hasPriorSdkSession` is true).
338
+ *
339
+ * Idempotent: calling on a session that doesn't currently hold a query is
340
+ * a no-op. Fire-and-forget; the interrupt runs in the background.
341
+ */
342
+ restartOnNextMessage() {
343
+ if (this.q) {
344
+ const dying = this.q
345
+ this.q = null
346
+ this.input = pushIterable<SDKUserMessage>()
347
+ dying.interrupt().catch(() => {})
348
+ }
349
+ }
350
+
351
+ private async loadHistoryFromDisk() {
352
+ try {
353
+ const raw = await readFile(loopHistoryPath(this.id), "utf8")
354
+ for (const line of raw.split("\n")) {
355
+ if (!line) continue
356
+ try {
357
+ this.history.push(JSON.parse(line))
358
+ } catch {}
359
+ }
360
+ } catch {}
361
+ }
362
+
363
+ private async ensureStarted() {
364
+ if (this.q) return
365
+ const shouldContinue = await hasPriorSdkSession(this.id)
366
+ const meta = await getLoop(this.id)
367
+ if (!meta) {
368
+ throw new Error(`loop ${this.id} meta missing`)
369
+ }
370
+ // Effective driver — credentials, plugins, vault, env, personal mount
371
+ // all follow this user, not the immutable createdBy. Updated by the
372
+ // /api/loops/:id/drive handoff endpoint; next spawn picks it up here.
373
+ const driver = effectiveDriver(meta)
374
+ const resolved = await this.resolveProvider(meta, [
375
+ this.providerOverride,
376
+ meta.config?.default_model,
377
+ ], true)
378
+ if (!resolved) {
379
+ throw new Error(`no provider with a valid apiKey for vault "${meta.config?.vault ?? "default"}" — set one in personal/${driver}/.loopat/vaults/${meta.config?.vault ?? "default"}/envs/`)
380
+ }
381
+ const providerName = resolved.name
382
+ const provider = resolved.provider
383
+
384
+ const loopatAppend = await buildLoopatAppend(meta)
385
+ const loopId = this.id
386
+
387
+ // Compose runs ONCE at loop creation (loops.ts:createLoop). At spawn we
388
+ // only re-compose if the snapshot is missing — this happens for loops
389
+ // created before the snapshot model landed, and self-heals on first spawn.
390
+ //
391
+ // The "compose once and freeze" semantics is what makes principle 1
392
+ // (old loops never change) work: subsequent admin pushes to knowledge
393
+ // don't affect a loop that's already been materialized.
394
+ const composedSettingsPath = join(loopClaudeDir(loopId), "settings.json")
395
+ if (!existsSync(composedSettingsPath)) {
396
+ await composeLoopClaudeConfig(loopId, driver, meta.config?.profiles)
397
+ }
398
+ // Ensure host CC has every marketplace registered + every enabled plugin
399
+ // installed. We don't need the resolved paths (sandbox sees host
400
+ // ~/.claude/plugins/ via a wholesale ro-bind in bwrap, and the inner SDK
401
+ // resolves enabledPlugins natively from settings.json). This is purely
402
+ // side-effectful: drive `claude plugin marketplace add/remove` +
403
+ // `claude plugin install` as needed. See plugin-installer.ts.
404
+ await ensureLoopPluginsInstalled(loopId)
405
+
406
+ // Nuke CC's MCP-related cache files that linger across spawns:
407
+ // `.credentials.json` — CC's ephemeral OAuth state.
408
+ // `mcp-needs-auth-cache.json` — CC's "this server needs auth" short-circuit.
409
+ // Tokens flow through vault envs/MCP_*_TOKEN now, substituted into the
410
+ // workspace `mcpServers[*].headers.Authorization` template by the spawned
411
+ // binary at startup. Stale CC cache files would shortcircuit that, so we
412
+ // clear them every spawn.
413
+ for (const f of [".credentials.json", "mcp-needs-auth-cache.json"]) {
414
+ try {
415
+ await rm(join(loopClaudeDir(loopId), f), { force: true })
416
+ } catch {}
417
+ }
418
+
419
+ // mcpServers come straight from the merged settings.json (workspace +
420
+ // profiles + personal — compose wrote it to loops/<id>/.claude/settings.json
421
+ // above). Any `${VAR}` references in headers / env are substituted by the
422
+ // spawned claude binary against its own process env — which inherits the
423
+ // vault envs we inject into extraEnv below.
424
+ const mergedSettingsPath = join(loopClaudeDir(loopId), "settings.json")
425
+ let mcpServers: Record<string, any> = {}
426
+ if (existsSync(mergedSettingsPath)) {
427
+ try {
428
+ const merged = JSON.parse(await readFile(mergedSettingsPath, "utf8"))
429
+ mcpServers = { ...(merged.mcpServers ?? {}) }
430
+ } catch (e: any) {
431
+ console.warn(`[session ${loopId.slice(0,8)}] could not read merged mcpServers: ${e?.message ?? e}`)
432
+ }
433
+ }
434
+
435
+ // Build sandbox env. Order matters: vault envs first (so the spawned binary
436
+ // can substitute ${VAR} in mcpServers headers passed via SDK options),
437
+ // then platform-controlled vars (which can't be overridden by a stray
438
+ // vault env file).
439
+ const personalCfg = await loadPersonalConfig(driver, meta.config?.vault)
440
+ const extraEnv: Record<string, string> = {
441
+ ...personalCfg.vaultEnvs,
442
+ ANTHROPIC_API_KEY: provider.apiKey,
443
+ ANTHROPIC_BASE_URL: provider.baseUrl,
444
+ CLAUDE_CONFIG_DIR: V_LOOP_CLAUDE(loopId),
445
+ }
446
+ // Override cli's hardcoded model→context-window map for gateway-routed
447
+ // models. Both env vars are required (cli checks DISABLE_COMPACT first
448
+ // to enable the override path, then reads CLAUDE_CODE_MAX_CONTEXT_TOKENS).
449
+ // Per-model override takes precedence over provider-level.
450
+ //
451
+ // Resolve the active model: loop meta override first, then personal
452
+ // config default model, then first enabled, then models[0].
453
+ let modelId: string | undefined = meta.config?.default_model_id
454
+ if (!modelId) {
455
+ const pCfg = await loadPersonalConfig(driver, meta.config?.vault)
456
+ const defaultParsed = parseDefault(pCfg.default)
457
+ if (defaultParsed.modelId && defaultParsed.providerName === providerName) {
458
+ modelId = defaultParsed.modelId
459
+ }
460
+ }
461
+ const activeModel = (modelId ? provider.models.find(m => m.id === modelId) : undefined)
462
+ ?? provider.models.find(m => m.enabled !== false)
463
+ ?? provider.models[0]
464
+ const contextTokenOverride = activeModel?.maxContextTokens ?? provider.maxContextTokens
465
+ if (contextTokenOverride && contextTokenOverride > 0) {
466
+ extraEnv.DISABLE_COMPACT = "1"
467
+ extraEnv.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(contextTokenOverride)
468
+ }
469
+ // Mise toolchain: baked into the per-loop image at ensureLoopImage
470
+ // build time. The image's ENV puts /opt/loopat-mise/shims on PATH, so
471
+ // every process inside the container (SDK + PTY) finds the right
472
+ // toolchain — no host-side activation needed here.
473
+ //
474
+ // Ensure the per-loop podman container exists and is running. Idempotent:
475
+ // if the container is already up with the same config-hash, no-op. Both
476
+ // this SDK driver AND the PTY (term.ts) call ensureContainer with the
477
+ // same options, so they end up sharing one container (same PID / Mount /
478
+ // IPC namespace) — that's the whole point of the podman refactor.
479
+ await ensureContainer({
480
+ loopId,
481
+ createdBy: driver,
482
+ vaultName: meta.config?.vault,
483
+ knowledgeRw: meta.config?.knowledge_rw,
484
+ mountAllLoops: meta.config?.mount_all_loops,
485
+ extraEnv,
486
+ ephemeralPorts: loopEphemeralPorts(meta),
487
+ }, {
488
+ onProgress: (msg) => updateLoopStatus(loopId, msg),
489
+ })
490
+ updateLoopStatus(loopId, "Ready")
491
+ // Tell the container lifecycle scheduler that this loop has an active
492
+ // SDK source. Released in destroy() via markInactive(loopId, "sdk").
493
+ markActive(loopId, "sdk")
494
+ const claudeBinary = getClaudeBinary()
495
+ if (DEBUG) {
496
+ const tag = loopId.slice(0, 8)
497
+ console.error(`[sdk:${tag}] config: provider=${providerName} model=${activeModel?.id ?? "?"} baseUrl=${provider.baseUrl} apiKey=${provider.apiKey ? `<set len=${provider.apiKey.length}>` : "<empty>"}`)
498
+ console.error(`[sdk:${tag}] config: continue=${shouldContinue} cwd=${V_LOOP_WORKDIR(loopId)} CLAUDE_CONFIG_DIR=${V_LOOP_CLAUDE(loopId)}`)
499
+ console.error(`[sdk:${tag}] config: binary=${claudeBinary}`)
500
+ }
501
+
502
+ this.q = query({
503
+ prompt: this.input.iter,
504
+ options: {
505
+ cwd: V_LOOP_WORKDIR(loopId),
506
+ env: {
507
+ ...process.env,
508
+ CLAUDE_CONFIG_DIR: V_LOOP_CLAUDE(loopId),
509
+ ANTHROPIC_API_KEY: provider.apiKey,
510
+ ANTHROPIC_BASE_URL: provider.baseUrl,
511
+ },
512
+ model: activeModel?.id ?? "",
513
+ permissionMode: this.currentPermissionMode,
514
+ // Required by SDK when using permissionMode: "bypassPermissions"
515
+ ...(this.currentPermissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } : {}),
516
+ systemPrompt: { type: "preset", preset: "claude_code", append: loopatAppend },
517
+ mcpServers,
518
+ // External marketplace plugins (enabledPlugins in settings.json) are
519
+ // resolved natively by the inner SDK now — ~/.claude/plugins/ is
520
+ // ro-bound wholesale, so installed_plugins.json + each installPath is
521
+ // reachable inside the sandbox. The only thing we still pass via
522
+ // `plugins:` is the loopat-shipped builtin, which lives under
523
+ // LOOPAT_INSTALL_DIR (not in CC's plugin cache).
524
+ plugins: [{ type: "local" as const, path: BUILTIN_LOOPAT_PLUGIN_PATH }],
525
+ stderr: (s) => console.error(`[sdk:${loopId.slice(0, 8)}] ${s.trimEnd()}`),
526
+ pathToClaudeCodeExecutable: claudeBinary,
527
+ canUseTool: async (toolName, input, { toolUseID, signal, title, displayName }) => {
528
+ // ── AskUserQuestion: always broadcast to frontend ──
529
+ if (toolName === "AskUserQuestion") {
530
+ const questions = (input as any)?.questions
531
+ if (!Array.isArray(questions) || questions.length === 0) {
532
+ return { behavior: "allow" as const, updatedInput: {} }
533
+ }
534
+ const questionMsg = {
535
+ type: "question",
536
+ tool_use_id: toolUseID,
537
+ questions,
538
+ }
539
+ this.broadcast(questionMsg)
540
+ return new Promise((resolve, reject) => {
541
+ const timeout = setTimeout(() => {
542
+ this.pendingQuestions.delete(toolUseID)
543
+ reject(new Error("question timed out"))
544
+ }, 300_000)
545
+ this.pendingQuestions.set(toolUseID, {
546
+ toolUseID,
547
+ questions,
548
+ resolve: (result) => {
549
+ clearTimeout(timeout)
550
+ resolve(result)
551
+ },
552
+ reject: (err) => {
553
+ clearTimeout(timeout)
554
+ reject(err)
555
+ },
556
+ })
557
+ signal.addEventListener("abort", () => {
558
+ clearTimeout(timeout)
559
+ this.pendingQuestions.delete(toolUseID)
560
+ reject(new Error("question cancelled"))
561
+ }, { once: true })
562
+ })
563
+ }
564
+
565
+ // ── Safe (read-only) tools: always allow ──
566
+ if (SAFE_TOOLS.has(toolName)) {
567
+ return { behavior: "allow" as const, updatedInput: {} }
568
+ }
569
+
570
+ const mode = this.currentPermissionMode
571
+
572
+ // ── Full-auto modes: allow everything ──
573
+ if (mode === "bypassPermissions" || mode === "auto" || mode === "dontAsk") {
574
+ return { behavior: "allow" as const, updatedInput: {} }
575
+ }
576
+
577
+ // ── acceptEdits: auto-allow file-editing tools; prompt for the rest ──
578
+ if (mode === "acceptEdits" && EDIT_TOOLS.has(toolName)) {
579
+ return { behavior: "allow" as const, updatedInput: {} }
580
+ }
581
+
582
+ // ── default / plan / acceptEdits(non-edit): prompt the user ──
583
+ const promptMsg = {
584
+ type: "permission_prompt",
585
+ tool_use_id: toolUseID,
586
+ tool_name: toolName,
587
+ title: title || `Claude wants to use ${toolName}`,
588
+ displayName: displayName || toolName,
589
+ }
590
+ this.broadcast(promptMsg)
591
+
592
+ return new Promise((resolve, reject) => {
593
+ const timeout = setTimeout(() => {
594
+ this.pendingPermissions.delete(toolUseID)
595
+ resolve({ behavior: "deny" as const, message: "Permission timed out" })
596
+ }, 120_000) // 2 min timeout
597
+ this.pendingPermissions.set(toolUseID, {
598
+ toolUseID,
599
+ toolName,
600
+ promptMsg,
601
+ resolve: (result) => {
602
+ clearTimeout(timeout)
603
+ resolve(result)
604
+ },
605
+ reject: (err) => {
606
+ clearTimeout(timeout)
607
+ reject(err)
608
+ },
609
+ })
610
+ signal.addEventListener("abort", () => {
611
+ clearTimeout(timeout)
612
+ this.pendingPermissions.delete(toolUseID)
613
+ reject(new Error("permission cancelled"))
614
+ }, { once: true })
615
+ })
616
+ },
617
+ // user-tier: read autoMemoryDirectory (/loopat/context/personal/memory)
618
+ // from CLAUDE_CONFIG_DIR/settings.json (SDK auto-memory uses that path).
619
+ // project-tier: auto-load <workdir>/CLAUDE.md so per-repo conventions
620
+ // (e.g. the project's own CLAUDE.md) layer on top of platform doctrine.
621
+ // local-tier: pick up <workdir>/.claude/settings.local.json + .local.md
622
+ // agents/skills so users can drop per-checkout overrides without
623
+ // committing them.
624
+ settingSources: ["user", "project", "local"],
625
+ // Stop hook: when an active goal exists, prevent the session from
626
+ // ending if there is unfinished background work. Mirrors CC's /goal
627
+ // behavior — the hook fires on session-end and blocks the stop while
628
+ // goal-related tasks are still running.
629
+ hooks: {
630
+ Stop: [{
631
+ hooks: [async (input) => {
632
+ const si = input as StopHookInput
633
+ const hasGoal = !!this.currentGoal && this.goalStatus === "active"
634
+ if (!hasGoal) return { continue: true }
635
+ const busy = (si.background_tasks?.length ?? 0) > 0
636
+ if (busy) {
637
+ return {
638
+ continue: false,
639
+ stopReason: `goal "${this.currentGoal}" still in progress — background work is running`,
640
+ }
641
+ }
642
+ // No background work and model is stopping naturally (first
643
+ // invocation). Auto-complete the goal — matches CC's /goal
644
+ // behavior where finishing the task marks the goal done.
645
+ if (!si.stop_hook_active) {
646
+ this.completeGoal()
647
+ }
648
+ return { continue: true }
649
+ }],
650
+ }],
651
+ },
652
+ // Inner SDK sandbox disabled — outer podman container wraps everything;
653
+ // bash subprocesses from the Bash tool inherit the same namespace via
654
+ // exec-in-container. No nested sandbox needed.
655
+ sandbox: { enabled: false },
656
+ // Spawn CLI inside the per-loop podman container via `podman exec`.
657
+ spawnClaudeCodeProcess: ({ command, args, signal }) => {
658
+ // SDK has already injected the resolved plugins via its `plugins`
659
+ // option → `--plugin-dir <path>` flags in `args`. We just wrap +
660
+ // spawn here.
661
+ //
662
+ // `interactive: true` is CRITICAL — without `-i` on `podman exec`,
663
+ // stdin from the host (which the SDK uses to push user messages
664
+ // as stream-json) is NOT forwarded to the claude binary inside
665
+ // the container, so claude reads EOF and exits immediately with
666
+ // code 0 producing no output ("chat sends but never responds").
667
+ // NO `tty: true` though — SDK speaks line-delimited stream-json
668
+ // over pipes, not via PTY.
669
+ const spawnBinary = process.env.LOOPAT_PODMAN_BIN || "podman"
670
+ const fullArgs = buildPodmanExecArgs({
671
+ loopId,
672
+ command,
673
+ args,
674
+ env: extraEnv,
675
+ workdir: V_LOOP_WORKDIR(loopId),
676
+ interactive: true,
677
+ })
678
+ const tag = loopId.slice(0, 8)
679
+ // Always tee stderr to a per-loop file so it survives terminal
680
+ // truncation (bun --filter, tools that elide). Path also printed
681
+ // on non-zero exit.
682
+ mkdirSync(loopDir(loopId), { recursive: true })
683
+ const stderrLogPath = join(loopDir(loopId), "stderr.log")
684
+ const stderrFile = createWriteStream(stderrLogPath, { flags: "a" })
685
+ stderrFile.write(`\n=== ${new Date().toISOString()} spawn ===\n`)
686
+ stderrFile.write(`binary: ${command}\n`)
687
+ stderrFile.write(`${spawnBinary} argc: ${fullArgs.length}\n`)
688
+ if (DEBUG) {
689
+ const argvLine = `${spawnBinary} ${fullArgs.map((a) => (a.includes(" ") ? JSON.stringify(a) : a)).join(" ")}`
690
+ console.error(`[sdk:${tag}] binary: ${command}`)
691
+ console.error(`[sdk:${tag}] spawn cmd: ${argvLine}`)
692
+ stderrFile.write(`argv: ${argvLine}\n`)
693
+ }
694
+
695
+ const proc = nodeSpawn(spawnBinary, fullArgs, {
696
+ stdio: ["pipe", "pipe", "pipe"],
697
+ signal,
698
+ })
699
+
700
+ if (DEBUG) {
701
+ console.error(`[sdk:${tag}] spawned pid=${proc.pid}`)
702
+ }
703
+ proc.on("error", (e) => {
704
+ console.error(`[sdk:${tag}] spawn error:`, e?.message ?? e)
705
+ stderrFile.write(`spawn error: ${e?.message ?? e}\n`)
706
+ })
707
+
708
+ // pipe stderr to file (always) and to console (always, lossy if
709
+ // terminal eats it, lossless via the file).
710
+ proc.stderr?.on("data", (chunk: Buffer) => {
711
+ stderrFile.write(chunk)
712
+ const text = chunk.toString("utf8")
713
+ for (const line of text.split("\n")) {
714
+ if (line.trim()) console.error(`[sdk:${tag}:stderr] ${line}`)
715
+ }
716
+ })
717
+
718
+ if (DEBUG) {
719
+ // mirror stdout too — useful for seeing the SDK protocol if the
720
+ // SDK itself isn't surfacing what came back. Capped to avoid
721
+ // flooding when chat is healthy.
722
+ proc.stdout?.on("data", (chunk: Buffer) => {
723
+ const s = chunk.toString("utf8")
724
+ const head = s.length > 400 ? s.slice(0, 400) + `…+${s.length - 400}b` : s
725
+ for (const line of head.split("\n")) {
726
+ if (line.trim()) console.error(`[sdk:${tag}:stdout] ${line}`)
727
+ }
728
+ })
729
+ }
730
+
731
+ proc.on("exit", (code, sig) => {
732
+ stderrFile.end(`=== exit code=${code} sig=${sig ?? ""} ===\n`)
733
+ if (code !== 0 && code !== null) {
734
+ console.error(`[sdk:${tag}] child exited code=${code}${sig ? ` sig=${sig}` : ""}; full stderr at ${stderrLogPath}`)
735
+ } else if (DEBUG) {
736
+ console.error(`[sdk:${tag}] child exited code=${code}${sig ? ` sig=${sig}` : ""}`)
737
+ }
738
+ })
739
+ return proc as any
740
+ },
741
+ // Stream text deltas + tool progress to the UI for live visibility.
742
+ includePartialMessages: true,
743
+ ...(shouldContinue ? { continue: true } : {}),
744
+ },
745
+ })
746
+ this.consume(this.q)
747
+ }
748
+
749
+ private async consume(q: Query) {
750
+ this.consuming = true
751
+ const tag = this.id.slice(0, 8)
752
+ // Set after we receive a `result` message — at that point the turn is
753
+ // semantically complete. If the SDK subsequently throws (e.g. the SIGKILL
754
+ // dance against an idle claude binary that podman exec can't forward
755
+ // SIGTERM into), the error is cleanup noise, not a real failure — we
756
+ // suppress it from the user-visible history / broadcast.
757
+ let resultReceived = false
758
+ try {
759
+ for await (const msg of q) {
760
+ if (DEBUG) {
761
+ const subtype = (msg as any).subtype ? `/${(msg as any).subtype}` : ""
762
+ const event = (msg as any).event?.type ? ` event=${(msg as any).event.type}` : ""
763
+ console.error(`[sdk:${tag}] msg ${msg.type}${subtype}${event}`)
764
+ }
765
+ // Track generating state: init → true, result → false
766
+ if (msg.type === "system" && (msg as any).subtype === "init") {
767
+ this.generating = true
768
+ } else if (msg.type === "result") {
769
+ this.generating = false
770
+ this.queueProcessing = false
771
+ this.q = null
772
+ this.processNextInQueue()
773
+ resultReceived = true
774
+ } else if (
775
+ // Inject queued messages at tool-result boundaries — matching
776
+ // real Claude Code's per-step queue consumption.
777
+ this.messageQueue.length > 0 &&
778
+ msg.type === "user" &&
779
+ Array.isArray((msg as any).message?.content) &&
780
+ (msg as any).message.content.some((b: any) => b?.type === "tool_result")
781
+ ) {
782
+ this.generating = false
783
+ this.queueProcessing = false
784
+ await q.interrupt().catch(() => {})
785
+ this.q = null
786
+ this.processNextInQueue()
787
+ return
788
+ }
789
+
790
+ // ephemeral live-feed events: don't persist or replay; just broadcast
791
+ // so already-attached clients see the streaming.
792
+ const ephemeral = msg.type === "stream_event" || msg.type === "tool_progress"
793
+ if (!ephemeral) {
794
+ this.history.push(msg)
795
+ this.persist(msg)
796
+ }
797
+ this.broadcast(msg)
798
+ this.updateStatus(msg)
799
+ }
800
+ } catch (e: any) {
801
+ const msg = e?.message ?? String(e)
802
+ // Post-result cleanup noise: the SDK kills the idle claude child after
803
+ // ~7s (SIGTERM → 5s grace → SIGKILL). With our podman-exec wrapper, the
804
+ // host-side SIGTERM doesn't propagate to claude inside the container,
805
+ // so the SIGKILL fires and podman reports exit 137. The turn already
806
+ // succeeded — don't pollute history or alarm the frontend.
807
+ if (resultReceived && /exited with code|aborted by user|terminated by signal/.test(msg)) {
808
+ console.warn(`[sdk:${tag}] post-result cleanup noise (suppressed): ${msg}`)
809
+ } else {
810
+ console.error(`[sdk:${tag}] consume error:`, msg)
811
+ if (DEBUG && e?.stack) console.error(e.stack)
812
+ const err = { type: "error", message: msg }
813
+ this.history.push(err as any)
814
+ this.persist(err)
815
+ this.broadcast(err)
816
+ }
817
+ } finally {
818
+ // If a new Query was started by processNextInQueue() above, skip cleanup —
819
+ // the new consume owns the lifecycle from here on.
820
+ if (this.q !== q) return
821
+ this.consuming = false
822
+ this.generating = false
823
+ this.queueProcessing = false
824
+ this.q = null
825
+ this.input = pushIterable<SDKUserMessage>()
826
+ // Emit a result marker so the frontend knows the run is done,
827
+ // even if the generator ended without one (e.g. after interrupt).
828
+ const result = { type: "result" as const }
829
+ this.history.push(result as any)
830
+ this.broadcast(result)
831
+ if (this.subscribers.size === 0) this.scheduleIdleCleanup()
832
+ }
833
+ }
834
+
835
+ private persist(msg: any) {
836
+ const stamped = { ...msg, _ts: new Date().toISOString() }
837
+ appendFile(loopHistoryPath(this.id), JSON.stringify(stamped) + "\n").catch((e) => {
838
+ console.error("[loopat] persist failed", e)
839
+ })
840
+ }
841
+
842
+ private broadcast(msg: any) {
843
+ for (const listener of this.messageListeners) {
844
+ try { listener(msg) } catch {}
845
+ }
846
+ const data = JSON.stringify(msg)
847
+ for (const [ws, state] of this.subscribers) {
848
+ if (state.pending !== null) {
849
+ state.pending.push(msg)
850
+ continue
851
+ }
852
+ try {
853
+ ws.send(data)
854
+ } catch {}
855
+ }
856
+ }
857
+
858
+ private broadcastViewers() {
859
+ const msg = { type: "viewers", count: this.subscribers.size }
860
+ const data = JSON.stringify(msg)
861
+ for (const [ws, state] of this.subscribers) {
862
+ if (state.pending !== null) continue
863
+ try {
864
+ ws.send(data)
865
+ } catch {}
866
+ }
867
+ }
868
+
869
+ private updateStatus(msg: any) {
870
+ // 1. 用户输入状态
871
+ if (msg.type === "user") {
872
+ const text = typeof msg.content === "string" ? msg.content : msg.content?.[0]?.text || ""
873
+ if (text) {
874
+ updateLoopStatus(this.id, `User: ${text.slice(0, 50)}${text.length > 50 ? "..." : ""}`)
875
+ }
876
+ return
877
+ }
878
+
879
+ // 2. AI 响应状态 (assistant 消息)
880
+ if (msg.type === "assistant") {
881
+ const content = Array.isArray(msg.content) ? msg.content : []
882
+ // 优先捕获 tool_use 或 thinking
883
+ for (const block of content) {
884
+ if (block.type === "tool_use") {
885
+ updateLoopStatus(this.id, `Using ${block.name || "tool"}...`)
886
+ return
887
+ }
888
+ if (block.type === "thinking" || block.type === "reasoning") {
889
+ updateLoopStatus(this.id, "Thinking...")
890
+ return
891
+ }
892
+ }
893
+ // 其次捕获文本输出
894
+ const textBlock = content.find((b: any) => b.type === "text")
895
+ if (textBlock?.text) {
896
+ const text = textBlock.text
897
+ const preview = text.trim().slice(-60).replace(/\n/g, " ")
898
+ updateLoopStatus(this.id, preview || "Generating...")
899
+ }
900
+ return
901
+ }
902
+
903
+ // 3. Stream events (Real-time updates)
904
+ if (msg.type === "stream_event") {
905
+ const evt = msg.event || msg.data
906
+ if (evt?.type === "content_block_start") {
907
+ const block = evt.content_block || evt.data
908
+ if (block?.type === "tool_use") {
909
+ updateLoopStatus(this.id, `Using ${block.name || "tool"}...`)
910
+ return
911
+ }
912
+ if (block?.type === "thinking") {
913
+ updateLoopStatus(this.id, "Thinking...")
914
+ return
915
+ }
916
+ }
917
+ if (evt?.type === "content_block_delta") {
918
+ const delta = evt.delta || evt.data
919
+ if (delta?.type === "text" && delta.text) {
920
+ updateLoopStatus(this.id, delta.text.slice(-60).replace(/\n/g, " "))
921
+ return
922
+ }
923
+ }
924
+ return
925
+ }
926
+
927
+ // 4. 兼容独立事件类型
928
+ if (msg.type === "tool_use" || msg.type === "tool_call") {
929
+ updateLoopStatus(this.id, `Using ${msg.name || msg.tool_name || "tool"}...`)
930
+ return
931
+ }
932
+ if (msg.type === "thinking" || msg.type === "reasoning") {
933
+ updateLoopStatus(this.id, "Thinking...")
934
+ return
935
+ }
936
+ if (msg.type === "content_block_start" || msg.type === "content_block_delta") {
937
+ const delta = msg.delta || msg.content_block
938
+ if (delta?.type === "tool_use") {
939
+ updateLoopStatus(this.id, `Using ${delta.name || "tool"}...`)
940
+ } else if (delta?.type === "thinking" || delta?.type === "reasoning") {
941
+ updateLoopStatus(this.id, "Thinking...")
942
+ } else if (delta?.type === "text" && delta.text) {
943
+ updateLoopStatus(this.id, delta.text.slice(-60).replace(/\n/g, " "))
944
+ }
945
+ return
946
+ }
947
+
948
+ // 5. 结束状态
949
+ if (msg.type === "result" || msg.stop_reason || msg.type === "message_stop") {
950
+ updateLoopStatus(this.id, "Done")
951
+ }
952
+ }
953
+
954
+ /**
955
+ * Read subdirectory names from a path — silently returns [] if missing.
956
+ * Includes symlinks (composeTier creates symlinks-to-dirs under
957
+ * .claude/plugins/cache/, which isDirectory() reports as false).
958
+ */
959
+ private async listDirNames(dir: string): Promise<string[]> {
960
+ try {
961
+ const entries = await readdir(dir, { withFileTypes: true })
962
+ return entries
963
+ .filter((e) => (e.isDirectory() || e.isSymbolicLink()) && !e.name.startsWith("."))
964
+ .map((e) => e.name)
965
+ } catch {
966
+ return []
967
+ }
968
+ }
969
+
970
+ /**
971
+ * Build a best-effort list of slash commands from the loop's workspace /
972
+ * personal config. Used to seed the frontend before CC's real init arrives.
973
+ * Includes well-known CC builtins, loose skills from knowledge/personal,
974
+ * AND plugin sub-commands (<plugin>:<skill>) read from the same paths the
975
+ * SDK is about to load — so the menu is complete on first open, not after
976
+ * the first message has triggered a spawn.
977
+ */
978
+ private async buildInitialSlashCommands(user: string): Promise<{ name: string; description: string }[]> {
979
+ const map = new Map<string, string>()
980
+ // CC built-in commands (descriptions handled by frontend's local COMMANDS)
981
+ for (const c of ["help", "model", "clear", "compress", "review", "init", "foxtrot"]) {
982
+ if (!map.has(c)) map.set(c, "")
983
+ }
984
+ // Workspace skills
985
+ for (const name of await this.listDirNames(workspaceTeamSkillsDir())) {
986
+ if (!map.has(name)) {
987
+ map.set(name, await readSkillDescription(workspaceTeamSkillsDir(), name))
988
+ }
989
+ }
990
+ // Personal skills (higher precedence)
991
+ for (const name of await this.listDirNames(personalSkillsDir(user))) {
992
+ map.set(name, await readSkillDescription(personalSkillsDir(user), name))
993
+ }
994
+ // Plugin sub-commands: scan each enabled plugin's skills/ dir on the host
995
+ // and surface as `<plugin>:<skill>`. Best-effort pre-spawn seed so the
996
+ // chip shows useful numbers before CC's init payload arrives; CC's init
997
+ // is the authoritative list.
998
+ try {
999
+ const settingsPath = join(loopClaudeDir(this.id), "settings.json")
1000
+ if (existsSync(settingsPath)) {
1001
+ const settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
1002
+ enabledPlugins?: Record<string, boolean>
1003
+ }
1004
+ const enabled = Object.entries(settings.enabledPlugins ?? {})
1005
+ .filter(([_, v]) => v)
1006
+ .map(([k]) => k)
1007
+ for (const spec of enabled) {
1008
+ const pluginPath = await lookupPluginInstallPath(spec)
1009
+ if (!pluginPath) continue
1010
+ const pluginName = spec.split("@")[0]
1011
+ const skillsDir = join(pluginPath, "skills")
1012
+ for (const skill of await this.listDirNames(skillsDir)) {
1013
+ map.set(`${pluginName}:${skill}`, await readSkillDescription(skillsDir, skill))
1014
+ }
1015
+ }
1016
+ }
1017
+ } catch (e: any) {
1018
+ console.warn(`[session ${this.id.slice(0,8)}] seed plugin scan failed: ${e?.message ?? e}`)
1019
+ }
1020
+ return [...map.entries()]
1021
+ .map(([name, description]) => ({ name, description }))
1022
+ .sort((a, b) => a.name.localeCompare(b.name))
1023
+ }
1024
+
1025
+ async attach(ws: WSContext) {
1026
+ await this.historyLoaded
1027
+ const state: SubscriberState = { pending: [] }
1028
+ this.subscribers.set(ws, state)
1029
+ // Send active provider info up-front so UI can render badge + true context window.
1030
+ try {
1031
+ const meta = await getLoop(this.id)
1032
+ if (meta) {
1033
+ const resolved = await this.resolveProvider(meta, [
1034
+ this.providerOverride,
1035
+ meta.config?.default_model,
1036
+ ], false)
1037
+ if (resolved) {
1038
+ let attachModelId: string | undefined = meta.config?.default_model_id
1039
+ if (!attachModelId) {
1040
+ try {
1041
+ const driver = effectiveDriver(meta)
1042
+ const pCfg = await loadPersonalConfig(driver, meta.config?.vault)
1043
+ const defaultParsed = parseDefault(pCfg.default)
1044
+ if (defaultParsed.modelId && defaultParsed.providerName === resolved.name) {
1045
+ attachModelId = defaultParsed.modelId
1046
+ }
1047
+ } catch {}
1048
+ }
1049
+ const activeModel = (attachModelId ? resolved.provider.models.find(m => m.id === attachModelId) : undefined)
1050
+ ?? resolved.provider.models.find(m => m.enabled !== false)
1051
+ ?? resolved.provider.models[0]
1052
+ const activeModelId = activeModel?.id ?? ""
1053
+ ws.send(JSON.stringify({
1054
+ type: "provider",
1055
+ name: resolved.name,
1056
+ model: activeModelId,
1057
+ models: resolved.provider.models,
1058
+ contextWindow: resolveContextWindow(resolved.provider, activeModelId),
1059
+ }))
1060
+ } else {
1061
+ console.warn(`[loop:${this.id.slice(0, 8)}] no provider found in personal or workspace config`)
1062
+ }
1063
+ // Restore persisted permission mode
1064
+ const pm = meta.config?.permission_mode
1065
+ if (isValidMode(pm) && pm !== this.currentPermissionMode) {
1066
+ this.currentPermissionMode = pm
1067
+ if (this.q) {
1068
+ try { await this.q.setPermissionMode(pm) } catch {}
1069
+ }
1070
+ }
1071
+ // Tell frontend the current mode so it can sync its selector
1072
+ ws.send(JSON.stringify({
1073
+ type: "permission_mode",
1074
+ mode: this.currentPermissionMode,
1075
+ }))
1076
+ // Send current goal so reconnecting clients see it
1077
+ if (this.currentGoal) {
1078
+ try {
1079
+ ws.send(JSON.stringify({ type: "goal", goal: this.currentGoal, setAt: this.goalSetAt }))
1080
+ } catch {}
1081
+ }
1082
+ }
1083
+ } catch (e: any) {
1084
+ console.error(`[loop:${this.id.slice(0, 8)}] attach provider error:`, e?.message ?? e)
1085
+ }
1086
+ const snapshot = this.history.slice()
1087
+ for (const m of snapshot) {
1088
+ try {
1089
+ ws.send(JSON.stringify(m))
1090
+ } catch {}
1091
+ }
1092
+ if (state.pending) {
1093
+ for (const m of state.pending) {
1094
+ try {
1095
+ ws.send(JSON.stringify(m))
1096
+ } catch {}
1097
+ }
1098
+ state.pending = null
1099
+ }
1100
+ // history_end — signals the frontend that replay is done.
1101
+ // When not generating, also embed a best-effort slash-command list
1102
+ // so the / menu works immediately (before CC starts). CC's real
1103
+ // system/init replaces this with the accurate list later.
1104
+ const meta = await getLoop(this.id)
1105
+ if (this.generating) {
1106
+ try {
1107
+ ws.send(JSON.stringify({ type: "history_end" }))
1108
+ } catch {}
1109
+ // The frontend also needs a synthetic init to show running status
1110
+ // (the history-replayed init was ignored during loadingHistory).
1111
+ try {
1112
+ ws.send(JSON.stringify({ type: "system", subtype: "init" }))
1113
+ } catch {}
1114
+ } else {
1115
+ const user = meta?.createdBy
1116
+ const slashCommands = user ? await this.buildInitialSlashCommands(user) : undefined
1117
+ try {
1118
+ ws.send(JSON.stringify({ type: "history_end", slash_commands: slashCommands }))
1119
+ } catch {}
1120
+ }
1121
+ // Re-broadcast active permission prompts that survived history replay
1122
+ for (const [_, pending] of this.pendingPermissions) {
1123
+ try {
1124
+ ws.send(JSON.stringify(pending.promptMsg))
1125
+ } catch {}
1126
+ }
1127
+ // Re-broadcast active AskUserQuestion prompts
1128
+ for (const [_id, pending] of this.pendingQuestions) {
1129
+ try {
1130
+ ws.send(JSON.stringify({
1131
+ type: "question",
1132
+ tool_use_id: pending.toolUseID,
1133
+ questions: pending.questions,
1134
+ }))
1135
+ } catch {}
1136
+ }
1137
+ // Send current queue status to reconnected clients
1138
+ if (this.messageQueue.length > 0) {
1139
+ try { ws.send(JSON.stringify({ type: "queue_update", queue: this.messageQueue.map(m => m.text) })) } catch {}
1140
+ }
1141
+ this.cancelIdleCleanup()
1142
+ this.broadcastViewers()
1143
+ console.log(`[loop:${this.id.slice(0, 8)}] attach → viewers=${this.subscribers.size}`)
1144
+ }
1145
+
1146
+ detach(ws: WSContext) {
1147
+ this.subscribers.delete(ws)
1148
+ this.broadcastViewers()
1149
+ if (this.subscribers.size === 0) this.scheduleIdleCleanup()
1150
+ console.log(`[loop:${this.id.slice(0, 8)}] detach → viewers=${this.subscribers.size}`)
1151
+ }
1152
+
1153
+ async sendUserText(text: string, permissionMode?: SdkPermissionMode) {
1154
+ updateLoopStatus(this.id, `User: ${text.slice(0, 50)}${text.length > 50 ? "..." : ""}`)
1155
+ if (this.generating || this.messageQueue.length > 0 || this.queueProcessing) {
1156
+ this.messageQueue.push({ text, permissionMode })
1157
+ this.broadcast({ type: "queue_update", queue: this.messageQueue.map(m => m.text) })
1158
+ return
1159
+ }
1160
+ await this._pushUserMessage(text, permissionMode)
1161
+ }
1162
+
1163
+ isBusy(): boolean {
1164
+ return this.generating || this.messageQueue.length > 0 || this.queueProcessing
1165
+ }
1166
+
1167
+ onMessage(listener: LoopSessionMessageListener): () => void {
1168
+ this.messageListeners.add(listener)
1169
+ return () => {
1170
+ this.messageListeners.delete(listener)
1171
+ }
1172
+ }
1173
+
1174
+ /** Push a synthetic message through the broadcast pipeline. Used by the
1175
+ * v1 API to dispatch control events (choice_resolved / interrupted) to
1176
+ * all active SSE listeners without polluting persisted history. */
1177
+ notifyListeners(msg: any): void {
1178
+ for (const listener of this.messageListeners) {
1179
+ try { listener(msg) } catch {}
1180
+ }
1181
+ }
1182
+
1183
+ hasPendingPermission(toolUseId: string): boolean {
1184
+ return this.pendingPermissions.has(toolUseId)
1185
+ }
1186
+
1187
+ hasPendingQuestion(toolUseId: string): boolean {
1188
+ return this.pendingQuestions.has(toolUseId)
1189
+ }
1190
+
1191
+ private async _pushUserMessage(text: string, permissionMode?: SdkPermissionMode) {
1192
+ if (permissionMode && permissionMode !== this.currentPermissionMode) {
1193
+ this.currentPermissionMode = permissionMode
1194
+ patchLoopMeta(this.id, { config: { permission_mode: permissionMode } }).catch(() => {})
1195
+ if (this.q) {
1196
+ try { await this.q.setPermissionMode(permissionMode) } catch {}
1197
+ }
1198
+ }
1199
+ // Driver-handoff preamble: if POST /api/loops/:id/drive set a one-shot
1200
+ // pendingDriverNote, prepend a system-style line to this user message so
1201
+ // the model knows the human it's talking to has just changed. Cleared
1202
+ // atomically before ensureStarted so a transient crash doesn't leak it
1203
+ // into a second message.
1204
+ const meta = await getLoop(this.id)
1205
+ if (meta?.pendingDriverNote) {
1206
+ const { from, to, at } = meta.pendingDriverNote
1207
+ text = `[loopat] Driver handoff: this loop was previously driven by ${from}; from now on the active driver is ${to} (handoff at ${at}). The user you're now talking to may differ from the one who started the conversation.\n\n${text}`
1208
+ await patchLoopMeta(this.id, { pendingDriverNote: undefined }).catch(() => {})
1209
+ }
1210
+ await this.ensureStarted()
1211
+ const userMsg: SDKUserMessage = {
1212
+ type: "user",
1213
+ message: { role: "user", content: text },
1214
+ parent_tool_use_id: null,
1215
+ uuid: randomUUID(),
1216
+ }
1217
+ this.history.push(userMsg)
1218
+ this.persist(userMsg)
1219
+ this.broadcast(userMsg)
1220
+ this.input.push(userMsg)
1221
+ }
1222
+
1223
+ /** Process the next queued message. Called from consume()'s finally block
1224
+ * after each generation completes. Only starts the next message; subsequent
1225
+ * messages are handled recursively by consume()'s finally. */
1226
+ private processNextInQueue() {
1227
+ if (this.queueProcessing) return // already processing
1228
+ if (this.messageQueue.length === 0) {
1229
+ this.broadcast({ type: "queue_update", queue: [] })
1230
+ return
1231
+ }
1232
+ this.queueProcessing = true
1233
+ const next = this.messageQueue.shift()!
1234
+ this.broadcast({ type: "queue_update", queue: this.messageQueue.map(m => m.text) })
1235
+ this._pushUserMessage(next.text, next.permissionMode).catch((e) => {
1236
+ console.error("[loopat] queued message failed:", e)
1237
+ this.queueProcessing = false
1238
+ // Try next message on failure
1239
+ if (this.messageQueue.length > 0) this.processNextInQueue()
1240
+ else this.broadcast({ type: "queue_update", queue: [] })
1241
+ })
1242
+ }
1243
+
1244
+ async answerQuestions(toolUseID: string, answers: Record<string, string>) {
1245
+ const pending = this.pendingQuestions.get(toolUseID)
1246
+ if (!pending) return
1247
+ this.pendingQuestions.delete(toolUseID)
1248
+ // Include original questions alongside answers so the CLI tool receives both
1249
+ pending.resolve({ behavior: "allow", updatedInput: { questions: pending.questions, answers } })
1250
+ }
1251
+
1252
+ async answerPermission(toolUseID: string, allow: boolean) {
1253
+ const pending = this.pendingPermissions.get(toolUseID)
1254
+ if (!pending) return
1255
+ this.pendingPermissions.delete(toolUseID)
1256
+ if (allow) {
1257
+ pending.resolve({ behavior: "allow", updatedInput: {} })
1258
+ } else {
1259
+ pending.resolve({ behavior: "deny", message: "User denied permission" })
1260
+ }
1261
+ }
1262
+
1263
+ async setMaxThinkingTokens(tokens: number | null) {
1264
+ if (this.q) {
1265
+ try { await this.q.setMaxThinkingTokens(tokens) } catch {}
1266
+ }
1267
+ }
1268
+
1269
+ async getContextUsage() {
1270
+ if (!this.q) return null
1271
+ try {
1272
+ return await this.q.getContextUsage()
1273
+ } catch {
1274
+ return null
1275
+ }
1276
+ }
1277
+
1278
+ async interrupt() {
1279
+ this.generating = false
1280
+ if (this.q) await this.q.interrupt().catch(() => {})
1281
+ }
1282
+
1283
+ getQueueLength(): number {
1284
+ return this.messageQueue.length
1285
+ }
1286
+
1287
+ removeQueueItem(index: number) {
1288
+ if (index >= 0 && index < this.messageQueue.length) {
1289
+ this.messageQueue.splice(index, 1)
1290
+ this.broadcast({ type: "queue_update", queue: this.messageQueue.map(m => m.text) })
1291
+ }
1292
+ }
1293
+
1294
+ clearQueue() {
1295
+ this.messageQueue = []
1296
+ this.queueProcessing = false
1297
+ this.broadcast({ type: "queue_update", queue: [] })
1298
+ }
1299
+
1300
+ /** Tear down the SDK process and disconnect all subscribers. Used when a
1301
+ * loop is archived so no orphaned processes remain. */
1302
+ async destroy() {
1303
+ this.cancelIdleCleanup()
1304
+ this.generating = false
1305
+ this.queueProcessing = false
1306
+ this.messageQueue = []
1307
+ sessions.delete(this.id)
1308
+ // Release the SDK side of the container activity registry; the container
1309
+ // will be `podman stop`'d after CONTAINER_IDLE_MS unless something else
1310
+ // (e.g. PTY subscribers) keeps it active.
1311
+ markInactive(this.id, "sdk")
1312
+ if (this.q) {
1313
+ try { await this.q.interrupt() } catch {}
1314
+ this.q = null
1315
+ }
1316
+ for (const [, pending] of this.pendingQuestions) {
1317
+ pending.reject(new Error("loop archived"))
1318
+ }
1319
+ this.pendingQuestions.clear()
1320
+ const closeMsg = JSON.stringify({ type: "error", message: "loop archived" })
1321
+ for (const [ws] of this.subscribers) {
1322
+ try { ws.send(closeMsg) } catch {}
1323
+ try { ws.close() } catch {}
1324
+ }
1325
+ this.subscribers.clear()
1326
+ }
1327
+
1328
+ /**
1329
+ * Equivalent to CC TUI's `/clear`: ends the in-flight SDK conversation
1330
+ * and makes the next message start with zero AI context — while keeping
1331
+ * old session jsonls intact (still resumable via `claude --resume`).
1332
+ *
1333
+ * Mechanism: touch a fresh empty `<new-uuid>.jsonl` in the same
1334
+ * `projects/<encoded-cwd>/` dir(s) the SDK uses. `claude --continue`
1335
+ * picks "the most recent" jsonl by mtime, so on the next query it finds
1336
+ * this empty file and resumes with 0 prior turns. Older jsonls stay in
1337
+ * place — `claude --resume` still lists them, matching CC behavior. No
1338
+ * persistent session-id state is needed.
1339
+ *
1340
+ * messages.jsonl (our chat record) is NOT modified beyond appending a
1341
+ * `clear-boundary` marker. Marker broadcasts to clients (UI divider),
1342
+ * persists to disk (segments the log into per-session ranges), and is
1343
+ * visible to future readers (humans + AI) so they can tell which
1344
+ * messages belong to which SDK session window.
1345
+ */
1346
+ /**
1347
+ * Strip all `thinking` / `redacted_thinking` content blocks from every
1348
+ * SDK jsonl in this loop. Used before swapping to a provider that won't
1349
+ * recognize the existing thinking signatures (different baseUrl / account
1350
+ * / gateway). The plain user/assistant text stays — the AI's context is
1351
+ * preserved minus the cryptographically-signed reasoning chains, which
1352
+ * are useless to the new provider anyway.
1353
+ *
1354
+ * Originals backed up to `.claude/projects-archive/<ts>/<sub>/<file>`.
1355
+ * Returns the number of blocks stripped across all sessions.
1356
+ *
1357
+ * Side effects: interrupts current query and resets the pushIterable so
1358
+ * the next sendUserText spawns fresh against the rewritten jsonl.
1359
+ */
1360
+ async stripThinkingBlocks(): Promise<{ stripped: number; sessionsTouched: number }> {
1361
+ if (this.q) {
1362
+ try { await this.q.interrupt() } catch {}
1363
+ this.q = null
1364
+ this.input = pushIterable<SDKUserMessage>()
1365
+ }
1366
+ const projectsDir = join(loopClaudeDir(this.id), "projects")
1367
+ let stripped = 0
1368
+ let sessionsTouched = 0
1369
+ const ts = new Date().toISOString().replace(/[:.]/g, "-")
1370
+ const archiveDir = join(loopClaudeDir(this.id), "projects-archive", ts)
1371
+ try {
1372
+ const subdirs = await readdir(projectsDir)
1373
+ for (const sub of subdirs) {
1374
+ const subPath = join(projectsDir, sub)
1375
+ const files = await readdir(subPath).catch(() => [])
1376
+ for (const f of files) {
1377
+ if (!f.endsWith(".jsonl")) continue
1378
+ const filePath = join(subPath, f)
1379
+ const raw = await readFile(filePath, "utf8")
1380
+ const lines = raw.split("\n")
1381
+ const out: string[] = []
1382
+ let changed = false
1383
+ for (const line of lines) {
1384
+ if (!line) { out.push(line); continue }
1385
+ try {
1386
+ const obj = JSON.parse(line)
1387
+ const content = obj?.message?.content
1388
+ if (Array.isArray(content)) {
1389
+ const filtered = content.filter((c: any) => c?.type !== "thinking" && c?.type !== "redacted_thinking")
1390
+ if (filtered.length !== content.length) {
1391
+ stripped += content.length - filtered.length
1392
+ obj.message.content = filtered
1393
+ changed = true
1394
+ out.push(JSON.stringify(obj))
1395
+ continue
1396
+ }
1397
+ }
1398
+ out.push(line)
1399
+ } catch {
1400
+ out.push(line)
1401
+ }
1402
+ }
1403
+ if (changed) {
1404
+ sessionsTouched++
1405
+ await mkdir(join(archiveDir, sub), { recursive: true })
1406
+ await writeFile(join(archiveDir, sub, f), raw)
1407
+ await writeFile(filePath, out.join("\n"))
1408
+ }
1409
+ }
1410
+ }
1411
+ } catch {}
1412
+ return { stripped, sessionsTouched }
1413
+ }
1414
+
1415
+ async clear(by: string) {
1416
+ // 1. Stop in-flight generation if any.
1417
+ if (this.q) {
1418
+ try { await this.q.interrupt() } catch {}
1419
+ this.q = null
1420
+ }
1421
+ // 2. Drop SDK context without deleting history. Touch an empty new
1422
+ // jsonl in each existing encoded-cwd subdir so --continue picks it.
1423
+ // If no subdir exists yet (no SDK has spawned in this loop), the
1424
+ // first post-clear message creates one naturally and starts fresh.
1425
+ const projectsDir = join(loopClaudeDir(this.id), "projects")
1426
+ try {
1427
+ const subdirs = await readdir(projectsDir)
1428
+ for (const sub of subdirs) {
1429
+ const newPath = join(projectsDir, sub, randomUUID() + ".jsonl")
1430
+ try { await writeFile(newPath, "") } catch {}
1431
+ }
1432
+ } catch {
1433
+ // projects/ doesn't exist yet — nothing to do; SDK state is already empty
1434
+ }
1435
+ // 3. Append boundary marker (in-memory + jsonl + broadcast).
1436
+ const marker = { type: "clear-boundary" as const, ts: new Date().toISOString(), by }
1437
+ this.history.push(marker as any)
1438
+ this.persist(marker)
1439
+ this.broadcast(marker)
1440
+ }
1441
+ }
1442
+
1443
+ const sessions = new Map<string, LoopSession>()
1444
+
1445
+ /**
1446
+ * Snapshot of in-memory session activity for the admin dashboard.
1447
+ * Only includes loops whose `LoopSession` has been instantiated (i.e. someone
1448
+ * touched them via attach / sendUserText / etc.). Idle loops aren't here.
1449
+ */
1450
+ export function getActivitySnapshot(): Array<{
1451
+ id: string
1452
+ wsCount: number
1453
+ generating: boolean
1454
+ }> {
1455
+ return [...sessions.entries()].map(([id, s]) => ({
1456
+ id,
1457
+ wsCount: (s as any).subscribers.size as number,
1458
+ generating: (s as any).generating as boolean,
1459
+ }))
1460
+ }
1461
+
1462
+ export function getSession(id: string): LoopSession {
1463
+ let s = sessions.get(id)
1464
+ if (!s) {
1465
+ s = new LoopSession(id)
1466
+ sessions.set(id, s)
1467
+ }
1468
+ return s
1469
+ }
1470
+
1471
+ /** Destroy a loop's session if one exists. No-op if there is no active session. */
1472
+ export function destroySession(id: string): boolean {
1473
+ const s = sessions.get(id)
1474
+ if (!s) return false
1475
+ s.destroy()
1476
+ return true
1477
+ }
1478
+
1479
+ /**
1480
+ * Restart the in-memory LoopSession for one loop, if it exists.
1481
+ *
1482
+ * "Restart" means: interrupt the current `query()` so the next user message
1483
+ * re-runs `ensureStarted` — which re-reads vault tokens, `mcpServers`,
1484
+ * provider env, etc. The SDK reads its session JSONL on respawn
1485
+ * (`continue: true`), so conversation history is preserved.
1486
+ *
1487
+ * Returns true if a session was restarted, false if the loop had no active
1488
+ * session (no-op).
1489
+ */
1490
+ export function restartSession(id: string): boolean {
1491
+ const s = sessions.get(id)
1492
+ if (!s) return false
1493
+ s.restartOnNextMessage()
1494
+ return true
1495
+ }
1496
+