loopat 0.1.46 → 0.1.48

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.46",
3
+ "version": "0.1.48",
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",
@@ -37,7 +37,8 @@
37
37
  "prepublishOnly": "bun install && bun run build:web",
38
38
  "postinstall": "node scripts/install-sandbox-claude.mjs",
39
39
  "test:e2e": "playwright test",
40
- "test:e2e:ui": "playwright test --ui"
40
+ "test:e2e:ui": "playwright test --ui",
41
+ "dogfood": "playwright test --config dogfood/playwright.config.ts"
41
42
  },
42
43
  "dependencies": {
43
44
  "@anthropic-ai/claude-agent-sdk": "^0.3.150",
@@ -1996,8 +1996,14 @@ async function _ensureContextWorktree(repo: string, path: string, branchName: st
1996
1996
  const start = await remoteStartPoint(repo)
1997
1997
  // -B (not -b): reset the branch if it lingers from a removed worktree, so a
1998
1998
  // rebuild always re-opens cleanly from the start point.
1999
- const tail = ["-B", branchName, path]
2000
- if (start) tail.push(start)
1999
+ // No start point → `worktree add -B <branch> <path>` tries to seed the new
2000
+ // branch from the main repo's HEAD. For an EMPTY context remote (e.g. a notes
2001
+ // repo with no commits) the clone's HEAD is an unborn ref (`ref: main`); once
2002
+ // the repo later gains an unrelated branch (master, pushed by another loop)
2003
+ // git no longer infers `--orphan` and dies with `fatal: invalid reference:
2004
+ // HEAD`. Pass `--orphan` ourselves so a startless worktree never depends on a
2005
+ // resolvable HEAD — it opens an empty branch the loop can commit onto.
2006
+ const tail = start ? ["-B", branchName, path, start] : ["--orphan", "-B", branchName, path]
2001
2007
 
2002
2008
  // git-crypt + worktree: the git-crypt key lives in the MAIN repo's
2003
2009
  // `.git/git-crypt`, but a worktree's gitdir is separate and has no key — so a
@@ -52,6 +52,7 @@ import {
52
52
  personalKnowledgeDir,
53
53
  personalNotesDir,
54
54
  personalReposDir,
55
+ personalRepoCacheDir,
55
56
  LOOPAT_INSTALL_DIR,
56
57
  loopHomeUpper,
57
58
  workspaceHomeSkelDir,
@@ -85,14 +86,20 @@ export const V_CONTEXT_CHAT = "/loopat/context/chat"
85
86
  export const V_HOST_EXEC_DIR = "/loopat/host-exec"
86
87
  export const V_HOST_EXEC_SOCK = "/loopat/host-exec/host-exec.sock"
87
88
 
88
- // $HOME inside the container. Deliberately NOT host's homedir — if we bound
89
- // host's $HOME at its real path, podman would auto-create parent dirs for
90
- // every nested bind (LOOPAT_HOME, LOOPAT_INSTALL_DIR, etc. all live under
91
- // host $HOME in typical installs), and those intermediate dirs end up owned
92
- // by a subuid that the user can't delete from the host. With $HOME under
93
- // /loopat/ outside the host's homedir tree — every host-absolute bind
94
- // sits beside it, never inside.
95
- export const V_HOME = (user: string) => `/loopat/home/${user}`
89
+ // $HOME inside the container. MUST equal the sandbox user's /etc/passwd home,
90
+ // otherwise ssh/git resolve `~` (e.g. ~/.ssh) via getpwuid (= passwd home), NOT
91
+ // $HOME so a vault mounted at $HOME/.ssh is invisible to ssh and every
92
+ // sandbox-side `git push`/clone fails "Host key verification failed" / can't
93
+ // find the key. The image's `loopat` user (uid 2000) has passwd home
94
+ // /home/loopat, so we use exactly that.
95
+ //
96
+ // Still NOT the host's homedir: binding host $HOME at its real path makes podman
97
+ // auto-create nested-bind parent dirs owned by a subuid the host can't delete.
98
+ // /home/loopat is a CONTAINER-internal path — host-absolute binds sit outside
99
+ // it, and it vanishes with the container, so there's no host residue. (Per-user
100
+ // distinction is unnecessary: each loop has its own isolated container + home
101
+ // overlay; the home path inside need not encode the user.)
102
+ export const V_HOME = (_user: string) => `/home/loopat`
96
103
 
97
104
  // Label keys for podman inspect.
98
105
  const LABEL_LOOP = "loopat.loop-id"
@@ -106,7 +113,12 @@ const LABEL_CONFIG_HASH = "loopat.config-hash"
106
113
  // by the loopat.workspace label, not this name — the name only prevents tag
107
114
  // collisions. Same-Containerfile builds still share overlay layers, so the
108
115
  // per-workspace tags don't multiply disk usage.
109
- export const SANDBOX_IMAGE = process.env.LOOPAT_SANDBOX_IMAGE || `loopat-sandbox-${WORKSPACE}:latest`
116
+ // Image tag is content-addressed (no workspace prefix) so the same image is
117
+ // reused across workspaces/LOOPAT_HOMEs instead of rebuilt per workspace. The
118
+ // trade-off (deliberate): deleting a workspace no longer prunes its images —
119
+ // we'd rather leave a residual image than rebuild on every fresh workspace.
120
+ // Containers + their LABEL_WORKSPACE stay workspace-scoped (runtime isolation).
121
+ export const SANDBOX_IMAGE = process.env.LOOPAT_SANDBOX_IMAGE || `loopat-sandbox:latest`
110
122
  // Prebuilt multi-arch base image published to GHCR by CI, tagged by the
111
123
  // Containerfile content hash. ensureSandboxImage pulls this instead of building
112
124
  // locally — a pull is faster and far more reliable than apt-installing ~150
@@ -128,6 +140,11 @@ export type ContainerOptions = {
128
140
  vaultName?: string
129
141
  knowledgeRw?: boolean
130
142
  mountAllLoops?: boolean
143
+ /** Source roster repo for this loop's workdir (meta.repo). The workdir is a
144
+ * `git worktree add` off this repo's bare mirror (repo-cache/<repo>), so its
145
+ * .git gitdir points into that mirror. When set, the mirror is bind-mounted
146
+ * at its host path (src=dst, rw) so the worktree resolves inside the sandbox. */
147
+ repo?: string
131
148
  /** Extra env vars to pre-bake into the container at create time. */
132
149
  extraEnv?: Record<string, string>
133
150
  /** Image to create the container from. Defaults to SANDBOX_IMAGE.
@@ -190,7 +207,7 @@ export type VolumeMount = {
190
207
  */
191
208
  export async function buildVolumeMounts(opts: ContainerOptions): Promise<VolumeMount[]> {
192
209
  const hostHome = homedir()
193
- const { loopId, createdBy, vaultName, knowledgeRw, mountAllLoops } = opts
210
+ const { loopId, createdBy, vaultName, knowledgeRw, mountAllLoops, repo } = opts
194
211
  const virtualHome = V_HOME(createdBy)
195
212
  const mounts: VolumeMount[] = []
196
213
 
@@ -204,6 +221,17 @@ export async function buildVolumeMounts(opts: ContainerOptions): Promise<VolumeM
204
221
 
205
222
  // Virtual mount points for AI / user:
206
223
  mounts.push({ src: loopWorkdir(loopId), dst: V_LOOP_WORKDIR(loopId) })
224
+
225
+ // Workdir built from a roster repo is a `git worktree add` off the repo's
226
+ // bare mirror (repo-cache/<repo>); the workdir's .git is a gitdir pointer INTO
227
+ // that mirror. Bind the mirror at its host path (src=dst) so the worktree's
228
+ // objects/refs/worktree-metadata resolve inside the sandbox — otherwise
229
+ // `git status` in the workdir is "fatal: not a git repository". rw because git
230
+ // writes the worktree's index/HEAD/logs under <mirror>/worktrees/<wt>/.
231
+ if (repo) {
232
+ const cache = personalRepoCacheDir(createdBy, repo)
233
+ mounts.push({ src: cache, dst: cache })
234
+ }
207
235
  mounts.push({ src: loopClaudeDir(loopId), dst: V_LOOP_CLAUDE(loopId) })
208
236
  mounts.push({
209
237
  src: loopContextKnowledge(loopId),
@@ -582,7 +610,7 @@ export async function ensureSandboxImage(opts?: { onProgress?: (msg: string) =>
582
610
 
583
611
  // Hash the Containerfile so the base image auto-rebuilds when it changes.
584
612
  const hash = await baseContainerfileHash()
585
- const hashTag = `loopat-sandbox-${WORKSPACE}-${hash}:latest`
613
+ const hashTag = `loopat-sandbox-${hash}:latest`
586
614
 
587
615
  const present = await runPodman(["image", "exists", hashTag], { allowFail: true })
588
616
  if (present.code === 0) {
@@ -704,7 +732,7 @@ export async function ensureLoopImage(loopId: string, opts?: { onProgress?: (msg
704
732
  // after the nested-podman base change shipped).
705
733
  const baseHash = await baseContainerfileHash()
706
734
  const hash = createHash("sha256").update(`base:${baseHash}\n`).update(content).digest("hex").slice(0, 16)
707
- const tag = `loopat-sandbox-${WORKSPACE}-${hash}:latest`
735
+ const tag = `loopat-sandbox-${hash}:latest`
708
736
 
709
737
  const existing = _loopImageInFlight.get(tag)
710
738
  if (existing) return existing
@@ -499,6 +499,7 @@ class LoopSession {
499
499
  vaultName: meta.config?.vault,
500
500
  knowledgeRw: meta.config?.knowledge_rw,
501
501
  mountAllLoops: meta.config?.mount_all_loops,
502
+ repo: meta.repo,
502
503
  extraEnv,
503
504
  ephemeralPorts: loopEphemeralPorts(meta),
504
505
  }, {
@@ -67,6 +67,7 @@ async function getOrSpawn(loopId: string, initCols = 80, initRows = 24): Promise
67
67
  vaultName: meta.config?.vault,
68
68
  knowledgeRw: meta.config?.knowledge_rw,
69
69
  mountAllLoops: meta.config?.mount_all_loops,
70
+ repo: meta.repo,
70
71
  extraEnv: personalCfg.vaultEnvs,
71
72
  ephemeralPorts: loopEphemeralPorts(meta),
72
73
  }, {