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.
- package/LICENSE +201 -0
- package/README.md +194 -0
- package/bin/loopat.mjs +65 -0
- package/package.json +52 -0
- package/server/package.json +22 -0
- package/server/src/api-tokens.ts +161 -0
- package/server/src/api-v1-openapi.ts +363 -0
- package/server/src/api-v1.ts +681 -0
- package/server/src/auth.ts +309 -0
- package/server/src/bootstrap.ts +113 -0
- package/server/src/chat.ts +390 -0
- package/server/src/claude-binary.ts +68 -0
- package/server/src/compose.ts +474 -0
- package/server/src/config.ts +783 -0
- package/server/src/files.ts +173 -0
- package/server/src/git-crypt-key.ts +36 -0
- package/server/src/git-host.ts +104 -0
- package/server/src/github.ts +161 -0
- package/server/src/index.ts +3204 -0
- package/server/src/kanban.ts +810 -0
- package/server/src/loop-stats.ts +225 -0
- package/server/src/loop-status.ts +67 -0
- package/server/src/loops.ts +1832 -0
- package/server/src/mcp-oauth.ts +516 -0
- package/server/src/onboarding.ts +105 -0
- package/server/src/paths.ts +190 -0
- package/server/src/personal-keys.ts +60 -0
- package/server/src/plugin-installer.ts +287 -0
- package/server/src/podman.ts +1216 -0
- package/server/src/presets.ts +30 -0
- package/server/src/profiles.ts +177 -0
- package/server/src/providers.ts +45 -0
- package/server/src/serve.ts +275 -0
- package/server/src/session.ts +1496 -0
- package/server/src/system-prompt.ts +90 -0
- package/server/src/term.ts +211 -0
- package/server/src/tiers.ts +762 -0
- package/server/src/vaults.ts +189 -0
- package/server/src/workspace.ts +501 -0
- package/server/templates/.claude-plugin/marketplace.json +13 -0
- package/server/templates/CLAUDE.md +78 -0
- package/server/templates/loop-kinds/distill/CLAUDE.md +46 -0
- package/server/templates/plugins/loopat/.claude-plugin/plugin.json +5 -0
- package/server/templates/plugins/loopat/skills/onboarding/SKILL.md +266 -0
- package/server/templates/plugins/loopat/skills/promote/SKILL.md +53 -0
- package/server/templates/sandbox/Containerfile +113 -0
- package/web/dist/assets/CodeEditor-BGODueTo.js +49 -0
- package/web/dist/assets/Editor-DMS25Vve.js +1 -0
- package/web/dist/assets/Markdown-CnHbW7WK.js +5 -0
- package/web/dist/assets/MilkdownEditor-nqo9_0v5.js +123 -0
- package/web/dist/assets/Terminal-BrP-ENHg.css +1 -0
- package/web/dist/assets/Terminal-CYWvxYam.js +174 -0
- package/web/dist/assets/index-DM5eO-Tv.js +163 -0
- package/web/dist/assets/index-DxIFezwv.css +1 -0
- package/web/dist/assets/w3c-keyname-BOAvb0qz.js +1 -0
- package/web/dist/favicon.svg +1 -0
- package/web/dist/index.html +14 -0
- 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
|
+
|