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,783 @@
1
+ import { existsSync, statSync } from "node:fs"
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises"
3
+ import { dirname, join } from "node:path"
4
+ import {
5
+ personalLoopatConfigPath,
6
+ personalLoopatDir,
7
+ personalTokenUsagePath,
8
+ personalVaultDir,
9
+ personalVaultEnvPath,
10
+ personalVaultEnvsDir,
11
+ workspaceDir,
12
+ personalSettingsPath,
13
+ } from "./paths"
14
+ import { DEFAULT_VAULT, loadVaultEnvs } from "./vaults"
15
+
16
+ /**
17
+ * MCP server config — shape matches Claude Agent SDK `McpServerConfig`.
18
+ * - stdio: spawn a command (binary must be reachable in sandbox PATH)
19
+ * - http/sse: connect to URL (network is shared with host, no extra bind needed)
20
+ */
21
+ export type McpServerConfig =
22
+ | { type?: "stdio"; command: string; args?: string[]; env?: Record<string, string> }
23
+ | { type: "http"; url: string; headers?: Record<string, string> }
24
+ | { type: "sse"; url: string; headers?: Record<string, string> }
25
+
26
+ /** CC-native marketplace source. We support local + git + github in step 3. */
27
+ export type MarketplaceSource =
28
+ | { source: "local"; path: string }
29
+ | { source: "git"; url: string }
30
+ | { source: "github"; repo: string }
31
+
32
+ export type WorkspaceClaudeJson = {
33
+ mcpServers?: Record<string, McpServerConfig>
34
+ /** Marketplaces to register. CC-native shape: keyed by marketplace name. */
35
+ extraKnownMarketplaces?: Record<string, { source: MarketplaceSource }>
36
+ /** Plugins to enable. CC-native shape: { "name@market": true }. */
37
+ enabledPlugins?: Record<string, boolean>
38
+ }
39
+
40
+ /** A model entry within a provider's model list. */
41
+ export type ModelEntry = {
42
+ id: string
43
+ enabled?: boolean
44
+ /** Per-model context-window override (takes precedence over provider-level). */
45
+ maxContextTokens?: number
46
+ }
47
+
48
+ export type ProviderPreset = {
49
+ name: string
50
+ baseUrl: string
51
+ models: string[]
52
+ }
53
+
54
+ export type MiseToolPreset = {
55
+ name: string
56
+ suggestedVersion: string
57
+ description?: string
58
+ backend?: string
59
+ }
60
+
61
+ export type PresetsData = {
62
+ providerPresets: ProviderPreset[]
63
+ miseToolPresets: MiseToolPreset[]
64
+ }
65
+
66
+ /**
67
+ * On-disk shape of a provider. `apiKey` is a plain string that may contain
68
+ * `${VAR}` references resolved against vault envs at load time. Empty / unset
69
+ * means no key (provider effectively disabled).
70
+ */
71
+ export type ProviderConfigDisk = {
72
+ model?: string // legacy single-model; migrated to models[] on read
73
+ models?: ModelEntry[] // canonical multi-model format
74
+ baseUrl: string
75
+ apiKey?: string
76
+ maxContextTokens?: number
77
+ enabled?: boolean // provider-level toggle, default true
78
+ }
79
+
80
+ /** Runtime/resolved shape — apiKey is the actual string after resolution. */
81
+ export type ProviderConfig = {
82
+ /** Canonical model list (at least one entry after migration). */
83
+ models: ModelEntry[]
84
+ baseUrl: string
85
+ /** Resolved at load time: `${VAR}` references in the disk apiKey are
86
+ * expanded against the active vault's envs/. Empty string if the
87
+ * referenced env doesn't exist (provider effectively disabled). */
88
+ apiKey: string
89
+ /**
90
+ * Override cli's context-window detection for this model. cli has a
91
+ * hardcoded list (DP / XV8 / coral_reef_sonnet predicates) of claude
92
+ * models that get 1M; everything else falls back to DR1=200000. For
93
+ * gateway-routed / non-claude models with larger windows, set this so
94
+ * auto-compact (92% × window) fires at the right point. Activated via
95
+ * env vars DISABLE_COMPACT=1 + CLAUDE_CODE_MAX_CONTEXT_TOKENS=<value>.
96
+ */
97
+ maxContextTokens?: number
98
+ enabled: boolean
99
+ }
100
+
101
+ export type RemoteSpec = {
102
+ /** clone URL; empty string or omitted = local-only, don't clone */
103
+ git?: string
104
+ }
105
+
106
+ /** A repo registered for spawn-loop use, cloned to context/repos/<name>/. */
107
+ export type RepoSpec = {
108
+ name: string
109
+ git: string
110
+ }
111
+
112
+ /** Operator-side mount (workspace config). src is always a literal host path.
113
+ * Operator owns the host, so any path under `~/...`, `$HOME/...`, or `/...`
114
+ * is allowed (modulo `..` traversal). Used for cross-user shared caches
115
+ * (e.g. /etc/pki/ca-trust). */
116
+ export type OperatorMount = {
117
+ src: string
118
+ dst: string
119
+ rw?: boolean
120
+ }
121
+
122
+ /**
123
+ * Workspace config (~/.loopat/config.json): workspace-shared, no per-user content.
124
+ * Hand this file to a clean machine and bootstrap can reconstruct the
125
+ * workspace: clone knowledge/notes/repos from remotes, seed doctrine.
126
+ *
127
+ * Per-user pieces (sandbox, providers, default provider) live in
128
+ * personal/<user>/.loopat/config.json — see PersonalConfig.
129
+ */
130
+ export type WorkspaceConfig = {
131
+ knowledge?: RemoteSpec
132
+ notes?: RemoteSpec
133
+ repos?: RepoSpec[]
134
+ providers?: Record<string, ProviderConfig>
135
+ default?: string
136
+ /** Platform-level git host for personal onboarding. `provider` is a
137
+ * GitHostProvider id (= the extension filename, e.g. "code"); empty/absent
138
+ * means GitHub. `baseUrl` is the API host for self-hosted/internal. */
139
+ gitHost?: { provider?: string; baseUrl?: string; defaultRepo?: string }
140
+ /** Operator-level mounts — any host path. Shared across all loops on this
141
+ * workspace. Only the operator (the host shell user) can edit. */
142
+ mounts?: OperatorMount[]
143
+ /** Domain suffix for workspace serve (e.g. "nip.io"). Defaults to "nip.io". */
144
+ serveDomain?: string
145
+ /** Whether to include port in the share URL. */
146
+ serveWithPort?: boolean
147
+ /** Whether to use HTTPS for share URLs. */
148
+ serveHttps?: boolean
149
+ /** Custom port to show in share URL (does not affect actual server listen port). */
150
+ serveDisplayPort?: number
151
+ /** Enable standard serve (subdomain-based, serve-rs). */
152
+ serveEnabled?: boolean
153
+ /** Enable dynamic port forwarding (port-proxy). */
154
+ serveDynamicEnabled?: boolean
155
+ /** Domain or IP for dynamic port access URLs (empty = auto-detect IP). */
156
+ serveDynamicDomain?: string
157
+ /** Port range for dynamic port forwarding (e.g., "10000-20000"). */
158
+ serveDynamicPortRange?: string
159
+ /** Whether to allow UDP protocol in dynamic port forwarding. */
160
+ serveDynamicUdpEnabled?: boolean
161
+ /** Whether dynamic ports can serve static files from workdir. */
162
+ serveDynamicStaticEnabled?: boolean
163
+ /** Enable ephemeral-port mode: each loop container publishes its share
164
+ * port via `podman -p :<internal>`, kernel-assigned host port, fresh
165
+ * every container restart. No port-proxy involved. */
166
+ serveEphemeralEnabled?: boolean
167
+ /** Domain or IP for ephemeral-port access URLs (empty = auto-detect IP). */
168
+ serveEphemeralDomain?: string
169
+ /** Admin-managed presets for quick-add in provider/mise tool configs. */
170
+ presets?: PresetsData
171
+ }
172
+
173
+ /**
174
+ * Personal config (personal/<user>/.loopat/config.json): per-user, kept in
175
+ * each driver's personal/ tree.
176
+ *
177
+ * On-disk layout:
178
+ * - `providers` is a heterogeneous map: a special key `"default"` carries
179
+ * a string (the active provider name); all other keys map to
180
+ * `ProviderConfigDisk`. We accept the slight type wobble in exchange
181
+ * for keeping every provider-related field under one section, which
182
+ * matches how the Settings UI groups them. No provider is allowed to
183
+ * be literally named "default".
184
+ * - `apiKey` is a plain string that may contain `${VAR}` references. At
185
+ * load time, each `${VAR}` is resolved against the active vault's
186
+ * `envs/<VAR>` file. Unset → empty string (provider effectively off).
187
+ * - Sandbox env vars and CLI config mounts are conventional, not declared:
188
+ * anything in `vault/envs/*` is auto-injected, anything in
189
+ * `vault/mounts/home/<rel>/...` is auto-bound at $HOME/<rel>/...
190
+ * There is no `envs` or `mounts` field — filesystem layout IS the spec.
191
+ */
192
+ /**
193
+ * Onboarding state per user. Used by the Welcome card on Loops list to
194
+ * decide whether to show "start onboarding" / "continue" / nothing.
195
+ *
196
+ * - `started`: a loop was spawned, but the user hasn't marked finished
197
+ * (`loopId` points at the in-progress onboarding loop).
198
+ * - `done`: user clicked skip/complete OR finished naturally. Card hides.
199
+ */
200
+ export type OnboardingState = {
201
+ status: "started" | "done"
202
+ loopId?: string
203
+ at: string
204
+ }
205
+
206
+ export type PersonalConfigDisk = {
207
+ /** Mixed: "default" key is a string, all other keys are providers. */
208
+ providers: Record<string, ProviderConfigDisk | string>
209
+ /** PTY shell override (highest precedence). */
210
+ shell?: string
211
+ /** Optional. Missing = "fresh" (user hasn't started or dismissed yet). */
212
+ onboarding?: OnboardingState
213
+ }
214
+
215
+ export type PersonalConfig = {
216
+ /** Active provider name. On disk this lives at `providers.default`. */
217
+ default: string
218
+ providers: Record<string, ProviderConfig>
219
+ /**
220
+ * Resolved env vars from the active vault's `envs/` dir. Filename → value.
221
+ * Used to (a) inject into spawn env so spawned binary's `${VAR}` substitution
222
+ * in mcpServers works, and (b) substitute `${VAR}` in provider.apiKey.
223
+ */
224
+ vaultEnvs: Record<string, string>
225
+ shell?: string
226
+ onboarding?: OnboardingState
227
+ }
228
+
229
+ /**
230
+ * Parse a default selector string. Supports two formats:
231
+ * - "providerName/modelId" (new) → { providerName, modelId }
232
+ * - "providerName" (legacy) → { providerName }
233
+ * Backward-compatible: if no "/" is present, the whole string is the provider name.
234
+ */
235
+ export function parseDefault(raw: string): { providerName: string; modelId?: string } {
236
+ if (!raw) return { providerName: "" }
237
+ const slashIdx = raw.indexOf("/")
238
+ if (slashIdx <= 0) return { providerName: raw }
239
+ return {
240
+ providerName: raw.slice(0, slashIdx),
241
+ modelId: raw.slice(slashIdx + 1) || undefined,
242
+ }
243
+ }
244
+
245
+ /** Preset providers with Anthropic-compatible endpoints. loopat uses the
246
+ * Claude Agent SDK which speaks the Anthropic Messages API — only providers
247
+ * that expose an Anthropic-compatible endpoint work directly.
248
+ * Each provider is disabled by default; the user supplies an API key. */
249
+ import { PROVIDER_PRESETS } from "./presets"
250
+
251
+ function buildPresetProviders(): Record<string, ProviderConfig> {
252
+ return Object.fromEntries(
253
+ PROVIDER_PRESETS.map(p => [
254
+ p.name,
255
+ {
256
+ models: p.models.map(id => ({ id, enabled: true })),
257
+ baseUrl: p.baseUrl,
258
+ apiKey: "",
259
+ enabled: false,
260
+ } satisfies ProviderConfig,
261
+ ]),
262
+ )
263
+ }
264
+
265
+ const WORKSPACE_TEMPLATE: WorkspaceConfig = {
266
+ knowledge: { git: "" },
267
+ notes: { git: "" },
268
+ repos: [
269
+ { name: "loopat", git: "git@github.com:simpx/loopat.git" },
270
+ ],
271
+ providers: buildPresetProviders(),
272
+ }
273
+
274
+ const PERSONAL_TEMPLATE: PersonalConfig = {
275
+ default: PROVIDER_PRESETS[0] ? `${PROVIDER_PRESETS[0].name}/${PROVIDER_PRESETS[0].models[0]}` : "",
276
+ providers: buildPresetProviders(),
277
+ vaultEnvs: {},
278
+ }
279
+
280
+ /** On-disk shape used when a config.json is missing or malformed. Seeded
281
+ * with presets so the user has a populated model list immediately. */
282
+ const PERSONAL_DISK_TEMPLATE: PersonalConfigDisk = {
283
+ providers: (() => {
284
+ const providers: Record<string, ProviderConfigDisk | string> = {
285
+ default: PROVIDER_PRESETS[0] ? `${PROVIDER_PRESETS[0].name}/${PROVIDER_PRESETS[0].models[0]}` : "",
286
+ }
287
+ for (const p of PROVIDER_PRESETS) {
288
+ providers[p.name] = {
289
+ models: p.models.map(id => ({ id, enabled: true })),
290
+ baseUrl: p.baseUrl,
291
+ enabled: false,
292
+ }
293
+ }
294
+ return providers
295
+ })(),
296
+ }
297
+
298
+ export const configPath = () => join(workspaceDir(), "config.json")
299
+
300
+ let cachedWorkspace: WorkspaceConfig | null = null
301
+ let cachedWorkspaceMtimeMs = 0
302
+
303
+ export async function loadConfig(): Promise<WorkspaceConfig> {
304
+ const path = configPath()
305
+ if (!existsSync(path)) {
306
+ await mkdir(workspaceDir(), { recursive: true })
307
+ await writeFile(path, JSON.stringify(WORKSPACE_TEMPLATE, null, 2) + "\n")
308
+ console.warn(`[loopat] config: created template at ${path}`)
309
+ cachedWorkspace = WORKSPACE_TEMPLATE
310
+ cachedWorkspaceMtimeMs = statSync(path).mtimeMs
311
+ return cachedWorkspace
312
+ }
313
+ // Re-read on mtime change so edits take effect on next attach without a
314
+ // server restart.
315
+ const mtimeMs = statSync(path).mtimeMs
316
+ if (cachedWorkspace && mtimeMs === cachedWorkspaceMtimeMs) return cachedWorkspace
317
+ const raw = await readFile(path, "utf8")
318
+ const parsed = JSON.parse(raw) as WorkspaceConfig
319
+ // Normalize legacy single-model providers to canonical models[] format.
320
+ if (parsed.providers) {
321
+ for (const [name, p] of Object.entries(parsed.providers)) {
322
+ const disk = p as any
323
+ if (!disk.models && disk.model) {
324
+ ;(p as any).models = [{ id: disk.model, enabled: true }]
325
+ }
326
+ if (p.enabled === undefined) (p as any).enabled = true
327
+ }
328
+ }
329
+ cachedWorkspace = parsed
330
+ cachedWorkspaceMtimeMs = mtimeMs
331
+ return cachedWorkspace
332
+ }
333
+
334
+ // Cache key = `${user}|${vault}` so per-vault apiKey/env resolutions don't
335
+ // clobber each other.
336
+ const personalCache = new Map<string, {
337
+ cfg: PersonalConfig
338
+ configMtimeMs: number
339
+ /** Snapshot of the vault envs dir mtime; if the dir changes (file added /
340
+ * removed / value edited) we re-resolve. We don't track per-file mtimes
341
+ * because vault envs are small enough to re-walk cheaply on miss. */
342
+ envsDirMtimeMs: number
343
+ }>()
344
+
345
+ export function clearPersonalCache(user: string): void {
346
+ for (const k of personalCache.keys()) {
347
+ if (k === user || k.startsWith(`${user}|`)) personalCache.delete(k)
348
+ }
349
+ }
350
+
351
+ const VAR_REF_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g
352
+
353
+ /** Substitute every `${VAR}` in a template against the env map. Unknown
354
+ * vars resolve to empty string. Literal strings (no $) pass through. */
355
+ export function expandVars(template: string, envs: Record<string, string>): string {
356
+ if (!template || !template.includes("${")) return template
357
+ return template.replace(VAR_REF_RE, (_, name) => envs[name] ?? "")
358
+ }
359
+
360
+ /**
361
+ * Load personal config from personal/<user>/.loopat/config.json. Resolves
362
+ * each provider's apiKey + every env entry against the selected vault.
363
+ *
364
+ * Missing config.json → in-memory empty template (do NOT lazy-write it; the
365
+ * vault may have been intentionally deleted).
366
+ */
367
+ export async function loadPersonalConfig(
368
+ user: string,
369
+ vault: string = DEFAULT_VAULT,
370
+ ): Promise<PersonalConfig> {
371
+ const path = personalLoopatConfigPath(user)
372
+ if (!existsSync(path)) {
373
+ return JSON.parse(JSON.stringify(PERSONAL_TEMPLATE)) as PersonalConfig
374
+ }
375
+ const configMtimeMs = statSync(path).mtimeMs
376
+ const envsDir = personalVaultEnvsDir(user, vault)
377
+ const envsDirMtimeMs = existsSync(envsDir) ? statSync(envsDir).mtimeMs : 0
378
+ const cacheKey = `${user}|${vault}`
379
+ const cached = personalCache.get(cacheKey)
380
+ if (
381
+ cached &&
382
+ cached.configMtimeMs === configMtimeMs &&
383
+ cached.envsDirMtimeMs === envsDirMtimeMs
384
+ ) {
385
+ return cached.cfg
386
+ }
387
+
388
+ const raw = await readFile(path, "utf8")
389
+ let disk: PersonalConfigDisk
390
+ try {
391
+ disk = JSON.parse(raw) as PersonalConfigDisk
392
+ if (!disk.providers || typeof disk.providers !== "object") {
393
+ throw new Error(`missing providers`)
394
+ }
395
+ } catch (e: any) {
396
+ console.warn(`[loopat] personal config: ${path} is malformed (${e?.message ?? e}), rewriting template`)
397
+ await writeFile(path, JSON.stringify(PERSONAL_DISK_TEMPLATE, null, 2) + "\n")
398
+ disk = JSON.parse(JSON.stringify(PERSONAL_DISK_TEMPLATE)) as PersonalConfigDisk
399
+ }
400
+
401
+ // Vault envs feed both the spawn env and ${VAR} substitution in apiKey.
402
+ const vaultEnvs = await loadVaultEnvs(user, vault)
403
+
404
+ // Split the heterogeneous providers map: pull out the special "default"
405
+ // string key, leave the rest as provider entries.
406
+ const rawDefault = typeof disk.providers.default === "string" ? disk.providers.default : ""
407
+ const { providerName: defaultProviderName } = parseDefault(rawDefault)
408
+ const providerEntries: Array<[string, ProviderConfigDisk]> = []
409
+ for (const [name, val] of Object.entries(disk.providers)) {
410
+ if (name === "default") continue
411
+ if (val && typeof val === "object") providerEntries.push([name, val as ProviderConfigDisk])
412
+ }
413
+ if (defaultProviderName && !providerEntries.some(([n]) => n === defaultProviderName)) {
414
+ console.warn(`[loopat] personal config: default "${rawDefault}" provider "${defaultProviderName}" not in providers (ignored)`)
415
+ }
416
+
417
+ const providers: Record<string, ProviderConfig> = {}
418
+ for (const [name, p] of providerEntries) {
419
+ let apiKey = ""
420
+ if (typeof p.apiKey === "string") {
421
+ apiKey = expandVars(p.apiKey, vaultEnvs)
422
+ } else if (p.apiKey && typeof (p.apiKey as any).vault === "string") {
423
+ // Resolve { vault: "provider-keys/DeepSeek" } format
424
+ const vaultPath = join(personalVaultDir(user, vault), (p.apiKey as any).vault as string)
425
+ try { apiKey = (await readFile(vaultPath, "utf8")).trim() } catch {}
426
+ }
427
+ // Normalize legacy single-model to canonical models[] format.
428
+ const models: ModelEntry[] = p.models && p.models.length > 0
429
+ ? p.models.map(m => ({ id: m.id, enabled: m.enabled !== false }))
430
+ : (p.model ? [{ id: p.model, enabled: true }] : [])
431
+ providers[name] = {
432
+ models,
433
+ baseUrl: p.baseUrl,
434
+ apiKey,
435
+ enabled: p.enabled !== false,
436
+ ...(p.maxContextTokens ? { maxContextTokens: p.maxContextTokens } : {}),
437
+ }
438
+ }
439
+
440
+ const cfg: PersonalConfig = {
441
+ default: defaultProviderName && providers[defaultProviderName] ? rawDefault : "",
442
+ providers,
443
+ vaultEnvs,
444
+ ...(disk.shell ? { shell: disk.shell } : {}),
445
+ ...(disk.onboarding ? { onboarding: disk.onboarding } : {}),
446
+ }
447
+ personalCache.set(cacheKey, { cfg, configMtimeMs, envsDirMtimeMs })
448
+ return cfg
449
+ }
450
+
451
+ export function getActiveProvider(cfg: PersonalConfig): { name: string; provider: ProviderConfig } | null {
452
+ const raw = cfg.default
453
+ if (!raw) return null
454
+ const { providerName } = parseDefault(raw)
455
+ if (!providerName || !cfg.providers[providerName]) return null
456
+ return { name: providerName, provider: cfg.providers[providerName] }
457
+ }
458
+
459
+ /**
460
+ * Per-user Claude config. Same JSON shape as workspace claude.json. Personal
461
+ * `mcpServers[<name>]` entries shadow workspace entries by name (user-tier
462
+ * wins over admin-tier — consistent with the skill/plugin compose model).
463
+ */
464
+ export async function loadPersonalClaudeJson(user: string): Promise<WorkspaceClaudeJson> {
465
+ const p = personalSettingsPath(user)
466
+ if (!existsSync(p)) return {}
467
+ try {
468
+ return JSON.parse(await readFile(p, "utf8")) as WorkspaceClaudeJson
469
+ } catch (e: any) {
470
+ console.warn(`[loopat] personal claude.json malformed at ${p}: ${e?.message ?? e}`)
471
+ return {}
472
+ }
473
+ }
474
+
475
+ // ── token usage ──
476
+
477
+ export type TokenUsage = Record<string, { inputTokens: number; outputTokens: number }>
478
+
479
+ export async function loadTokenUsage(user: string): Promise<TokenUsage> {
480
+ const p = personalTokenUsagePath(user)
481
+ if (!existsSync(p)) return {}
482
+ try {
483
+ return JSON.parse(await readFile(p, "utf8")) as TokenUsage
484
+ } catch {
485
+ return {}
486
+ }
487
+ }
488
+
489
+ export async function saveTokenUsage(user: string, usage: TokenUsage): Promise<void> {
490
+ await mkdir(personalLoopatDir(user), { recursive: true })
491
+ await writeFile(personalTokenUsagePath(user), JSON.stringify(usage, null, 2) + "\n")
492
+ }
493
+
494
+ export async function addTokenUsage(user: string, model: string, inputTokens: number, outputTokens: number): Promise<void> {
495
+ if (!model || (inputTokens === 0 && outputTokens === 0)) return
496
+ const usage = await loadTokenUsage(user)
497
+ const entry = usage[model] ?? { inputTokens: 0, outputTokens: 0 }
498
+ entry.inputTokens += inputTokens
499
+ entry.outputTokens += outputTokens
500
+ usage[model] = entry
501
+ await saveTokenUsage(user, usage)
502
+ }
503
+
504
+ // ── config persistence ──
505
+
506
+ /**
507
+ * Read the raw on-disk shape (without resolving any references). Used by
508
+ * savers that need to preserve existing apiKey/env reference structure.
509
+ */
510
+ export async function readPersonalDiskRaw(user: string): Promise<PersonalConfigDisk> {
511
+ return readPersonalDisk(user)
512
+ }
513
+
514
+ /**
515
+ * For an apiKey string that may contain `${VAR}` references, describe the
516
+ * shape so the Settings UI can render "✓ exists / ✗ missing" indicators
517
+ * without leaking the value.
518
+ *
519
+ * - "literal" : no `${VAR}` ref; value is the literal text (or empty)
520
+ * - "var" : exactly one `${VAR}` ref; reports whether the vault env
521
+ * file `envs/<VAR>` exists
522
+ * - "mixed" : multiple refs or template+text; existence not surfaced
523
+ */
524
+ export function describeApiKeyRef(
525
+ apiKey: string | undefined,
526
+ user: string,
527
+ vault: string = DEFAULT_VAULT,
528
+ ): { kind: "literal" | "var" | "mixed" | "empty"; varName?: string; path?: string; exists: boolean } {
529
+ // Handle { vault: "..." } object format
530
+ if (typeof apiKey !== "string") {
531
+ if (apiKey && typeof (apiKey as any).vault === "string") {
532
+ const vaultPath = join(personalVaultDir(user, vault), (apiKey as any).vault as string)
533
+ return { kind: "var", varName: (apiKey as any).vault, path: vaultPath, exists: existsSync(vaultPath) }
534
+ }
535
+ return { kind: "empty", exists: false }
536
+ }
537
+ if (!apiKey) return { kind: "empty", exists: false }
538
+ const matches = [...apiKey.matchAll(VAR_REF_RE)]
539
+ if (matches.length === 0) return { kind: "literal", exists: true }
540
+ if (matches.length === 1 && matches[0][0] === apiKey) {
541
+ const name = matches[0][1]
542
+ const path = personalVaultEnvPath(user, vault, name)
543
+ return { kind: "var", varName: name, path, exists: existsSync(path) }
544
+ }
545
+ return { kind: "mixed", exists: false }
546
+ }
547
+
548
+ /**
549
+ * Apply a structural patch to personal/<user>/.loopat/config.json. Accepts
550
+ * partial fields from `PersonalConfigDisk`; only fields present on the
551
+ * patch are touched. Does NOT write any secret values — apiKey values
552
+ * referenced as `${VAR}` are managed via `writeVaultEnv()`.
553
+ */
554
+ export async function savePersonalDisk(
555
+ user: string,
556
+ patch: Partial<PersonalConfigDisk>,
557
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
558
+ const disk = await readPersonalDisk(user)
559
+ if (patch.providers !== undefined) {
560
+ for (const [name, val] of Object.entries(patch.providers)) {
561
+ if (name === "default") {
562
+ if (typeof val !== "string") return { ok: false, error: `providers.default must be a string` }
563
+ continue
564
+ }
565
+ if (!val || typeof val !== "object" || Array.isArray(val)) {
566
+ return { ok: false, error: `provider "${name}" must be an object` }
567
+ }
568
+ const p = val as ProviderConfigDisk
569
+ const hasModels = Array.isArray(p.models) && p.models.length > 0
570
+ const hasModel = typeof p.model === "string"
571
+ if (!hasModels && !hasModel) {
572
+ return { ok: false, error: `provider "${name}" missing models (or legacy model)` }
573
+ }
574
+ if (typeof p.baseUrl !== "string") {
575
+ return { ok: false, error: `provider "${name}" missing baseUrl` }
576
+ }
577
+ if (p.apiKey !== undefined && typeof p.apiKey !== "string" && !(typeof p.apiKey === "object" && typeof (p.apiKey as any).vault === "string")) {
578
+ return { ok: false, error: `provider "${name}" apiKey must be a string or { vault }` }
579
+ }
580
+ }
581
+ const defName = patch.providers.default
582
+ if (typeof defName === "string" && defName) {
583
+ const { providerName } = parseDefault(defName)
584
+ const exists = Object.entries(patch.providers).some(([n, v]) => n !== "default" && n === providerName && typeof v === "object")
585
+ if (!exists) return { ok: false, error: `default "${defName}" provider "${providerName}" not in providers` }
586
+ }
587
+ // Force enabled: false for providers without an apiKey reference.
588
+ for (const [name, val] of Object.entries(patch.providers)) {
589
+ if (name === "default" || !val || typeof val !== "object") continue
590
+ const p = val as ProviderConfigDisk
591
+ if (p.enabled !== false) {
592
+ const hasNewKey = (typeof p.apiKey === "string" && p.apiKey.length > 0) || (p.apiKey && typeof (p.apiKey as any).vault === "string")
593
+ const existingEntry = disk.providers[name]
594
+ const existingKey = (existingEntry && typeof existingEntry === "object") ? (existingEntry as ProviderConfigDisk).apiKey : undefined
595
+ if (!hasNewKey && !existingKey) {
596
+ p.enabled = false
597
+ }
598
+ }
599
+ }
600
+ disk.providers = patch.providers
601
+ }
602
+ if (patch.shell !== undefined) {
603
+ if (typeof patch.shell !== "string") return { ok: false, error: `shell must be a string` }
604
+ disk.shell = patch.shell || undefined
605
+ }
606
+
607
+ await mkdir(personalLoopatDir(user), { recursive: true })
608
+ await writeFile(personalLoopatConfigPath(user), JSON.stringify(disk, null, 2) + "\n")
609
+ clearPersonalCache(user)
610
+ return { ok: true }
611
+ }
612
+
613
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
614
+
615
+ /**
616
+ * Write a value to the vault's `envs/<NAME>` file. Used when the Settings UI
617
+ * stores a fresh apiKey / token value. Caller chooses the variable name; we
618
+ * just validate and write. Re-reading the personal config picks up the value
619
+ * automatically via `${VAR}` substitution.
620
+ */
621
+ export async function writeVaultEnv(
622
+ user: string,
623
+ vault: string,
624
+ name: string,
625
+ value: string,
626
+ ): Promise<{ ok: true; path: string } | { ok: false; error: string }> {
627
+ if (!ENV_NAME_RE.test(name)) return { ok: false, error: `invalid env name "${name}"` }
628
+ const writeAt = personalVaultEnvPath(user, vault, name)
629
+ await mkdir(dirname(writeAt), { recursive: true })
630
+ await writeFile(writeAt, value.replace(/\r?\n+$/, "") + "\n")
631
+ clearPersonalCache(user)
632
+ return { ok: true, path: writeAt }
633
+ }
634
+
635
+ /** Delete a vault env file. No-op if missing. */
636
+ export async function deleteVaultEnv(user: string, vault: string, name: string): Promise<void> {
637
+ if (!ENV_NAME_RE.test(name)) return
638
+ const p = personalVaultEnvPath(user, vault, name)
639
+ if (existsSync(p)) {
640
+ const { rm } = await import("node:fs/promises")
641
+ await rm(p, { force: true })
642
+ }
643
+ clearPersonalCache(user)
644
+ }
645
+
646
+ async function readPersonalDisk(user: string): Promise<PersonalConfigDisk> {
647
+ const path = personalLoopatConfigPath(user)
648
+ if (!existsSync(path)) {
649
+ return JSON.parse(JSON.stringify(PERSONAL_DISK_TEMPLATE)) as PersonalConfigDisk
650
+ }
651
+ try {
652
+ const parsed = JSON.parse(await readFile(path, "utf8")) as PersonalConfigDisk
653
+ if (!parsed.providers || typeof parsed.providers !== "object") parsed.providers = {}
654
+ return parsed
655
+ } catch {
656
+ return JSON.parse(JSON.stringify(PERSONAL_DISK_TEMPLATE)) as PersonalConfigDisk
657
+ }
658
+ }
659
+
660
+ /**
661
+ * Save personal config to disk. Provider apiKey values are stored in vault
662
+ * envs/<NAME>_API_KEY (NAME = uppercase provider name); config.json carries
663
+ * a `${VAR}` reference. `default` lives at `providers.default` inside the
664
+ * providers map.
665
+ */
666
+ export async function savePersonalConfig(user: string, cfg: {
667
+ default?: string
668
+ providers?: Record<string, { model?: string; models?: ModelEntry[]; baseUrl: string; apiKey?: string; maxContextTokens?: number; enabled?: boolean }>
669
+ }): Promise<void> {
670
+ const disk = await readPersonalDisk(user)
671
+ const existingDefault = typeof disk.providers.default === "string" ? disk.providers.default : ""
672
+
673
+ if (cfg.providers !== undefined) {
674
+ const rebuilt: Record<string, ProviderConfigDisk | string> = {}
675
+ const nextDefault = cfg.default !== undefined ? cfg.default : existingDefault
676
+ if (nextDefault) rebuilt.default = nextDefault
677
+ for (const [name, p] of Object.entries(cfg.providers)) {
678
+ if (name === "default") {
679
+ console.warn(`[loopat] savePersonalConfig: ignored provider named "default" (reserved key)`)
680
+ continue
681
+ }
682
+ const existingEntry = disk.providers[name]
683
+ const existingKey = (existingEntry && typeof existingEntry === "object") ? existingEntry.apiKey : undefined
684
+ // Decide the apiKey field for disk:
685
+ // - If the user passed a new value, derive the env var name and stash
686
+ // the literal value into vault envs/<VAR>, then write a `${VAR}` ref.
687
+ // - Else keep whatever was there.
688
+ const defaultVar = providerEnvVarName(name)
689
+ let apiKeyField: string | undefined = existingKey
690
+ const hasNewKey = p.apiKey !== undefined && p.apiKey.trim() !== ""
691
+ if (hasNewKey) {
692
+ // If existing ref is a `${VAR}` template, reuse its var name; otherwise
693
+ // pick a deterministic default like ANTHROPIC_API_KEY for "Anthropic".
694
+ const targetVar = (existingKey && extractSingleVarName(existingKey)) ?? defaultVar
695
+ await writeVaultEnv(user, DEFAULT_VAULT, targetVar, p.apiKey!.trim())
696
+ apiKeyField = `\${${targetVar}}`
697
+ } else if (!apiKeyField) {
698
+ // No new key, no existing key → leave field unset (provider disabled).
699
+ apiKeyField = undefined
700
+ }
701
+ const models: ModelEntry[] = p.models && p.models.length > 0
702
+ ? p.models.map(m => ({ id: m.id, ...(m.enabled === false ? { enabled: false } : {}) }))
703
+ : (p.model ? [{ id: p.model, enabled: true }] : [])
704
+ rebuilt[name] = {
705
+ baseUrl: p.baseUrl,
706
+ ...(apiKeyField !== undefined ? { apiKey: apiKeyField } : {}),
707
+ ...(models.length > 0 ? { models } : {}),
708
+ ...(p.maxContextTokens ? { maxContextTokens: p.maxContextTokens } : {}),
709
+ ...(p.enabled === false ? { enabled: false } : {}),
710
+ }
711
+ }
712
+ disk.providers = rebuilt
713
+ } else if (cfg.default !== undefined) {
714
+ const rebuilt: Record<string, ProviderConfigDisk | string> = {}
715
+ if (cfg.default) rebuilt.default = cfg.default
716
+ for (const [name, val] of Object.entries(disk.providers)) {
717
+ if (name === "default") continue
718
+ rebuilt[name] = val
719
+ }
720
+ disk.providers = rebuilt
721
+ }
722
+
723
+ await mkdir(personalLoopatDir(user), { recursive: true })
724
+ await writeFile(personalLoopatConfigPath(user), JSON.stringify(disk, null, 2) + "\n")
725
+ clearPersonalCache(user)
726
+ }
727
+
728
+ /** Derive a default vault env var name from a provider name.
729
+ * "Anthropic" → "ANTHROPIC_API_KEY"; "DeepSeek" → "DEEPSEEK_API_KEY". */
730
+ export function providerEnvVarName(providerName: string): string {
731
+ const sanitized = providerName.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase()
732
+ return `${sanitized || "PROVIDER"}_API_KEY`
733
+ }
734
+
735
+ /** If `template` is exactly `${X}` (one ref, nothing else), return X. Else null. */
736
+ function extractSingleVarName(template: string): string | null {
737
+ const m = template.match(/^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/)
738
+ return m ? m[1] : null
739
+ }
740
+
741
+ /** Save workspace config to disk. Only provided fields are overwritten.
742
+ * Preserves existing apiKeys unless explicitly replaced. */
743
+ export async function saveWorkspaceConfig(cfg: Partial<WorkspaceConfig>): Promise<void> {
744
+ const existing = await loadConfig()
745
+ const merged: WorkspaceConfig = { ...existing }
746
+ if (cfg.providers !== undefined) {
747
+ merged.providers = merged.providers ?? {}
748
+ for (const [name, p] of Object.entries(cfg.providers)) {
749
+ const existingProv = merged.providers[name]
750
+ const incoming = p as any
751
+ // Normalize to canonical models[] format.
752
+ const models: ModelEntry[] = incoming.models?.length > 0
753
+ ? incoming.models.map((m: any) => ({ id: m.id, ...(m.enabled === false ? { enabled: false } : {}) }))
754
+ : existingProv?.models ?? (incoming.model ? [{ id: incoming.model, enabled: true }] : [])
755
+ merged.providers[name] = {
756
+ models,
757
+ baseUrl: incoming.baseUrl ?? existingProv?.baseUrl ?? "",
758
+ ...(incoming.maxContextTokens ? { maxContextTokens: incoming.maxContextTokens } : {}),
759
+ apiKey: incoming.apiKey || existingProv?.apiKey || "",
760
+ enabled: incoming.enabled !== undefined ? incoming.enabled : (existingProv?.enabled ?? true),
761
+ } as any
762
+ }
763
+ }
764
+ if (cfg.default !== undefined) merged.default = cfg.default
765
+ if (cfg.knowledge !== undefined) merged.knowledge = cfg.knowledge
766
+ if (cfg.notes !== undefined) merged.notes = cfg.notes
767
+ if (cfg.repos !== undefined) merged.repos = cfg.repos
768
+ if (cfg.serveDomain !== undefined) merged.serveDomain = cfg.serveDomain
769
+ if (cfg.serveWithPort !== undefined) merged.serveWithPort = cfg.serveWithPort
770
+ if (cfg.serveHttps !== undefined) merged.serveHttps = cfg.serveHttps
771
+ if (cfg.serveDisplayPort !== undefined) merged.serveDisplayPort = cfg.serveDisplayPort
772
+ if (cfg.serveEnabled !== undefined) merged.serveEnabled = cfg.serveEnabled
773
+ if (cfg.serveDynamicEnabled !== undefined) merged.serveDynamicEnabled = cfg.serveDynamicEnabled
774
+ if (cfg.serveDynamicDomain !== undefined) merged.serveDynamicDomain = cfg.serveDynamicDomain
775
+ if (cfg.serveDynamicPortRange !== undefined) merged.serveDynamicPortRange = cfg.serveDynamicPortRange
776
+ if (cfg.serveDynamicUdpEnabled !== undefined) merged.serveDynamicUdpEnabled = cfg.serveDynamicUdpEnabled
777
+ if (cfg.serveDynamicStaticEnabled !== undefined) merged.serveDynamicStaticEnabled = cfg.serveDynamicStaticEnabled
778
+ if (cfg.serveEphemeralEnabled !== undefined) merged.serveEphemeralEnabled = cfg.serveEphemeralEnabled
779
+ if (cfg.serveEphemeralDomain !== undefined) merged.serveEphemeralDomain = cfg.serveEphemeralDomain
780
+ if (cfg.presets !== undefined) merged.presets = cfg.presets
781
+ await writeFile(configPath(), JSON.stringify(merged, null, 2) + "\n")
782
+ cachedWorkspace = null
783
+ }