loopat 0.1.20 → 0.1.22

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.20",
3
+ "version": "0.1.22",
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",
@@ -14,6 +14,7 @@
14
14
  },
15
15
  "files": [
16
16
  "bin/",
17
+ "scripts/install-sandbox-claude.mjs",
17
18
  "server/src/",
18
19
  "!server/src/serve-rs",
19
20
  "!server/src/port-proxy-rs",
@@ -34,6 +35,7 @@
34
35
  "build": "bun install && (cd web && bun run build)",
35
36
  "build:web": "cd web && bunx tsc -b && bunx vite build",
36
37
  "prepublishOnly": "bun install && bun run build:web",
38
+ "postinstall": "node scripts/install-sandbox-claude.mjs",
37
39
  "test:e2e": "playwright test",
38
40
  "test:e2e:ui": "playwright test --ui"
39
41
  },
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * postinstall: make sure a LINUX claude binary is available for the sandbox.
4
+ *
5
+ * loopat runs the AI inside a linux podman sandbox, so it needs the
6
+ * linux-<arch> claude binary. npm only installs the claude-agent-sdk platform
7
+ * binary matching the HOST (os/cpu filtered optionalDependencies) — so on a
8
+ * linux host we already have it (no-op here), but on macOS/Windows npm installs
9
+ * the darwin/win binary and the sandbox would hit "Exec format error".
10
+ *
11
+ * On a non-linux host we fetch the linux-<arch> binary into
12
+ * <loopat>/sandbox-claude (pinned to the SDK version we depend on) using npm's
13
+ * --os/--cpu override. Best-effort: a failure only means sandbox AI won't run
14
+ * on this host until fixed; the install itself still succeeds.
15
+ */
16
+ import { execFileSync } from "node:child_process"
17
+ import { existsSync, mkdirSync } from "node:fs"
18
+ import { dirname, join, resolve } from "node:path"
19
+ import { fileURLToPath } from "node:url"
20
+ import { createRequire } from "node:module"
21
+
22
+ if (process.platform === "linux") process.exit(0) // host claude IS the sandbox claude
23
+
24
+ const arch = process.arch // "arm64" | "x64"
25
+ const pkg = `@anthropic-ai/claude-agent-sdk-linux-${arch}`
26
+ const installDir = resolve(dirname(fileURLToPath(import.meta.url)), "..")
27
+ const dest = join(installDir, "sandbox-claude")
28
+ const binary = join(dest, "node_modules", pkg, "claude")
29
+
30
+ if (existsSync(binary)) process.exit(0) // already fetched
31
+
32
+ try {
33
+ const require = createRequire(import.meta.url)
34
+ let version = ""
35
+ try {
36
+ version = require("@anthropic-ai/claude-agent-sdk/package.json").version
37
+ } catch {}
38
+ const spec = version ? `${pkg}@${version}` : pkg
39
+ mkdirSync(dest, { recursive: true })
40
+ console.log(`[loopat] host is ${process.platform}/${arch}; fetching linux claude for the sandbox (${spec})…`)
41
+ // The platform binary declares os=linux, so `--os=linux --cpu` hits
42
+ // EBADPLATFORM on a darwin host — `--force` is what actually fetches it.
43
+ execFileSync("npm", ["install", "--prefix", dest, "--no-save", "--force", spec], { stdio: "inherit" })
44
+ if (existsSync(binary)) console.log(`[loopat] sandbox claude ready at ${binary}`)
45
+ else console.warn(`[loopat] sandbox claude install finished but ${binary} is missing`)
46
+ } catch (e) {
47
+ console.warn(`[loopat] could not fetch linux claude for the sandbox: ${e?.message ?? e}`)
48
+ console.warn(`[loopat] sandbox AI won't run on this host until fixed; everything else works.`)
49
+ }
@@ -6,7 +6,7 @@
6
6
  import { existsSync } from "node:fs"
7
7
  import { execFileSync } from "node:child_process"
8
8
  import { join } from "node:path"
9
- import { resolveClaudeBinary } from "./claude-binary"
9
+ import { resolveSandboxClaudeBinary } from "./claude-binary"
10
10
  import { configPath, loadKnowledgeConfig, type WorkspaceConfig, type KnowledgeConfig } from "./config"
11
11
  import {
12
12
  WORKSPACE,
@@ -68,14 +68,19 @@ function checkPodman(): Check {
68
68
  }
69
69
 
70
70
  function checkClaudeBinary(): Check {
71
+ // The AI runs in the linux sandbox, so what matters is the SANDBOX claude.
71
72
  try {
72
- const p = resolveClaudeBinary()
73
- return { ok: true, label: `claude binary (${p.split("/").slice(-3).join("/")})` }
74
- } catch (e: any) {
73
+ const p = resolveSandboxClaudeBinary()
74
+ const tag = process.platform === "linux" ? "" : " [linux, for sandbox]"
75
+ return { ok: true, label: `claude binary${tag} (${p.split("/").slice(-3).join("/")})` }
76
+ } catch {
75
77
  return {
76
78
  ok: false,
77
- label: "claude binary",
78
- hint: "run `bun install` in the loopat repo root — SDK ships the binary as a platform-specific package",
79
+ label: "claude binary (sandbox/linux)",
80
+ hint:
81
+ process.platform === "linux"
82
+ ? "run `bun install` in the loopat repo root — SDK ships the binary as a platform-specific package"
83
+ : "the linux claude for the sandbox wasn't fetched (postinstall). Reinstall loopat, or run the `npm install --os=linux ...` command from the resolve error",
79
84
  }
80
85
  }
81
86
  }
@@ -1,7 +1,11 @@
1
- import { existsSync } from "node:fs"
2
- import { execSync } from "node:child_process"
1
+ import { existsSync, mkdirSync } from "node:fs"
2
+ import { execSync, execFile } from "node:child_process"
3
+ import { promisify } from "node:util"
3
4
  import { fileURLToPath } from "node:url"
4
5
  import { dirname, resolve, join } from "node:path"
6
+ import { createRequire } from "node:module"
7
+
8
+ const execFileP = promisify(execFile)
5
9
 
6
10
  function detectIsMusl(): boolean {
7
11
  if (process.platform !== "linux") return false
@@ -66,3 +70,50 @@ export function resolveClaudeBinary(): string {
66
70
  }
67
71
  throw new Error(`claude binary not found; tried:\n${candidates.join("\n")}`)
68
72
  }
73
+
74
+ /** Where the fetched linux claude lives on a non-linux host. */
75
+ function sandboxClaudeDir(): string {
76
+ const installDir = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..")
77
+ return join(installDir, "sandbox-claude")
78
+ }
79
+ function sandboxClaudeBinaryPath(): string {
80
+ return join(sandboxClaudeDir(), "node_modules", "@anthropic-ai", `claude-agent-sdk-linux-${process.arch}`, "claude")
81
+ }
82
+
83
+ /**
84
+ * The claude binary the SANDBOX runs (the AI executes inside a linux podman
85
+ * container). On a linux host that's just the host claude. On a non-linux host
86
+ * npm only installed the host (e.g. darwin) binary, so we fetch the linux-<arch>
87
+ * one into <loopat>/sandbox-claude — bind THAT into the sandbox, not the host
88
+ * binary (otherwise: "Exec format error").
89
+ */
90
+ export function resolveSandboxClaudeBinary(): string {
91
+ if (process.platform === "linux") return resolveClaudeBinary()
92
+ const candidate = sandboxClaudeBinaryPath()
93
+ if (existsSync(candidate)) return candidate
94
+ throw new Error(`sandbox (linux) claude not found at ${candidate}; run ensureSandboxClaudeBinary() (loopat fetches it on first boot)`)
95
+ }
96
+
97
+ /**
98
+ * Make sure the sandbox's linux claude exists, fetching it if not. No-op on a
99
+ * linux host (host claude IS the sandbox claude). On a non-linux host this runs
100
+ * `npm install --force` (the platform binary has os=linux, so --os/--cpu hit
101
+ * EBADPLATFORM; --force is what gets it). Pinned to the SDK version. Best-effort
102
+ * and idempotent: once fetched, returns immediately. npx does NOT run package
103
+ * postinstall scripts, so this boot-time fetch is what actually covers
104
+ * `npx loopat`.
105
+ */
106
+ export async function ensureSandboxClaudeBinary(onLog?: (m: string) => void): Promise<void> {
107
+ if (process.platform === "linux") return
108
+ if (existsSync(sandboxClaudeBinaryPath())) return
109
+ const arch = process.arch
110
+ let version = ""
111
+ try { version = createRequire(import.meta.url)("@anthropic-ai/claude-agent-sdk/package.json").version } catch {}
112
+ const spec = `@anthropic-ai/claude-agent-sdk-linux-${arch}${version ? `@${version}` : ""}`
113
+ const dest = sandboxClaudeDir()
114
+ mkdirSync(dest, { recursive: true })
115
+ onLog?.(`host is ${process.platform}/${arch}; fetching linux claude for the sandbox (${spec})…`)
116
+ await execFileP("npm", ["install", "--prefix", dest, "--no-save", "--force", spec], { timeout: 180_000 })
117
+ if (!existsSync(sandboxClaudeBinaryPath())) throw new Error(`fetch finished but ${sandboxClaudeBinaryPath()} missing`)
118
+ onLog?.(`sandbox claude ready`)
119
+ }
@@ -58,6 +58,7 @@ import {
58
58
  import { loadConfig, loadPersonalConfig, savePersonalConfig, saveWorkspaceConfig, loadTokenUsage, getActiveProvider, readPersonalDiskRaw, savePersonalDisk, describeApiKeyRef, writeVaultEnv, deleteVaultEnv, loadKnowledgeConfig, saveKnowledgeConfig, type ProviderConfig, type ModelEntry } from "./config"
59
59
  import { listBoards, createBoard, renameBoard, listKanbanColumns, addCard, toggleCard, deleteCard, moveCard, updateCardMeta, updateCardBlock, reorderCards, createColumn, deleteColumn, readKanbanConfig, saveColumnOrder, setColumnColor, renameColumn, assignDriverForCard, createLoopFromCard, linkLoopToCard, kanbanUserCtx } from "./kanban"
60
60
  import { printBootstrapBanner } from "./bootstrap"
61
+ import { ensureSandboxClaudeBinary } from "./claude-binary"
61
62
  import { serveHostExec, hostExecSocketPath } from "./host-exec"
62
63
  import {
63
64
  createUser,
@@ -3170,6 +3171,13 @@ console.log(`[loopat] server listening on http://${hostname}:${port}`)
3170
3171
  }
3171
3172
  }
3172
3173
 
3174
+ // On a non-linux host the sandbox needs a linux claude that npm/npx didn't
3175
+ // install (npx skips postinstall scripts) — fetch it in the background on first
3176
+ // boot (~18s, doesn't block the port; skipped once present / on a linux host).
3177
+ ensureSandboxClaudeBinary((m) => console.log(`[loopat] ${m}`)).catch((e) =>
3178
+ console.warn(`[loopat] sandbox claude fetch failed: ${e?.message ?? e}; sandbox AI won't run until fixed`),
3179
+ )
3180
+
3173
3181
  await printBootstrapBanner(cfg)
3174
3182
  const backfilled = await backfillAllMounts()
3175
3183
  if (backfilled > 0) console.log(`[loopat] backfilled context mounts on ${backfilled} loop(s)`)
@@ -60,7 +60,7 @@ import {
60
60
  import { loadConfig } from "./config"
61
61
  import { DEFAULT_VAULT, listVaultHomeMounts } from "./vaults"
62
62
  import { hostExecDir, writeHostShims } from "./host-exec"
63
- import { resolveClaudeBinary } from "./claude-binary"
63
+ import { resolveSandboxClaudeBinary } from "./claude-binary"
64
64
  import { parse as tomlParse, stringify as tomlStringify } from "smol-toml"
65
65
 
66
66
  const execFileP = promisify(execFile)
@@ -221,7 +221,7 @@ export async function buildVolumeMounts(opts: ContainerOptions): Promise<VolumeM
221
221
  // sandbox exec's it by its host path, so bind that path in (ro) when it isn't
222
222
  // already covered by the install-dir mount — otherwise the AI is code 127.
223
223
  try {
224
- const claudeDir = dirname(resolveClaudeBinary())
224
+ const claudeDir = dirname(resolveSandboxClaudeBinary())
225
225
  if (existsSync(claudeDir) && !claudeDir.startsWith(LOOPAT_INSTALL_DIR)) {
226
226
  mounts.push({ src: claudeDir, dst: claudeDir, ro: true })
227
227
  }
@@ -5,7 +5,7 @@ import { createWriteStream, mkdirSync, existsSync } from "node:fs"
5
5
  import { randomUUID } from "node:crypto"
6
6
  import { join } from "node:path"
7
7
  import { loopClaudeDir, loopDir, loopHistoryPath, personalSkillsDir, workspaceTeamSkillsDir } from "./paths"
8
- import { resolveClaudeBinary } from "./claude-binary"
8
+ import { resolveSandboxClaudeBinary } from "./claude-binary"
9
9
  import { loadConfig, loadPersonalConfig, parseDefault, type ProviderConfig } from "./config"
10
10
  import { buildLoopatAppend } from "./system-prompt"
11
11
  import { composeLoopClaudeConfig, writeLoopSettings } from "./compose"
@@ -21,8 +21,11 @@ import { updateLoopStatus } from "./loop-status"
21
21
  // Resolved lazily — each spawn re-reads the env var so the full-suite test
22
22
  // run, where module load order isn't guaranteed, sees the test's override
23
23
  // even if session.ts was imported earlier with the env var unset.
24
+ // The AI runs inside the linux sandbox, so it needs the linux claude binary
25
+ // (resolveSandboxClaudeBinary) — on a linux host that's the host claude; on a
26
+ // darwin/win host it's the linux binary postinstall fetched into sandbox-claude.
24
27
  function getClaudeBinary(): string {
25
- return process.env.LOOPAT_CLAUDE_BIN || resolveClaudeBinary()
28
+ return process.env.LOOPAT_CLAUDE_BIN || resolveSandboxClaudeBinary()
26
29
  }
27
30
  const DEBUG = !!process.env.LOOPAT_DEBUG || !!process.env.LOOPAT_DEBUG_SPAWN
28
31