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,190 @@
1
+ import { homedir } from "node:os"
2
+ import { basename, dirname, join, resolve } from "node:path"
3
+ import { fileURLToPath } from "node:url"
4
+
5
+ /**
6
+ * LOOPAT_HOME *is* the workspace directory. Single-workspace by design — to
7
+ * run a second workspace, start a second loopat instance with a different
8
+ * LOOPAT_HOME. Default `~/.loopat`. The display/URL name is the basename
9
+ * with leading dots stripped (so `~/.loopat` → "loopat").
10
+ */
11
+ export const LOOPAT_HOME = process.env.LOOPAT_HOME ?? join(homedir(), ".loopat")
12
+
13
+ // loopat code install dir (contains node_modules/, helper binaries the sandbox needs)
14
+ // Computed from this file's path: server/src/paths.ts → loop/
15
+ const __DIRNAME = dirname(fileURLToPath(import.meta.url))
16
+ export const LOOPAT_INSTALL_DIR = resolve(__DIRNAME, "../..")
17
+ export const TEMPLATES_DIR = join(LOOPAT_INSTALL_DIR, "server", "templates")
18
+
19
+ export const WORKSPACE = basename(LOOPAT_HOME).replace(/^\.+/, "") || "loopat"
20
+
21
+ export const workspaceDir = () => LOOPAT_HOME
22
+ export const usersPath = () => join(LOOPAT_HOME, "users.json")
23
+ export const loopsDir = () => join(LOOPAT_HOME, "loops")
24
+ export const workspaceContextDir = () => join(LOOPAT_HOME, "context")
25
+ export const workspaceKnowledgeDir = () => join(workspaceContextDir(), "knowledge")
26
+ export const workspaceNotesDir = () => join(workspaceContextDir(), "notes")
27
+ export const workspaceReposDir = () => join(workspaceContextDir(), "repos")
28
+ export const workspaceRepoDir = (name: string) => join(workspaceReposDir(), name)
29
+ // Local git hosting: when a context repo has no remote, loopat hosts the
30
+ // `origin` itself as a bare repo here (docs/context-flow.md "solo").
31
+ export const workspaceOriginsDir = () => join(LOOPAT_HOME, "origins")
32
+ export const workspaceOriginPath = (name: string) => join(workspaceOriginsDir(), `${name}.git`)
33
+ // External git-host provider extensions (e.g. internal "Code" platform). Drop a
34
+ // duck-typed provider file here; loopat loads it without any core change.
35
+ export const extensionsProvidersDir = () => join(LOOPAT_HOME, "extensions", "providers")
36
+ export const personalDir = (user: string) => join(LOOPAT_HOME, "personal", user)
37
+
38
+ export const loopDir = (id: string) => join(loopsDir(), id)
39
+ export const loopWorkdir = (id: string) => join(loopDir(id), "workdir")
40
+ export const loopClaudeDir = (id: string) => join(loopDir(id), ".claude")
41
+ export const loopContextDir = (id: string) => join(loopDir(id), "context")
42
+ export const loopContextKnowledge = (id: string) => join(loopContextDir(id), "knowledge")
43
+ export const loopContextNotes = (id: string) => join(loopContextDir(id), "notes")
44
+ export const loopContextPersonal = (id: string) => join(loopContextDir(id), "personal")
45
+ export const loopContextRepos = (id: string) => join(loopContextDir(id), "repos")
46
+ export const loopContextChatDir = (id: string) => join(loopContextDir(id), "chat")
47
+ export const loopMetaPath = (id: string) => join(loopDir(id), "meta.json")
48
+
49
+ // UI loop checkouts — a per-user worktree for editing team context (notes) from
50
+ // outside any AI loop (a "no-AI UI loop", see docs/context-flow.md). Disposable:
51
+ // opened from origin/main, synced back ff-only.
52
+ export const uiDir = (user: string) => join(LOOPAT_HOME, "ui", user)
53
+ export const uiNotesDir = (user: string) => join(uiDir(user), "notes")
54
+ export const loopHistoryPath = (id: string) => join(loopDir(id), "messages.jsonl")
55
+ export const loopChatHistoryPath = (id: string) => join(loopDir(id), "chat_history.jsonl")
56
+
57
+ export const chatDbPath = () => join(LOOPAT_HOME, "chat.db")
58
+
59
+ export const personalMemoryDir = (user: string) => join(personalDir(user), "memory")
60
+ export const workspaceMemoryDir = () => join(workspaceNotesDir(), "memory")
61
+ // (Old `workspaceLoopat{Reserved,Claude,Skills,Agents,Sandbox*}` helpers
62
+ // deleted — superseded by the tiered .claude/ model. Workspace CC config now
63
+ // lives at `<knowledge>/.loopat/.claude/` and is accessed via
64
+ // `workspaceTeamClaudeDir/SettingsPath/ClaudeMdPath/SkillsDir/AgentsDir` below.)
65
+
66
+ // Per-loop $HOME overlay (docker container layer for home). The sandbox's
67
+ // $HOME is an overlayfs mount: lower = workspaceHomeSkelDir (shared skeleton,
68
+ // typically empty), upper = home-upper (per-loop persistent diff), work =
69
+ // home-work (overlayfs internal scratch). merged is the mount point that
70
+ // bwrap binds into the sandbox at $HOME. Persists across loop restarts; AI's
71
+ // pip/npm installs and shell history survive.
72
+ export const loopHomeUpper = (id: string) => join(loopDir(id), "home-upper")
73
+ export const loopHomeWork = (id: string) => join(loopDir(id), "home-work")
74
+ export const loopHomeMerged = (id: string) => join(loopDir(id), "home-merged")
75
+ // Workspace-shared base layer for the home overlay. User can drop default
76
+ // dotfiles in here; left empty by default.
77
+ export const workspaceHomeSkelDir = () => join(LOOPAT_HOME, "sandbox-home-skel")
78
+ // Bundled platform doctrine — ships with loopat code, always present.
79
+ export const bundledDoctrinePath = () => join(TEMPLATES_DIR, "CLAUDE.md")
80
+
81
+ // Per-loop-kind templates (distill, future: review, plan, etc.). Each kind
82
+ // has its own dir; createLoop / distillLoop copies the kind's CLAUDE.md into
83
+ // the new loop's workdir as the L2++ project-tier doctrine.
84
+ export const loopKindTemplateDir = (kind: string) => join(TEMPLATES_DIR, "loop-kinds", kind)
85
+ export const loopKindClaudePath = (kind: string) => join(loopKindTemplateDir(kind), "CLAUDE.md")
86
+
87
+ // Personal `.loopat/` reserved namespace: per-user loopat config + vaults.
88
+ // Mirrors `knowledge/.loopat/` as the personal counterpart.
89
+ //
90
+ // Vault model: each loop selects one vault (default = "default"). The vault is
91
+ // NOT exposed to the sandbox as a directory. Instead two filesystem conventions
92
+ // inside the vault drive automatic delivery at spawn time:
93
+ // - `vaults/<v>/envs/<NAME>` → injected as env var $NAME
94
+ // - `vaults/<v>/mounts/home/<rel>/...` → bound at $HOME/<rel>/...
95
+ // AI never sees "vault" as a concept — it just sees a configured machine.
96
+ export const personalLoopatDir = (user: string) => join(personalDir(user), ".loopat")
97
+ export const personalLoopatConfigPath = (user: string) => join(personalLoopatDir(user), "config.json")
98
+ export const personalVaultsDir = (user: string) => join(personalLoopatDir(user), "vaults")
99
+ // Personal `.claude/` — CC-native shape. The 4th layer in loopat's tiered
100
+ // .claude merge (workspace + profiles + personal + repo). Lives under
101
+ // `.loopat/` to mirror the team convention (`knowledge/.loopat/.claude/`):
102
+ // loopat-controlled config goes under `.loopat/` so the personal repo's
103
+ // other content (memory, scratch files) stays cleanly separate.
104
+ // Contains: settings.json, CLAUDE.md, skills/, agents/.
105
+ export const personalClaudeDir = (user: string) => join(personalLoopatDir(user), ".claude")
106
+ export const personalClaudeMdPath = (user: string) => join(personalClaudeDir(user), "CLAUDE.md")
107
+ export const personalSettingsPath = (user: string) => join(personalClaudeDir(user), "settings.json")
108
+ export const personalSkillsDir = (user: string) => join(personalClaudeDir(user), "skills")
109
+ export const personalAgentsDir = (user: string) => join(personalClaudeDir(user), "agents")
110
+ // Composed output inside each loop's .claude/. Regenerated every spawn.
111
+ // Plugin loading does NOT touch the loop's .claude/ — SDK loads plugins via
112
+ // its `plugins` option (resolved from server cache; see plugin-installer.ts).
113
+ export const loopComposedSkillsDir = (id: string) => join(loopDir(id), ".claude", "skills")
114
+ export const loopComposedAgentsDir = (id: string) => join(loopDir(id), ".claude", "agents")
115
+
116
+ // Platform-shipped builtin plugins live under server/templates/plugins/<name>/.
117
+ // They're always loaded into every loop (plugin-installer.ts:resolveBuiltinPlugins).
118
+ // No marketplace wrapper — direct path injection via SDK plugins option.
119
+ export const personalVaultDir = (user: string, vault: string) => join(personalVaultsDir(user), vault)
120
+ /** Convention dir: every file under this dir is auto-injected as an env var
121
+ * at spawn time. Filename = env var name. content = value (trailing newline
122
+ * stripped). Subdirs not recursed. */
123
+ export const personalVaultEnvsDir = (user: string, vault: string) =>
124
+ join(personalVaultDir(user, vault), "envs")
125
+ /** Path to a specific env-var file inside the vault. */
126
+ export const personalVaultEnvPath = (user: string, vault: string, name: string) =>
127
+ join(personalVaultEnvsDir(user, vault), name)
128
+ /** Convention dir: every top-level entry under this is auto-bound at the
129
+ * corresponding $HOME-relative path. e.g. `mounts/home/.ssh/` → `$HOME/.ssh/`. */
130
+ export const personalVaultMountsHomeDir = (user: string, vault: string) =>
131
+ join(personalVaultDir(user, vault), "mounts", "home")
132
+
133
+ // Host-only per-user state: deploy key (loopat → personal repo) and git-crypt
134
+ // key (decrypts secrets/ inside the cloned personal repo). Kept OUTSIDE
135
+ // personal/<user>/ so it never appears in the sandbox bind view. The user
136
+ // can't see these from inside their loop's terminal / file browser.
137
+ export const hostSecretsDir = (user: string) => join(LOOPAT_HOME, "host-secrets", user)
138
+ export const hostDeployKeyPath = (user: string) => join(hostSecretsDir(user), "deploy-key")
139
+ export const hostDeployKeyPubPath = (user: string) => join(hostSecretsDir(user), "deploy-key.pub")
140
+ export const personalGitCryptKeyPath = (user: string) => join(hostSecretsDir(user), "git-crypt.key")
141
+ export const personalTokenUsagePath = (user: string) => join(personalLoopatDir(user), "token-usage.json")
142
+ export const workspaceSecretsDir = () => join(workspaceDir(), "secrets")
143
+
144
+ // ─── Profile composition model (post-2026-05 design, CC-native refactor) ─
145
+ //
146
+ // See docs/composition.md. The team workspace lives inside the
147
+ // knowledge git repo at `.loopat/`, structured as a stack of CC-native
148
+ // `.claude/` directories — one per tier (team / profile). loopat materializes
149
+ // a merge of selected tiers into each loop's `.claude/`.
150
+ //
151
+ // knowledge/.loopat/
152
+ // .claude/ ← team-tier CC config
153
+ // settings.json (enabledPlugins, extraKnownMarketplaces)
154
+ // CLAUDE.md, skills/, agents/
155
+ // profiles/<name>/.claude/ ← profile-tier CC config
156
+ // settings.json, CLAUDE.md, skills/, agents/
157
+ // marketplace/ ← team's local CC marketplace
158
+ // .claude-plugin/marketplace.json
159
+ // <plugin>/...
160
+ //
161
+ // No loopat-invented schema (profile.json gone). Admins use CC's own
162
+ // commands (`claude plugin install --scope=project` etc.) inside these
163
+ // dirs to edit team / profile configuration.
164
+ export const workspaceLoopatRoot = () => join(workspaceKnowledgeDir(), ".loopat")
165
+
166
+ // Team-tier .claude/ — analogous to ~/.claude/ but shared across team via git.
167
+ export const workspaceTeamClaudeDir = () => join(workspaceLoopatRoot(), ".claude")
168
+ export const workspaceTeamSettingsPath = () => join(workspaceTeamClaudeDir(), "settings.json")
169
+ export const workspaceTeamClaudeMdPath = () => join(workspaceTeamClaudeDir(), "CLAUDE.md")
170
+ export const workspaceTeamSkillsDir = () => join(workspaceTeamClaudeDir(), "skills")
171
+ export const workspaceTeamAgentsDir = () => join(workspaceTeamClaudeDir(), "agents")
172
+
173
+ // Profiles — each is a dir with a `.claude/` subdir (CC project-tier shape).
174
+ export const workspaceProfilesDir = () => join(workspaceLoopatRoot(), "profiles")
175
+ export const workspaceProfileDir = (name: string) => join(workspaceProfilesDir(), name)
176
+ export const workspaceProfileClaudeDir = (name: string) =>
177
+ join(workspaceProfileDir(name), ".claude")
178
+ export const workspaceProfileSettingsPath = (name: string) =>
179
+ join(workspaceProfileClaudeDir(name), "settings.json")
180
+ export const workspaceProfileClaudeMdPath = (name: string) =>
181
+ join(workspaceProfileClaudeDir(name), "CLAUDE.md")
182
+ export const workspaceProfileSkillsDir = (name: string) =>
183
+ join(workspaceProfileClaudeDir(name), "skills")
184
+ export const workspaceProfileAgentsDir = (name: string) =>
185
+ join(workspaceProfileClaudeDir(name), "agents")
186
+
187
+ // (Marketplace location is NOT a loopat convention. Teams choose where to
188
+ // host private plugins — typically `<knowledge>/marketplace/` — and declare
189
+ // it via `extraKnownMarketplaces` in their team `.claude/settings.json`.
190
+ // loopat just registers whatever's declared; it doesn't probe fixed paths.)
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Loopat-managed SSH keypair for the user's personal git repo (deploy key).
3
+ *
4
+ * Lives under `host-secrets/<user>/deploy-key` — OUTSIDE personal/<user>/ so
5
+ * it never enters the sandbox bind view. The user can't see this key from
6
+ * inside their loop. It's loopat-the-platform's clone credential, not a
7
+ * user-owned tool.
8
+ *
9
+ * Public key is rendered to the UI once at register time so the user can
10
+ * register it as a deploy key on their personal git repo (aone / github).
11
+ *
12
+ * Idempotent: if the keypair already exists, returns the existing public key.
13
+ */
14
+ import { existsSync } from "node:fs"
15
+ import { mkdir, readFile, chmod } from "node:fs/promises"
16
+ import { execFile } from "node:child_process"
17
+ import { promisify } from "node:util"
18
+ import {
19
+ hostSecretsDir,
20
+ hostDeployKeyPath,
21
+ hostDeployKeyPubPath,
22
+ } from "./paths"
23
+
24
+ const execFileP = promisify(execFile)
25
+
26
+ /**
27
+ * Generate ed25519 keypair if missing. Tolerant: if `ssh-keygen` is not on
28
+ * PATH (host missing openssh-client) returns `{ publicKey: null }` so the
29
+ * rest of register/provision proceeds — user can install ssh-keygen later
30
+ * and retrigger key gen via /api/personal/import (which calls this again).
31
+ */
32
+ export async function ensurePersonalKeypair(userId: string): Promise<{ publicKey: string | null }> {
33
+ const dir = hostSecretsDir(userId)
34
+ const priv = hostDeployKeyPath(userId)
35
+ const pub = hostDeployKeyPubPath(userId)
36
+
37
+ await mkdir(dir, { recursive: true })
38
+ await chmod(dir, 0o700).catch(() => {})
39
+
40
+ if (!existsSync(priv) || !existsSync(pub)) {
41
+ const comment = `loopat:${userId}`
42
+ try {
43
+ await execFileP("ssh-keygen", ["-t", "ed25519", "-N", "", "-C", comment, "-f", priv, "-q"])
44
+ } catch (e: any) {
45
+ console.warn(`[loopat] ssh-keygen failed for user=${userId}: ${e?.message ?? e}. Install openssh-client to enable deploy-key flow.`)
46
+ return { publicKey: null }
47
+ }
48
+ await chmod(priv, 0o600).catch(() => {})
49
+ await chmod(pub, 0o644).catch(() => {})
50
+ }
51
+
52
+ const publicKey = (await readFile(pub, "utf8")).trim()
53
+ return { publicKey }
54
+ }
55
+
56
+ export async function getPublicKey(userId: string): Promise<string | null> {
57
+ const pub = hostDeployKeyPubPath(userId)
58
+ if (!existsSync(pub)) return null
59
+ return (await readFile(pub, "utf8")).trim()
60
+ }
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Plugin orchestration on the host. Post-wholesale-bind (2026-05): the inner
3
+ * SDK now resolves enabledPlugins natively because bwrap.ts ro-binds
4
+ * ~/.claude/plugins/ wholesale into the sandbox. So loopat's job here is
5
+ * purely host-side preparation:
6
+ *
7
+ * 1. Make sure every marketplace declared in the loop's merged
8
+ * `extraKnownMarketplaces` is registered with the host CC.
9
+ * 2. Make sure every spec in `enabledPlugins` is installed in the host CC
10
+ * cache (otherwise SDK would find an enabled-but-uninstalled spec and
11
+ * fail to load it).
12
+ *
13
+ * That's it — no path resolution, no return value beyond success/failure. The
14
+ * `plugins:` SDK option is reserved for the loopat-shipped builtin (which
15
+ * lives under LOOPAT_INSTALL_DIR, not in CC's plugin cache).
16
+ *
17
+ * `lookupPluginInstallPath` remains a host-side utility for the slash-command
18
+ * pre-seed (session.ts) and the loop-stats preview (loop-stats.ts).
19
+ */
20
+ import { existsSync, statSync } from "node:fs"
21
+ import { readFile } from "node:fs/promises"
22
+ import { execFile } from "node:child_process"
23
+ import { homedir } from "node:os"
24
+ import { join, resolve as resolvePath } from "node:path"
25
+ import { promisify } from "node:util"
26
+ import { TEMPLATES_DIR, loopClaudeDir } from "./paths"
27
+
28
+ const execFileP = promisify(execFile)
29
+
30
+ /** loopat-shipped builtin plugin (not in CC's plugin cache; passed via SDK option). */
31
+ export const BUILTIN_LOOPAT_PLUGIN_PATH = join(TEMPLATES_DIR, "plugins", "loopat")
32
+
33
+ const USER_CLAUDE_DIR = join(homedir(), ".claude")
34
+ const USER_INSTALLED_PLUGINS = join(USER_CLAUDE_DIR, "plugins", "installed_plugins.json")
35
+ const USER_KNOWN_MARKETPLACES = join(USER_CLAUDE_DIR, "plugins", "known_marketplaces.json")
36
+
37
+ type InstalledPluginsFile = {
38
+ version: number
39
+ plugins: Record<string, Array<{ installPath: string; version: string; scope?: string }>>
40
+ }
41
+ type KnownMarketplacesFile = Record<string, { installLocation?: string; source?: any }>
42
+ type MarketplaceCatalog = {
43
+ plugins?: Array<{ name: string; source: string | { source: string; [k: string]: any } }>
44
+ }
45
+
46
+ async function readJsonOpt<T>(path: string): Promise<T | null> {
47
+ if (!existsSync(path)) return null
48
+ try {
49
+ return JSON.parse(await readFile(path, "utf8")) as T
50
+ } catch {
51
+ return null
52
+ }
53
+ }
54
+
55
+ async function runClaude(args: string[]): Promise<{ ok: boolean; out: string; err: string }> {
56
+ try {
57
+ const { stdout, stderr } = await execFileP("claude", args)
58
+ return { ok: true, out: stdout, err: stderr }
59
+ } catch (e: any) {
60
+ return {
61
+ ok: false,
62
+ out: e?.stdout?.toString?.() ?? "",
63
+ err: e?.stderr?.toString?.() ?? e?.message ?? String(e),
64
+ }
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Compare two marketplace sources (from settings.json and from CC's
70
+ * known_marketplaces.json). Used to detect URL/path drift — if a team admin
71
+ * changes the marketplace URL, members' host CC needs to re-register.
72
+ */
73
+ export function sourcesMatch(declared: any, existing: any): boolean {
74
+ if (declared === existing) return true
75
+ if (!declared || !existing) return false
76
+ if (typeof declared !== "object" || typeof existing !== "object") return false
77
+ if (declared.source !== existing.source) return false
78
+ switch (declared.source) {
79
+ case "git":
80
+ case "url":
81
+ return declared.url === existing.url
82
+ case "github":
83
+ return (declared.repo ?? declared.repository) === (existing.repo ?? existing.repository)
84
+ case "directory":
85
+ return declared.path === existing.path
86
+ default:
87
+ return JSON.stringify(declared) === JSON.stringify(existing)
88
+ }
89
+ }
90
+
91
+ async function ensureMarketplace(
92
+ name: string,
93
+ addPath: string,
94
+ declaredSource: any,
95
+ km: KnownMarketplacesFile | null,
96
+ ): Promise<void> {
97
+ const existing = (km?.[name] as any)?.source
98
+ if (existing) {
99
+ if (sourcesMatch(declaredSource, existing)) return
100
+ console.warn(
101
+ `[plugins] marketplace "${name}" source drift; re-registering ` +
102
+ `(was ${JSON.stringify(existing)}, want ${JSON.stringify(declaredSource)})`,
103
+ )
104
+ await runClaude(["plugin", "marketplace", "remove", name])
105
+ }
106
+ const add = await runClaude(["plugin", "marketplace", "add", addPath])
107
+ if (!add.ok) {
108
+ console.warn(`[plugins] failed to register marketplace "${name}": ${add.err}`)
109
+ }
110
+ }
111
+
112
+ async function ensureExtraMarketplaces(
113
+ extras: Record<string, { source?: any }> | undefined,
114
+ loopId: string,
115
+ km: KnownMarketplacesFile | null,
116
+ ): Promise<void> {
117
+ if (!extras) return
118
+ for (const [name, entry] of Object.entries(extras)) {
119
+ const src = entry?.source as any
120
+ let addPath: string | undefined
121
+ let normalized: any = src
122
+ if (typeof src === "string") {
123
+ addPath = src
124
+ normalized = { source: "github", repo: src }
125
+ } else if (src?.source === "directory" && typeof src.path === "string") {
126
+ addPath = resolvePath(loopClaudeDir(loopId), src.path)
127
+ normalized = { source: "directory", path: addPath }
128
+ } else if (src?.source === "github" && typeof src.repo === "string") {
129
+ addPath = src.repo
130
+ normalized = { source: "github", repo: src.repo }
131
+ } else if ((src?.source === "git" || src?.source === "url") && typeof src.url === "string") {
132
+ addPath = src.url
133
+ normalized = { source: src.source, url: src.url }
134
+ }
135
+ if (!addPath) {
136
+ console.warn(`[plugins] extraKnownMarketplaces["${name}"]: unsupported source shape, skip`)
137
+ continue
138
+ }
139
+ await ensureMarketplace(name, addPath, normalized, km)
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Install + version-pin check.
145
+ *
146
+ * `lockedVersions` maps spec → required version label, sourced from the loop's
147
+ * snapshot installed_plugins.json (compose merged it from team/profile/personal
148
+ * tiers). When provided we check the host already has that exact version in
149
+ * its CC cache; if not, run `claude plugin install` and verify the resulting
150
+ * version matches.
151
+ *
152
+ * Mismatch is loud-failure (option a — see docs/composition.md). The recovery
153
+ * is for the user to either restore the pinned version manually (e.g. via
154
+ * marketplace clone checkout dance) or for admin to bump the team lock.
155
+ * Option b (loopat does the checkout dance automatically) is a future add.
156
+ *
157
+ * When `lockedVersions[spec]` is null/undefined: no version pin → fall back to
158
+ * the old "install if missing" behavior.
159
+ */
160
+ async function ensurePluginsInstalled(
161
+ specs: string[],
162
+ ip: InstalledPluginsFile | null,
163
+ lockedVersions: Record<string, string | undefined>,
164
+ ): Promise<void> {
165
+ if (specs.length === 0) return
166
+ const installedKeys = new Set(Object.keys(ip?.plugins ?? {}))
167
+ for (const spec of specs) {
168
+ const wantVersion = lockedVersions[spec]
169
+ const hostEntry = ip?.plugins?.[spec]?.[0]
170
+ const hostVersion = hostEntry?.version
171
+
172
+ if (wantVersion && hostVersion === wantVersion) continue // pinned + matches
173
+ if (!wantVersion && installedKeys.has(spec)) continue // not pinned + installed
174
+
175
+ const r = await runClaude(["plugin", "install", spec, "--scope=user"])
176
+ if (!r.ok) {
177
+ console.warn(`[plugins] install failed for "${spec}": ${r.err.trim().split("\n").slice(-2).join(" | ")}`)
178
+ continue
179
+ }
180
+
181
+ // Verify post-install version matches the pin, if there is one.
182
+ if (wantVersion) {
183
+ const ip2 = await readJsonOpt<InstalledPluginsFile>(USER_INSTALLED_PLUGINS)
184
+ const got = ip2?.plugins?.[spec]?.[0]?.version
185
+ if (got !== wantVersion) {
186
+ throw new Error(
187
+ `[plugins] version mismatch for "${spec}": loop pinned "${wantVersion}", ` +
188
+ `host installed "${got ?? "<unknown>"}". CC's marketplace clone has likely ` +
189
+ `advanced past the pinned version. Options:\n` +
190
+ ` 1. Admin: bump the team lock — set this spec's version to "${got ?? "<new>"}" ` +
191
+ `in knowledge/.loopat/.claude/plugins/installed_plugins.json + git push knowledge.\n` +
192
+ ` 2. Member: restore the pinned version manually — checkout marketplace at the ` +
193
+ `pinned gitCommitSha, run \`claude plugin install ${spec}\`, then checkout master.`,
194
+ )
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Mtime cache on loops/<id>/.claude/settings.json — we only re-run marketplace
202
+ * registration + install when compose actually rewrote settings.
203
+ */
204
+ type EnsureCacheEntry = { mtime: number }
205
+ const ensureCache = new Map<string, EnsureCacheEntry>()
206
+
207
+ /**
208
+ * Idempotently ensure the host CC has every marketplace + enabled plugin
209
+ * installed that the loop's merged settings.json declares. No return value —
210
+ * the inner SDK resolves enabledPlugins natively at spawn time (via the
211
+ * wholesale ~/.claude/plugins/ bind in bwrap.ts).
212
+ */
213
+ export async function ensureLoopPluginsInstalled(loopId: string): Promise<void> {
214
+ const settingsPath = join(loopClaudeDir(loopId), "settings.json")
215
+ const mtime = existsSync(settingsPath) ? statSync(settingsPath).mtimeMs : 0
216
+
217
+ const cached = ensureCache.get(loopId)
218
+ if (cached && cached.mtime === mtime) return
219
+
220
+ const settings = await readJsonOpt<{
221
+ enabledPlugins?: Record<string, boolean>
222
+ extraKnownMarketplaces?: Record<string, { source?: any }>
223
+ }>(settingsPath)
224
+
225
+ const enabled = Object.entries(settings?.enabledPlugins ?? {})
226
+ .filter(([_, v]) => v)
227
+ .map(([k]) => k)
228
+
229
+ if (enabled.length === 0 && !settings?.extraKnownMarketplaces) {
230
+ ensureCache.set(loopId, { mtime })
231
+ return
232
+ }
233
+
234
+ const km = await readJsonOpt<KnownMarketplacesFile>(USER_KNOWN_MARKETPLACES)
235
+ const ip = await readJsonOpt<InstalledPluginsFile>(USER_INSTALLED_PLUGINS)
236
+
237
+ // Read the loop's plugin version lock (compose's merged installed_plugins.json
238
+ // snapshot). When present, ensurePluginsInstalled enforces the pinned
239
+ // version per spec; otherwise it falls back to "install if missing".
240
+ const lockPath = join(loopClaudeDir(loopId), "plugins", "installed_plugins.json")
241
+ const lock = await readJsonOpt<InstalledPluginsFile>(lockPath)
242
+ const lockedVersions: Record<string, string | undefined> = {}
243
+ for (const [spec, entries] of Object.entries(lock?.plugins ?? {})) {
244
+ lockedVersions[spec] = entries[0]?.version
245
+ }
246
+
247
+ await ensureExtraMarketplaces(settings?.extraKnownMarketplaces, loopId, km)
248
+ await ensurePluginsInstalled(enabled, ip, lockedVersions)
249
+
250
+ ensureCache.set(loopId, { mtime })
251
+ }
252
+
253
+ /**
254
+ * Resolve a `name@marketplace` spec to a host path (best-effort, no install
255
+ * side-effect). Used by:
256
+ * - session.ts: pre-seed plugin slash-commands before CC's init payload
257
+ * - loop-stats.ts: count plugin contributions for the NewLoopDialog preview
258
+ *
259
+ * Prefers the marketplace's local source dir (preserves symlinks); falls back
260
+ * to the CC cache installPath. Returns null if not installed.
261
+ */
262
+ export async function lookupPluginInstallPath(spec: string): Promise<string | null> {
263
+ const ip = await readJsonOpt<InstalledPluginsFile>(USER_INSTALLED_PLUGINS)
264
+ const km = await readJsonOpt<KnownMarketplacesFile>(USER_KNOWN_MARKETPLACES)
265
+ if (!ip) return null
266
+ const entry = ip.plugins?.[spec]?.[0]
267
+ if (!entry?.installPath) return null
268
+
269
+ const atIdx = spec.lastIndexOf("@")
270
+ if (atIdx >= 0) {
271
+ const pluginName = spec.slice(0, atIdx)
272
+ const marketName = spec.slice(atIdx + 1)
273
+ const market = km?.[marketName]
274
+ if (market?.installLocation) {
275
+ const catalog = await readJsonOpt<MarketplaceCatalog>(
276
+ join(market.installLocation, ".claude-plugin", "marketplace.json"),
277
+ )
278
+ const cat = catalog?.plugins?.find((p) => p.name === pluginName)
279
+ const src = typeof cat?.source === "string" ? cat.source : null
280
+ if (src?.startsWith("./")) {
281
+ const p = join(market.installLocation, src)
282
+ if (existsSync(p)) return p
283
+ }
284
+ }
285
+ }
286
+ return existsSync(entry.installPath) ? entry.installPath : null
287
+ }