loopat 0.1.13 → 0.1.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopat",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Self-hosted AI workspace built around context management — works solo, scales to teams",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/simpx/loopat",
@@ -7,7 +7,7 @@ import { existsSync } from "node:fs"
7
7
  import { execFileSync } from "node:child_process"
8
8
  import { join } from "node:path"
9
9
  import { resolveClaudeBinary } from "./claude-binary"
10
- import { configPath, type WorkspaceConfig } from "./config"
10
+ import { configPath, loadKnowledgeConfig, type WorkspaceConfig, type KnowledgeConfig } from "./config"
11
11
  import {
12
12
  WORKSPACE,
13
13
  usersPath,
@@ -104,8 +104,8 @@ function describeRemote(dir: string, url: string | undefined): string {
104
104
  return "local-only (no remote)"
105
105
  }
106
106
 
107
- function describeRepos(cfg: WorkspaceConfig): Check {
108
- const specs = cfg.repos ?? []
107
+ function describeRepos(kcfg: KnowledgeConfig): Check {
108
+ const specs = kcfg.repos ?? []
109
109
  if (specs.length === 0) return { ok: true, label: `repos: (none configured)` }
110
110
  // Repos are clone-on-demand (cloned only when a loop selects one), so a repo
111
111
  // that isn't cloned yet is NORMAL, not a failure. Report cloned vs on-demand;
@@ -132,12 +132,14 @@ async function checkUsers(): Promise<Check> {
132
132
  }
133
133
 
134
134
  export async function printBootstrapBanner(cfg: WorkspaceConfig) {
135
+ // notes + repos are declared inside the knowledge repo's .loopat/config.json.
136
+ const kcfg = await loadKnowledgeConfig()
135
137
  const checks: Check[] = [
136
138
  { ok: true, label: `workspace: ${workspaceDir()}` },
137
139
  { ok: true, label: `team .claude/CLAUDE.md (${existsSync(workspaceTeamClaudeMdPath()) ? "present" : "absent"})` },
138
140
  { ok: existsSync(workspaceKnowledgeDir()), label: `knowledge: ${describeRemote(workspaceKnowledgeDir(), cfg.knowledge?.git || undefined)}` },
139
- { ok: existsSync(workspaceNotesDir()), label: `notes: ${describeRemote(workspaceNotesDir(), cfg.notes?.git || undefined)}` },
140
- describeRepos(cfg),
141
+ { ok: existsSync(workspaceNotesDir()), label: `notes: ${describeRemote(workspaceNotesDir(), kcfg.notes?.git || undefined)}` },
142
+ describeRepos(kcfg),
141
143
  await checkUsers(),
142
144
  { ok: existsSync(configPath()), label: `config: ${configPath()}` },
143
145
  checkPodman(),
@@ -9,6 +9,8 @@ import {
9
9
  personalVaultEnvPath,
10
10
  personalVaultEnvsDir,
11
11
  workspaceDir,
12
+ workspaceLoopatRoot,
13
+ personalKnowledgeLoopatRoot,
12
14
  personalSettingsPath,
13
15
  } from "./paths"
14
16
  import { DEFAULT_VAULT, loadVaultEnvs } from "./vaults"
@@ -109,6 +111,55 @@ export type RepoSpec = {
109
111
  git: string
110
112
  }
111
113
 
114
+ /**
115
+ * Config living INSIDE the knowledge repo at <knowledge>/.loopat/config.json.
116
+ * The knowledge repo is the SoT for the team's notes remote + the repo roster;
117
+ * workspace/personal config only hold the `knowledge` entry pointer. To read
118
+ * it the knowledge repo must already be cloned (its url comes from
119
+ * personal/workspace config), so this is loaded AFTER the knowledge clone.
120
+ */
121
+ export type KnowledgeConfig = {
122
+ notes?: RemoteSpec
123
+ repos?: RepoSpec[]
124
+ }
125
+
126
+ const KNOWLEDGE_CONFIG_TEMPLATE: KnowledgeConfig = { notes: { git: "" }, repos: [] }
127
+
128
+ /** The .loopat root holding the knowledge config. With a user → that user's
129
+ * per-user knowledge repo; without → the workspace-default knowledge repo
130
+ * (used only for the bootstrap banner, never to drive a loop). */
131
+ function knowledgeLoopatRoot(user?: string): string {
132
+ return user ? personalKnowledgeLoopatRoot(user) : workspaceLoopatRoot()
133
+ }
134
+
135
+ /** Read the knowledge repo's .loopat/config.json. Missing/malformed → empty.
136
+ * Pass `user` for the per-user knowledge repo (what a loop uses); omit for the
137
+ * workspace-default (bootstrap display only). */
138
+ export async function loadKnowledgeConfig(user?: string): Promise<KnowledgeConfig> {
139
+ const path = join(knowledgeLoopatRoot(user), "config.json")
140
+ if (!existsSync(path)) return JSON.parse(JSON.stringify(KNOWLEDGE_CONFIG_TEMPLATE))
141
+ try {
142
+ const disk = JSON.parse(await readFile(path, "utf8")) as KnowledgeConfig
143
+ return { notes: disk.notes, repos: Array.isArray(disk.repos) ? disk.repos : [] }
144
+ } catch (e: any) {
145
+ console.warn(`[loopat] knowledge config: ${path} malformed (${e?.message ?? e}), treating as empty`)
146
+ return JSON.parse(JSON.stringify(KNOWLEDGE_CONFIG_TEMPLATE))
147
+ }
148
+ }
149
+
150
+ /** Merge-write a knowledge repo's .loopat/config.json. Caller is responsible
151
+ * for committing/promoting the change (knowledge is gated). */
152
+ export async function saveKnowledgeConfig(user: string | undefined, patch: KnowledgeConfig): Promise<void> {
153
+ const cur = await loadKnowledgeConfig(user)
154
+ const next: KnowledgeConfig = {
155
+ notes: patch.notes !== undefined ? patch.notes : cur.notes,
156
+ repos: patch.repos !== undefined ? patch.repos : cur.repos,
157
+ }
158
+ const dir = knowledgeLoopatRoot(user)
159
+ await mkdir(dir, { recursive: true })
160
+ await writeFile(join(dir, "config.json"), JSON.stringify(next, null, 2) + "\n")
161
+ }
162
+
112
163
  /** Operator-side mount (workspace config). src is always a literal host path.
113
164
  * Operator owns the host, so any path under `~/...`, `$HOME/...`, or `/...`
114
165
  * is allowed (modulo `..` traversal). Used for cross-user shared caches
@@ -128,9 +179,11 @@ export type OperatorMount = {
128
179
  * personal/<user>/.loopat/config.json — see PersonalConfig.
129
180
  */
130
181
  export type WorkspaceConfig = {
182
+ /** Workspace-default entry pointer to the knowledge repo. The notes remote
183
+ * and the repo roster now live INSIDE the knowledge repo's
184
+ * .loopat/config.json (KnowledgeConfig) — see loadKnowledgeConfig. A
185
+ * per-user override lives in personal config. */
131
186
  knowledge?: RemoteSpec
132
- notes?: RemoteSpec
133
- repos?: RepoSpec[]
134
187
  providers?: Record<string, ProviderConfig>
135
188
  default?: string
136
189
  /** Platform-level git host for personal onboarding. `provider` is a
@@ -192,11 +245,10 @@ export type WorkspaceConfig = {
192
245
  export type PersonalConfigDisk = {
193
246
  /** Mixed: "default" key is a string, all other keys are providers. */
194
247
  providers: Record<string, ProviderConfigDisk | string>
195
- /** Authoritative kn/notes remotes the personal repo is self-describing.
196
- * host config.json's knowledge/notes are a display mirror; these are what a
197
- * loop actually connects to (with the user's vault key). */
248
+ /** Per-user entry pointer to the knowledge repo (authoritative over the
249
+ * workspace default). The notes remote + repo roster live inside the
250
+ * knowledge repo's own .loopat/config.json, not here. */
198
251
  knowledge?: RemoteSpec
199
- notes?: RemoteSpec
200
252
  }
201
253
 
202
254
  export type PersonalConfig = {
@@ -209,9 +261,9 @@ export type PersonalConfig = {
209
261
  * in mcpServers works, and (b) substitute `${VAR}` in provider.apiKey.
210
262
  */
211
263
  vaultEnvs: Record<string, string>
212
- /** Authoritative kn/notes remotes (self-describing personal repo). */
264
+ /** Per-user entry pointer to the knowledge repo (notes/repos live in the
265
+ * knowledge repo's own .loopat/config.json). */
213
266
  knowledge?: RemoteSpec
214
- notes?: RemoteSpec
215
267
  }
216
268
 
217
269
  /**
@@ -252,10 +304,6 @@ function buildPresetProviders(): Record<string, ProviderConfig> {
252
304
 
253
305
  const WORKSPACE_TEMPLATE: WorkspaceConfig = {
254
306
  knowledge: { git: "" },
255
- notes: { git: "" },
256
- repos: [
257
- { name: "loopat", git: "git@github.com:simpx/loopat.git" },
258
- ],
259
307
  providers: buildPresetProviders(),
260
308
  }
261
309
 
@@ -430,7 +478,6 @@ export async function loadPersonalConfig(
430
478
  providers,
431
479
  vaultEnvs,
432
480
  ...(disk.knowledge ? { knowledge: disk.knowledge } : {}),
433
- ...(disk.notes ? { notes: disk.notes } : {}),
434
481
  }
435
482
  personalCache.set(cacheKey, { cfg, configMtimeMs, envsDirMtimeMs })
436
483
  return cfg
@@ -747,8 +794,6 @@ export async function saveWorkspaceConfig(cfg: Partial<WorkspaceConfig>): Promis
747
794
  }
748
795
  if (cfg.default !== undefined) merged.default = cfg.default
749
796
  if (cfg.knowledge !== undefined) merged.knowledge = cfg.knowledge
750
- if (cfg.notes !== undefined) merged.notes = cfg.notes
751
- if (cfg.repos !== undefined) merged.repos = cfg.repos
752
797
  if (cfg.serveDomain !== undefined) merged.serveDomain = cfg.serveDomain
753
798
  if (cfg.serveWithPort !== undefined) merged.serveWithPort = cfg.serveWithPort
754
799
  if (cfg.serveHttps !== undefined) merged.serveHttps = cfg.serveHttps
@@ -49,6 +49,10 @@ import {
49
49
  workspaceNotesDir,
50
50
  workspaceRepoDir,
51
51
  workspaceReposDir,
52
+ personalKnowledgeDir,
53
+ personalNotesDir,
54
+ personalRepoDir,
55
+ personalReposDir,
52
56
  loopsDir,
53
57
  } from "./paths"
54
58
  import { loadConfig, loadPersonalConfig, savePersonalConfig, saveWorkspaceConfig, loadTokenUsage, getActiveProvider, readPersonalDiskRaw, savePersonalDisk, describeApiKeyRef, writeVaultEnv, deleteVaultEnv, type ProviderConfig, type ModelEntry } from "./config"
@@ -1373,45 +1377,50 @@ app.post("/api/personal/push", requireAuth, async (c) => {
1373
1377
  // All ff-only. Any authenticated user may sync. Push to repos/<name> is
1374
1378
  // not supported — code flows through PRs upstream, never from primary.
1375
1379
 
1376
- function syncDirFor(resource: string, name?: string): string | null {
1377
- if (resource === "knowledge") return workspaceKnowledgeDir()
1378
- if (resource === "notes") return workspaceNotesDir()
1380
+ function syncDirFor(user: string, resource: string, name?: string): string | null {
1381
+ if (resource === "knowledge") return personalKnowledgeDir(user)
1382
+ if (resource === "notes") return personalNotesDir(user)
1379
1383
  if (resource === "repos" && name) {
1380
- const dir = workspaceRepoDir(name)
1384
+ const dir = personalRepoDir(user, name)
1381
1385
  return existsSync(dir) ? dir : null
1382
1386
  }
1383
1387
  return null
1384
1388
  }
1385
1389
 
1386
- app.get("/api/sync/knowledge/status", requireAuth, async (c) => c.json(await inspectRepoSync(workspaceKnowledgeDir(), c.get("userId") as string)))
1390
+ app.get("/api/sync/knowledge/status", requireAuth, async (c) => { const u = c.get("userId") as string; return c.json(await inspectRepoSync(personalKnowledgeDir(u), u)) })
1387
1391
  app.post("/api/sync/knowledge/pull", requireAuth, async (c) => {
1388
- const r = await pullRepoFromRemote(workspaceKnowledgeDir(), c.get("userId") as string)
1392
+ const u = c.get("userId") as string
1393
+ const r = await pullRepoFromRemote(personalKnowledgeDir(u), u)
1389
1394
  return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
1390
1395
  })
1391
1396
  app.post("/api/sync/knowledge/push", requireAuth, async (c) => {
1392
- const r = await pushRepoToRemote(workspaceKnowledgeDir(), c.get("userId") as string)
1397
+ const u = c.get("userId") as string
1398
+ const r = await pushRepoToRemote(personalKnowledgeDir(u), u)
1393
1399
  return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
1394
1400
  })
1395
1401
 
1396
- app.get("/api/sync/notes/status", requireAuth, async (c) => c.json(await inspectRepoSync(workspaceNotesDir(), c.get("userId") as string)))
1402
+ app.get("/api/sync/notes/status", requireAuth, async (c) => { const u = c.get("userId") as string; return c.json(await inspectRepoSync(personalNotesDir(u), u)) })
1397
1403
  app.post("/api/sync/notes/pull", requireAuth, async (c) => {
1398
- const r = await pullRepoFromRemote(workspaceNotesDir(), c.get("userId") as string)
1404
+ const u = c.get("userId") as string
1405
+ const r = await pullRepoFromRemote(personalNotesDir(u), u)
1399
1406
  return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
1400
1407
  })
1401
1408
  app.post("/api/sync/notes/push", requireAuth, async (c) => {
1402
- const r = await pushRepoToRemote(workspaceNotesDir(), c.get("userId") as string)
1409
+ const u = c.get("userId") as string
1410
+ const r = await pushRepoToRemote(personalNotesDir(u), u)
1403
1411
  return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
1404
1412
  })
1405
1413
 
1406
1414
  app.get("/api/sync/repos", requireAuth, async (c) => {
1407
- // List repos available for sync (just names of subdirs of workspaceReposDir).
1415
+ // List the user's per-user repos available for sync (subdirs of their repos dir).
1416
+ const u = c.get("userId") as string
1408
1417
  try {
1409
1418
  const { readdir } = await import("node:fs/promises")
1410
- const entries = await readdir(workspaceReposDir())
1419
+ const entries = await readdir(personalReposDir(u))
1411
1420
  const repos: string[] = []
1412
1421
  for (const e of entries) {
1413
1422
  if (e.startsWith(".")) continue
1414
- if (existsSync(workspaceRepoDir(e) + "/.git")) repos.push(e)
1423
+ if (existsSync(personalRepoDir(u, e) + "/.git")) repos.push(e)
1415
1424
  }
1416
1425
  return c.json({ repos })
1417
1426
  } catch {
@@ -1419,14 +1428,16 @@ app.get("/api/sync/repos", requireAuth, async (c) => {
1419
1428
  }
1420
1429
  })
1421
1430
  app.get("/api/sync/repos/:name/status", requireAuth, async (c) => {
1422
- const dir = syncDirFor("repos", c.req.param("name"))
1431
+ const u = c.get("userId") as string
1432
+ const dir = syncDirFor(u, "repos", c.req.param("name"))
1423
1433
  if (!dir) return c.json({ error: "repo not found" }, 404)
1424
- return c.json(await inspectRepoSync(dir, c.get("userId") as string))
1434
+ return c.json(await inspectRepoSync(dir, u))
1425
1435
  })
1426
1436
  app.post("/api/sync/repos/:name/pull", requireAuth, async (c) => {
1427
- const dir = syncDirFor("repos", c.req.param("name"))
1437
+ const u = c.get("userId") as string
1438
+ const dir = syncDirFor(u, "repos", c.req.param("name"))
1428
1439
  if (!dir) return c.json({ error: "repo not found" }, 404)
1429
- const r = await pullRepoFromRemote(dir, c.get("userId") as string)
1440
+ const r = await pullRepoFromRemote(dir, u)
1430
1441
  return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
1431
1442
  })
1432
1443
 
@@ -3103,13 +3114,26 @@ app.get("*", async (c, next) => {
3103
3114
  // Try to serve the exact file
3104
3115
  const file = Bun.file(join(webDist, path === "/" ? "index.html" : path))
3105
3116
  if (await file.exists()) {
3117
+ // Hashed build assets are content-addressed → cache hard. HTML must always
3118
+ // revalidate so a version swap (new chunk names) is picked up immediately.
3119
+ const isAsset = path.startsWith("/assets/")
3106
3120
  return new Response(file, {
3107
- headers: { "content-type": file.type },
3121
+ headers: {
3122
+ "content-type": file.type,
3123
+ "cache-control": isAsset ? "public, max-age=31536000, immutable" : "no-cache",
3124
+ },
3108
3125
  })
3109
3126
  }
3110
- // SPA fallback
3127
+ // A missing build asset (a stale chunk after a deploy, e.g. when a browser
3128
+ // still holds an old index.html) must 404 — NOT fall through to index.html,
3129
+ // or the browser loads HTML as a JS module and throws "Failed to fetch
3130
+ // dynamically imported module". Only extensionless paths are real SPA routes.
3131
+ if (path.startsWith("/assets/") || /\.[a-zA-Z0-9]+$/.test(path)) {
3132
+ return c.notFound()
3133
+ }
3134
+ // SPA fallback (real client-side routes) — always revalidate.
3111
3135
  return new Response(Bun.file(indexHtml), {
3112
- headers: { "content-type": "text/html" },
3136
+ headers: { "content-type": "text/html", "cache-control": "no-cache" },
3113
3137
  })
3114
3138
  })
3115
3139
 
@@ -24,6 +24,10 @@ import {
24
24
  workspaceOriginsDir,
25
25
  workspaceOriginPath,
26
26
  personalDir,
27
+ personalKnowledgeDir,
28
+ personalNotesDir,
29
+ personalReposDir,
30
+ personalRepoDir,
27
31
  personalVaultDir,
28
32
  uiNotesDir,
29
33
  personalMemoryDir,
@@ -36,7 +40,7 @@ import {
36
40
  } from "./paths"
37
41
  import type { RepoSpec } from "./config"
38
42
  import { existsSync as existsSyncBase } from "node:fs"
39
- import { loadConfig, loadPersonalConfig } from "./config"
43
+ import { loadConfig, loadPersonalConfig, loadKnowledgeConfig } from "./config"
40
44
  import { ensurePersonalKeypair } from "./personal-keys"
41
45
  import { composeLoopClaudeConfig, writeLoopSettings } from "./compose"
42
46
  import { getProvider } from "./git-host"
@@ -84,6 +88,13 @@ export type LoopMeta = {
84
88
  pendingDriverNote?: { from: string; to: string; at: string }
85
89
  repo?: string
86
90
  branch?: string
91
+ /**
92
+ * Context-setup problems captured at loop creation (e.g. the per-user
93
+ * knowledge/notes clone failed — bad/again-missing key, no access). Surfaced
94
+ * as a banner in the loop UI so the user isn't left with a silently-empty
95
+ * context. Empty/absent = context set up cleanly.
96
+ */
97
+ contextWarnings?: string[]
87
98
  config?: {
88
99
  default_model?: string
89
100
  default_model_source?: "personal" | "workspace"
@@ -332,31 +343,57 @@ async function ensureContextRepo(dir: string, name: string, url?: string): Promi
332
343
  * interactive host-key prompt. Host-side git overrides that config via
333
344
  * GIT_SSH_COMMAND (env beats config), so the sandbox path is never used here.
334
345
  */
335
- export async function ensureUserContext(user: string, vault: string = "default"): Promise<void> {
346
+ export async function ensureUserContext(user: string, vault: string = "default"): Promise<string[]> {
347
+ const errors: string[] = []
336
348
  const cfg = await loadPersonalConfig(user, vault)
337
349
  const sshEnv = { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(user, vault) }
338
- const layers: Array<[string, string | undefined]> = [
339
- [workspaceKnowledgeDir(), cfg.knowledge?.git],
340
- [workspaceNotesDir(), cfg.notes?.git],
341
- ]
342
- for (const [dir, url] of layers) {
343
- if (!url) continue // personal didn't declare one → keep the host/local origin
344
- if (!existsSyncBase(join(dir, ".git"))) continue // not initialized yet (startup hasn't run)
345
- const has = await execFileP("git", ["-C", dir, "remote", "get-url", "origin"]).then(() => true).catch(() => false)
346
- await execFileP("git", ["-C", dir, "remote", has ? "set-url" : "add", "origin", url]).catch(() => {})
347
- // Sandbox-side promote: persist core.sshCommand pointing at the vault key's
348
- // SANDBOX path (/loopat/home/<user>/.ssh/id, where the vault home-mount
349
- // lands) with accept-new, so the AI's `git push` inside the sandbox
350
- // authenticates as the user without an interactive host-key prompt. Host-
351
- // side ops override this with GIT_SSH_COMMAND (env beats config), so the
352
- // sandbox path is never used server-side.
353
- const sandboxKey = `/loopat/home/${user}/.ssh/id`
350
+ // Sandbox-side promote: core.sshCommand points at the vault key's SANDBOX path
351
+ // (/loopat/home/<user>/.ssh/id, where the vault home-mount lands) with
352
+ // accept-new, so the AI's `git push` inside the sandbox authenticates as the
353
+ // user without an interactive host-key prompt. Host-side ops override this
354
+ // with GIT_SSH_COMMAND (env beats config), so the sandbox path is never used
355
+ // server-side.
356
+ const sandboxKey = `/loopat/home/${user}/.ssh/id`
357
+ // Clone-or-sync a PER-USER context main repo from `url` with the vault key.
358
+ // STRICT, per the context model: personal wins even when empty an empty url
359
+ // means the dir is REMOVED so the loop sees nothing (no fallback to any
360
+ // workspace default). Returns whether the repo exists afterwards.
361
+ const ensurePerUserRepo = async (dir: string, url: string | undefined, label: string): Promise<boolean> => {
362
+ if (!url) {
363
+ try { await rm(dir, { recursive: true, force: true }) } catch {}
364
+ return false
365
+ }
366
+ if (existsSyncBase(join(dir, ".git"))) {
367
+ const has = await execFileP("git", ["-C", dir, "remote", "get-url", "origin"]).then(() => true).catch(() => false)
368
+ await execFileP("git", ["-C", dir, "remote", has ? "set-url" : "add", "origin", url]).catch(() => {})
369
+ } else {
370
+ try { await rm(dir, { recursive: true, force: true }) } catch {}
371
+ await mkdir(join(dir, ".."), { recursive: true })
372
+ try {
373
+ await execFileP("git", ["clone", "--", url, dir], { env: sshEnv, timeout: 60_000 })
374
+ console.log(`[loopat] cloned per-user context ${url} → ${dir}`)
375
+ } catch (e: any) {
376
+ // Concise reason (last non-empty stderr line — e.g. "Permission denied
377
+ // (publickey)") for the loop-creation warning surfaced in the UI.
378
+ const reason = (e?.stderr ?? e?.message ?? String(e)).toString().trim().split("\n").filter(Boolean).pop() ?? "clone failed"
379
+ console.warn(`[loopat] per-user context clone failed (${url}): ${e?.stderr ?? e?.message ?? e}`)
380
+ errors.push(`${label}: couldn't clone ${url} — ${reason}`)
381
+ return false
382
+ }
383
+ }
354
384
  await execFileP("git", ["-C", dir, "config", "core.sshCommand",
355
385
  `ssh -i ${sandboxKey} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null`]).catch(() => {})
356
- // validate connectivity + populate origin/* with the user's key (transient,
357
- // server-side only).
358
- await execFileP("git", ["-C", dir, "fetch", "--quiet", "origin"], { env: sshEnv, timeout: 20_000 }).catch(() => {})
386
+ await execFileP("git", ["-C", dir, "fetch", "--quiet", "origin"], { env: sshEnv, timeout: 30_000 }).catch(() => {})
387
+ return true
359
388
  }
389
+ // knowledge is the entry pointer (personal-declared url); clone it first, then
390
+ // read ITS .loopat/config.json for the notes remote + repo roster — notes and
391
+ // repos now live inside the per-user knowledge repo, not in personal config.
392
+ const hasKnowledge = await ensurePerUserRepo(personalKnowledgeDir(user), cfg.knowledge?.git, "knowledge")
393
+ const kcfg = hasKnowledge ? await loadKnowledgeConfig(user) : { notes: undefined, repos: [] as RepoSpec[] }
394
+ await ensurePerUserRepo(personalNotesDir(user), kcfg.notes?.git, "notes")
395
+ await writeReposManifest(personalReposDir(user), kcfg.repos ?? [])
396
+ return errors
360
397
  }
361
398
 
362
399
  /**
@@ -365,8 +402,8 @@ export async function ensureUserContext(user: string, vault: string = "default")
365
402
  * clone a repo only when it's actually needed. Per docs/context-flow.md the AI
366
403
  * can also clone any listed repo by hand into context/repos/<name>.
367
404
  */
368
- async function writeReposManifest(specs: RepoSpec[]) {
369
- await mkdir(workspaceReposDir(), { recursive: true })
405
+ async function writeReposManifest(reposDir: string, specs: RepoSpec[]) {
406
+ await mkdir(reposDir, { recursive: true })
370
407
  const body = [
371
408
  "# repos — clone on demand",
372
409
  "",
@@ -376,21 +413,22 @@ async function writeReposManifest(specs: RepoSpec[]) {
376
413
  ...specs.filter((r) => r?.name && r?.git).map((r) => `- **${r.name}** — \`${r.git}\``),
377
414
  "",
378
415
  ].join("\n")
379
- await writeFile(join(workspaceReposDir(), "REPOS.md"), body)
416
+ await writeFile(join(reposDir, "REPOS.md"), body)
380
417
  }
381
418
 
382
419
  /**
383
420
  * Clone a single registered repo if it isn't present yet. Returns whether the
384
421
  * repo dir exists afterwards. Used by loop creation and any on-demand path.
385
422
  */
386
- async function ensureRepoCloned(name: string, sshCommand?: string): Promise<boolean> {
387
- const dir = workspaceRepoDir(name)
423
+ async function ensureRepoCloned(user: string, name: string, sshCommand?: string): Promise<boolean> {
424
+ const dir = personalRepoDir(user, name)
388
425
  if (existsSyncBase(dir)) return true
389
- const cfg = await loadConfig()
390
- const spec = cfg.repos?.find((r) => r.name === name)
426
+ // The roster lives in the user's OWN knowledge repo (per-user, no fallback).
427
+ const kcfg = await loadKnowledgeConfig(user)
428
+ const spec = kcfg.repos?.find((r) => r.name === name)
391
429
  if (!spec?.git) return false
392
430
  try {
393
- await mkdir(workspaceReposDir(), { recursive: true })
431
+ await mkdir(personalReposDir(user), { recursive: true })
394
432
  const env = sshCommand ? { ...process.env, GIT_SSH_COMMAND: sshCommand } : process.env
395
433
  await execFileP("git", ["clone", "--", spec.git, dir], { env })
396
434
  console.log(`[loopat] cloned on demand ${spec.git} → ${dir}`)
@@ -406,11 +444,14 @@ export async function ensureWorkspaceDirs() {
406
444
  await mkdir(loopsDir(), { recursive: true })
407
445
  await mkdir(workspaceReposDir(), { recursive: true })
408
446
 
409
- // knowledge / notes / repos: clone from config'd remote if present
447
+ // WORKSPACE-DEFAULT clone (bootstrap display + seed source only loops use the
448
+ // per-user knowledge/notes, see ensureUserContext). knowledge is the entry
449
+ // pointer; clone it, then read its .loopat/config.json for notes + repo roster.
410
450
  const cfg = await loadConfig()
411
451
  await ensureContextRepo(workspaceKnowledgeDir(), "knowledge", cfg.knowledge?.git || undefined)
412
- await ensureContextRepo(workspaceNotesDir(), "notes", cfg.notes?.git || undefined)
413
- await writeReposManifest(cfg.repos ?? [])
452
+ const kcfg = await loadKnowledgeConfig()
453
+ await ensureContextRepo(workspaceNotesDir(), "notes", kcfg.notes?.git || undefined)
454
+ await writeReposManifest(workspaceReposDir(), kcfg.repos ?? [])
414
455
 
415
456
  // workspace memory dir + stub
416
457
  const tm = workspaceMemoryDir()
@@ -1327,7 +1368,10 @@ export async function pushPersonalToRemote(
1327
1368
  * rebuilt from origin if missing.
1328
1369
  */
1329
1370
  export async function ensureUiNotesWorktree(user: string): Promise<void> {
1330
- await ensureContextWorktree(workspaceNotesDir(), uiNotesDir(user), `ui/${user}`)
1371
+ // Ensure the user's per-user notes main repo is cloned from their declared
1372
+ // remote first (notes is per-user now), then open the UI worktree from it.
1373
+ await ensureUserContext(user).catch(() => {})
1374
+ await ensurePerUserContextWorktree(personalNotesDir(user), uiNotesDir(user), `ui/${user}`)
1331
1375
  }
1332
1376
 
1333
1377
  /**
@@ -1803,15 +1847,34 @@ async function remoteStartPoint(repo: string, sshCommand?: string): Promise<stri
1803
1847
  }
1804
1848
  }
1805
1849
 
1850
+ /**
1851
+ * Worktree from a PER-USER context main repo. When the main repo is absent (the
1852
+ * user declared no remote for this context — see ensureUserContext's strict
1853
+ * rule), the loop gets an EMPTY dir, never a fallback to a workspace default.
1854
+ */
1855
+ async function ensurePerUserContextWorktree(repo: string, path: string, branch: string) {
1856
+ if (!existsSyncBase(join(repo, ".git"))) {
1857
+ try { await rm(path, { recursive: true, force: true }) } catch {}
1858
+ await mkdir(path, { recursive: true })
1859
+ return
1860
+ }
1861
+ await ensureContextWorktree(repo, path, branch)
1862
+ }
1863
+
1806
1864
  export async function ensureContextMounts(id: string, createdBy: string) {
1807
1865
  await mkdir(loopContextDir(id), { recursive: true })
1808
- await ensureContextWorktree(workspaceKnowledgeDir(), loopContextKnowledge(id), `loop/${id}`)
1809
- await ensureContextWorktree(workspaceNotesDir(), loopContextNotes(id), `loop/${id}`)
1810
- // personal is also a per-loop worktree (docs/context-flow.md) same shape as
1811
- // notes, just wired to the user's private remote. ensureContextWorktree falls
1812
- // back to a symlink when personal/ isn't a git repo yet.
1866
+ // knowledge / notes are per-user (cloned by ensureUserContext from the user's
1867
+ // personal-declared remotes). Each worktree opens from origin/main — a fresh
1868
+ // pull of consensus; the local main repo is just the fetch cache + worktree
1869
+ // host (docs/context-flow.md). Empty when the user declared no remote.
1870
+ await ensurePerUserContextWorktree(personalKnowledgeDir(createdBy), loopContextKnowledge(id), `loop/${id}`)
1871
+ await ensurePerUserContextWorktree(personalNotesDir(createdBy), loopContextNotes(id), `loop/${id}`)
1872
+ // personal is also a per-loop worktree — same shape, wired to the user's
1873
+ // private remote. ensureContextWorktree falls back to a symlink when
1874
+ // personal/ isn't a git repo yet.
1813
1875
  await ensureContextWorktree(personalDir(createdBy), loopContextPersonal(id), `loop/${id}`)
1814
- await ensureSymlink(loopContextRepos(id), workspaceReposDir())
1876
+ await mkdir(personalReposDir(createdBy), { recursive: true })
1877
+ await ensureSymlink(loopContextRepos(id), personalReposDir(createdBy))
1815
1878
  }
1816
1879
 
1817
1880
  export async function listLoops(): Promise<LoopMeta[]> {
@@ -1886,15 +1949,25 @@ export async function createLoop(opts: {
1886
1949
  await composeLoopClaudeConfig(id, opts.createdBy, opts.profiles)
1887
1950
  await writeLoopSettings(id)
1888
1951
 
1952
+ // Pull the per-user knowledge/notes FIRST: clones the user's knowledge repo
1953
+ // (from personal.knowledge) and reads its .loopat/config.json for the notes
1954
+ // remote + repo roster — which the workdir clone-on-demand below depends on.
1955
+ // Surface any clone failures (bad key / no access) as a loop banner so the
1956
+ // user isn't left with a silently-empty context.
1957
+ const ctxWarnings = await ensureUserContext(opts.createdBy, opts.vault ?? "default").catch(
1958
+ (e: any) => { console.warn(`[loopat] ensureUserContext(${opts.createdBy}): ${e?.message ?? e}`); return [`context init failed: ${e?.message ?? e}`] },
1959
+ )
1960
+ if (ctxWarnings.length) meta.contextWarnings = ctxWarnings
1961
+
1889
1962
  // workdir = git worktree add (if repo selected) OR plain mkdir
1890
1963
  if (opts.repo) {
1891
1964
  // clone + fetch as the user (their vault key), not the host's ssh.
1892
1965
  const userSsh = sshCommandForUser(opts.createdBy, opts.vault ?? "default")
1893
1966
  // clone-on-demand: pull the repo down only now that a loop actually needs it
1894
- if (!(await ensureRepoCloned(opts.repo, userSsh))) {
1967
+ if (!(await ensureRepoCloned(opts.createdBy, opts.repo, userSsh))) {
1895
1968
  throw new Error(`repo "${opts.repo}" not found / clone failed`)
1896
1969
  }
1897
- const repoPath = workspaceRepoDir(opts.repo)
1970
+ const repoPath = personalRepoDir(opts.createdBy, opts.repo)
1898
1971
  const branch = `loop/${(await shortBranchSlug(meta.title))}-${id.slice(0, 6)}`
1899
1972
  try {
1900
1973
  // ① pull (docs/context-flow.md): base the workdir branch on origin/main
@@ -1915,11 +1988,7 @@ export async function createLoop(opts: {
1915
1988
  await mkdir(loopWorkdir(id), { recursive: true })
1916
1989
  }
1917
1990
 
1918
- // Point the context repos at the personal-declared kn/notes remotes and
1919
- // connect with the user's vault key (personal repo is self-describing).
1920
- await ensureUserContext(opts.createdBy, opts.vault ?? "default").catch(
1921
- (e: any) => console.warn(`[loopat] ensureUserContext(${opts.createdBy}): ${e?.message ?? e}`),
1922
- )
1991
+ // (ensureUserContext already ran above, before the workdir clone.)
1923
1992
  await ensureContextMounts(id, effectiveDriver(meta))
1924
1993
  await writeFile(loopMetaPath(id), JSON.stringify(meta, null, 2))
1925
1994
  return meta
@@ -35,6 +35,21 @@ export const workspaceOriginPath = (name: string) => join(workspaceOriginsDir(),
35
35
  export const extensionsProvidersDir = () => join(LOOPAT_HOME, "extensions", "providers")
36
36
  export const personalDir = (user: string) => join(LOOPAT_HOME, "personal", user)
37
37
 
38
+ // Per-user context main repos. knowledge/notes/repos are NOT workspace-shared:
39
+ // each user's loop sees ONLY what their personal.knowledge points at (no
40
+ // fallback to the workspace default). These are the main repos the loop's
41
+ // context worktrees are derived from — the per-user analogue of the shared
42
+ // workspaceKnowledgeDir()/workspaceNotesDir(). Live under context/users/<user>/
43
+ // so they never collide with the workspace-default context/knowledge.
44
+ export const userContextDir = (user: string) => join(workspaceContextDir(), "users", user)
45
+ export const personalKnowledgeDir = (user: string) => join(userContextDir(user), "knowledge")
46
+ export const personalNotesDir = (user: string) => join(userContextDir(user), "notes")
47
+ export const personalReposDir = (user: string) => join(userContextDir(user), "repos")
48
+ export const personalRepoDir = (user: string, name: string) => join(personalReposDir(user), name)
49
+ // The per-user knowledge repo's .loopat root (holds its config.json = notes +
50
+ // repo roster). Workspace-default equivalent is workspaceLoopatRoot().
51
+ export const personalKnowledgeLoopatRoot = (user: string) => join(personalKnowledgeDir(user), ".loopat")
52
+
38
53
  export const loopDir = (id: string) => join(loopsDir(), id)
39
54
  export const loopWorkdir = (id: string) => join(loopDir(id), "workdir")
40
55
  export const loopClaudeDir = (id: string) => join(loopDir(id), ".claude")
@@ -46,12 +46,12 @@ import {
46
46
  loopClaudeDir,
47
47
  loopsDir,
48
48
  loopContextChatDir,
49
- workspaceKnowledgeDir,
50
- workspaceNotesDir,
51
- workspaceReposDir,
52
49
  loopContextKnowledge,
53
50
  loopContextNotes,
54
51
  personalDir,
52
+ personalKnowledgeDir,
53
+ personalNotesDir,
54
+ personalReposDir,
55
55
  LOOPAT_INSTALL_DIR,
56
56
  loopHomeUpper,
57
57
  workspaceHomeSkelDir,
@@ -250,20 +250,22 @@ export async function buildVolumeMounts(opts: ContainerOptions): Promise<VolumeM
250
250
  }
251
251
 
252
252
  // Repos: bind at virtual path AND host-absolute path (git worktree internals
253
- // store absolute gitdir paths). Both RW.
254
- const reposDir = workspaceReposDir()
253
+ // store absolute gitdir paths). Both RW. PER-USER (the loop's own roster).
254
+ const reposDir = personalReposDir(createdBy)
255
255
  if (existsSync(reposDir)) {
256
256
  mounts.push({ src: reposDir, dst: V_CONTEXT_REPOS })
257
257
  mounts.push({ src: reposDir, dst: reposDir })
258
258
  }
259
259
 
260
- // notes/knowledge main repos: re-bind at host-absolute path so per-loop
261
- // worktree `.git` files resolve.
262
- const notesRepo = workspaceNotesDir()
260
+ // notes/knowledge main repos: re-bind at host-absolute path so the per-loop
261
+ // worktree `.git` files resolve. PER-USER — the worktrees are derived from
262
+ // personalKnowledgeDir/personalNotesDir(createdBy), so the main repos bound
263
+ // here must match (same as personalDir above).
264
+ const notesRepo = personalNotesDir(createdBy)
263
265
  if (existsSync(notesRepo)) {
264
266
  mounts.push({ src: notesRepo, dst: notesRepo })
265
267
  }
266
- const knowledgeRepo = workspaceKnowledgeDir()
268
+ const knowledgeRepo = personalKnowledgeDir(createdBy)
267
269
  if (existsSync(knowledgeRepo)) {
268
270
  mounts.push({ src: knowledgeRepo, dst: knowledgeRepo, ro: !knowledgeRw })
269
271
  }