loadout-ai 0.8.0 → 0.9.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 (82) hide show
  1. package/CHANGELOG.md +76 -1
  2. package/README.md +149 -304
  3. package/SECURITY.md +21 -1
  4. package/catalog/discovered.json +27897 -25327
  5. package/catalog/packages.json +4 -4
  6. package/dist/src/cli.js +12 -4
  7. package/dist/src/commands/agents.js +3 -1
  8. package/dist/src/commands/catalog-candidate.js +150 -0
  9. package/dist/src/commands/catalog-workflows.js +147 -0
  10. package/dist/src/commands/catalog.js +9 -354
  11. package/dist/src/commands/coordinate.js +586 -0
  12. package/dist/src/commands/coordination-discussions.js +194 -0
  13. package/dist/src/commands/coordination-sessions.js +197 -0
  14. package/dist/src/commands/inventory.js +4 -2
  15. package/dist/src/core/{routing → agents}/model-config.js +7 -2
  16. package/dist/src/core/catalog/catalog.js +1 -0
  17. package/dist/src/core/catalog/registry.js +5 -19
  18. package/dist/src/core/coordination/adapters/claude-code.js +154 -0
  19. package/dist/src/core/coordination/adapters/codex.js +137 -0
  20. package/dist/src/core/coordination/adapters/types.js +64 -0
  21. package/dist/src/core/coordination/auth.js +59 -0
  22. package/dist/src/core/coordination/bridge-lease.js +79 -0
  23. package/dist/src/core/coordination/conflict-preview.js +167 -0
  24. package/dist/src/core/coordination/contract-diff.js +106 -0
  25. package/dist/src/core/coordination/coordinator.js +552 -0
  26. package/dist/src/core/coordination/crash-recovery.js +169 -0
  27. package/dist/src/core/coordination/daemon.js +667 -0
  28. package/dist/src/core/coordination/discussion.js +340 -0
  29. package/dist/src/core/coordination/events.js +161 -0
  30. package/dist/src/core/coordination/http-api.js +118 -0
  31. package/dist/src/core/coordination/interrupt-policy.js +89 -0
  32. package/dist/src/core/coordination/lock.js +110 -0
  33. package/dist/src/core/coordination/mcp-server.js +424 -0
  34. package/dist/src/core/coordination/redaction.js +85 -0
  35. package/dist/src/core/coordination/replay.js +188 -0
  36. package/dist/src/core/coordination/retention.js +128 -0
  37. package/dist/src/core/coordination/runtime.js +21 -0
  38. package/dist/src/core/coordination/session-manager.js +366 -0
  39. package/dist/src/core/coordination/watcher.js +128 -0
  40. package/dist/src/core/{routing → delegation}/first-party-skills.js +25 -13
  41. package/dist/src/core/{routing → delegation}/handoff.js +40 -9
  42. package/dist/src/core/discovery/candidate-intelligence-evidence.js +108 -0
  43. package/dist/src/core/discovery/candidate-intelligence-types.js +1 -0
  44. package/dist/src/core/discovery/candidate-intelligence-validation.js +108 -0
  45. package/dist/src/core/discovery/candidate-intelligence.js +3 -214
  46. package/dist/src/core/discovery/community.js +12 -4
  47. package/dist/src/core/discovery/github-discovery.js +6 -2
  48. package/dist/src/core/discovery/private-discovery.js +6 -2
  49. package/dist/src/core/install/reconcile.js +1 -1
  50. package/dist/src/core/install/uninstall.js +39 -3
  51. package/dist/src/core/reporting/cli-guide.js +9 -5
  52. package/dist/src/core/reporting/completion.js +43 -38
  53. package/dist/src/core/reporting/doctor.js +1 -1
  54. package/dist/src/core/runtime/bounded-json.js +65 -0
  55. package/dist/src/core/runtime/github.js +30 -16
  56. package/dist/src/core/runtime/mcp-recipes.js +1 -1
  57. package/dist/src/core/workspace/active-policy.js +1 -1
  58. package/docs/DEMO_SCRIPT.md +19 -23
  59. package/docs/DISCOVERED.md +251 -251
  60. package/docs/FEATURE_TEST_MATRIX.md +3 -3
  61. package/docs/LIVE_COLLABORATION.md +234 -0
  62. package/docs/REFERENCE.md +163 -0
  63. package/docs/RELEASE_REVIEW.md +1 -1
  64. package/docs/TESTING.md +17 -1
  65. package/docs/USER_TEST_GUIDE.md +138 -3
  66. package/docs/assets/loadout-discover-activate.webp +0 -0
  67. package/docs/assets/loadout-handoff-coordinate.webp +0 -0
  68. package/docs/assets/loadout-social-preview.png +0 -0
  69. package/docs/decisions/001-coordination-jsonl-locking.md +35 -0
  70. package/docs/decisions/002-local-daemon-authentication.md +32 -0
  71. package/docs/decisions/003-bounded-agent-discussions.md +91 -0
  72. package/docs/specs/BOUNDED_AGENT_DISCUSSIONS.md +169 -0
  73. package/docs/superpowers/plans/2026-09-03-coordination-hardening.md +231 -0
  74. package/docs/superpowers/plans/2026-09-03-release-readiness.md +232 -0
  75. package/docs/superpowers/plans/2026-09-04-bounded-agent-discussions.md +121 -0
  76. package/package.json +13 -4
  77. package/skills/loadout-handoff/SKILL.md +238 -0
  78. package/dist/src/core/routing/policy.js +0 -147
  79. package/dist/src/core/routing/route.js +0 -411
  80. package/docs/assets/loadout-workflow.png +0 -0
  81. package/skills/loadout-router/SKILL.md +0 -85
  82. /package/dist/src/core/{routing → agents}/credentials.js +0 -0
@@ -0,0 +1,194 @@
1
+ import { formatDiscussion, getDiscussion, listDiscussions, requiredDiscussionTurns, runDiscussion, validateDiscussionOptions, } from "../core/coordination/discussion.js";
2
+ import { SessionManager } from "../core/coordination/session-manager.js";
3
+ import { createProviderAdapters } from "../core/coordination/runtime.js";
4
+ import { acquireBridgeLease } from "../core/coordination/bridge-lease.js";
5
+ import { parseProviderSessionRef, } from "./coordination-sessions.js";
6
+ function assertBothProviders(values) {
7
+ if (values.length !== 2 ||
8
+ new Set(values).size !== 2 ||
9
+ !values.includes("claude-code") ||
10
+ !values.includes("codex")) {
11
+ throw new Error("A discussion requires exactly claude-code and codex, each once");
12
+ }
13
+ }
14
+ export function parseDiscussionAgents(value) {
15
+ const providers = value
16
+ .split(",")
17
+ .map((provider) => provider.trim())
18
+ .filter(Boolean);
19
+ assertBothProviders(providers);
20
+ return providers;
21
+ }
22
+ export function parseDiscussionSessions(values) {
23
+ if (values.length !== 2) {
24
+ throw new Error("A discussion requires exactly two provider sessions");
25
+ }
26
+ const sessions = values.map(parseProviderSessionRef);
27
+ assertBothProviders(sessions.map((session) => session.provider));
28
+ if (sessions[0].sessionId === sessions[1].sessionId) {
29
+ throw new Error("Discussion session IDs must be different");
30
+ }
31
+ return sessions;
32
+ }
33
+ export function resolveDiscussionSelection(options) {
34
+ if (options.agents && options.sessions) {
35
+ throw new Error("Choose either --agents or --sessions, not both");
36
+ }
37
+ if (!options.agents && !options.sessions) {
38
+ throw new Error("Discussion start requires --agents or --sessions");
39
+ }
40
+ const mode = options.agents ? "fresh" : "existing";
41
+ const selected = options.agents
42
+ ? parseDiscussionAgents(options.agents).map((provider) => ({
43
+ provider,
44
+ }))
45
+ : parseDiscussionSessions(options.sessions ?? []).map((session) => ({
46
+ provider: session.provider,
47
+ sessionId: session.sessionId,
48
+ }));
49
+ if (options.proposer) {
50
+ if (!selected.some((item) => item.provider === options.proposer)) {
51
+ throw new Error("--proposer must be claude-code or codex and be part of the discussion");
52
+ }
53
+ selected.sort((left) => (left.provider === options.proposer ? -1 : 1));
54
+ }
55
+ return { mode, participants: selected };
56
+ }
57
+ export function discussionTimeoutMs(seconds) {
58
+ if (!Number.isSafeInteger(seconds) || seconds < 10 || seconds > 600) {
59
+ throw new Error("Discussion timeout must be an integer between 10 and 600 seconds");
60
+ }
61
+ return seconds * 1_000;
62
+ }
63
+ /** Adapt a fresh or already-attached provider session to the discussion port. */
64
+ export function createSessionParticipant(sessions, selection, role, cwd, timeoutMs) {
65
+ let sessionId = selection.sessionId;
66
+ return {
67
+ agent: selection.provider,
68
+ role,
69
+ async respond(prompt) {
70
+ if (!sessionId) {
71
+ const started = await sessions.startSession(selection.provider, cwd, prompt, timeoutMs);
72
+ sessionId = started.sessionId;
73
+ }
74
+ else {
75
+ const accepted = await sessions.submitTurn(sessionId, prompt, timeoutMs);
76
+ if (!accepted) {
77
+ throw new Error(`${selection.provider}:${sessionId} rejected the discussion turn`);
78
+ }
79
+ }
80
+ return sessions.getLastResponse(sessionId) ?? "";
81
+ },
82
+ };
83
+ }
84
+ function createManager(projectRoot) {
85
+ return new SessionManager({
86
+ projectRoot,
87
+ adapters: createProviderAdapters(),
88
+ autoSubmit: false,
89
+ });
90
+ }
91
+ /** Register opt-in bounded provider discussions under `loadout coord discuss`. */
92
+ export function registerCoordinationDiscussions(coord) {
93
+ const discuss = coord
94
+ .command("discuss")
95
+ .description("Bounded Claude Code ↔ Codex design discussions");
96
+ discuss
97
+ .command("list")
98
+ .description("List recorded design discussions without running an agent")
99
+ .option("--json", "machine-readable JSON output")
100
+ .action(async (options) => {
101
+ const discussions = await listDiscussions(process.cwd());
102
+ if (options.json) {
103
+ console.log(JSON.stringify(discussions, null, 2));
104
+ }
105
+ else if (discussions.length === 0) {
106
+ console.log("No design discussions recorded.");
107
+ }
108
+ else {
109
+ for (const thread of discussions) {
110
+ console.log(`${thread.threadId} ${thread.status} ${thread.participants.join(" ↔ ")} ${thread.topic}`);
111
+ }
112
+ }
113
+ });
114
+ discuss
115
+ .command("show")
116
+ .description("Show one public discussion transcript without running an agent")
117
+ .argument("<thread-id>", "discussion thread ID")
118
+ .option("--json", "machine-readable JSON output")
119
+ .action(async (threadId, options) => {
120
+ const discussion = await getDiscussion(process.cwd(), threadId);
121
+ if (!discussion) {
122
+ throw new Error(`No discussion named '${threadId}'`);
123
+ }
124
+ console.log(options.json
125
+ ? JSON.stringify(discussion, null, 2)
126
+ : formatDiscussion(discussion));
127
+ });
128
+ discuss
129
+ .command("start")
130
+ .description("Run a bounded design discussion (spends paid provider turns)")
131
+ .argument("<topic>", "technical question for Claude Code and Codex")
132
+ .option("--agents <providers>", "start fresh sessions: claude-code,codex")
133
+ .option("--sessions <references...>", "reuse provider sessions: claude-code:<id> codex:<id>")
134
+ .option("--proposer <provider>", "claude-code or codex")
135
+ .option("--rounds <n>", "rounds; each agent speaks once per round", Number, 2)
136
+ .option("--max-turns <n>", "hard paid-turn budget", Number, 17)
137
+ .option("--timeout <seconds>", "timeout for each paid provider turn", Number, 120)
138
+ .option("--thread-id <id>", "stable discussion ID")
139
+ .option("--initiator <name>", "coordination identity starting the discussion", "user")
140
+ .option("--json", "machine-readable final output")
141
+ .action(async (topic, options) => {
142
+ const projectRoot = process.cwd();
143
+ const selection = resolveDiscussionSelection(options);
144
+ const timeoutMs = discussionTimeoutMs(options.timeout);
145
+ const noop = async () => "validated only";
146
+ validateDiscussionOptions({
147
+ topic,
148
+ rounds: options.rounds,
149
+ maxTurns: options.maxTurns,
150
+ participants: [
151
+ {
152
+ agent: selection.participants[0].provider,
153
+ role: "proposer",
154
+ respond: noop,
155
+ },
156
+ {
157
+ agent: selection.participants[1].provider,
158
+ role: "reviewer",
159
+ respond: noop,
160
+ },
161
+ ],
162
+ });
163
+ const plannedTurns = requiredDiscussionTurns(options.rounds);
164
+ console.error(`Starting bounded discussion: exactly ${plannedTurns} paid provider turns (${options.rounds} round(s) plus synthesis).`);
165
+ const lease = await acquireBridgeLease(projectRoot);
166
+ const sessions = createManager(projectRoot);
167
+ try {
168
+ await sessions.start();
169
+ if (selection.mode === "existing") {
170
+ for (const item of selection.participants) {
171
+ await sessions.attachSession(item.provider, item.sessionId, projectRoot);
172
+ }
173
+ }
174
+ const result = await runDiscussion(projectRoot, {
175
+ topic,
176
+ rounds: options.rounds,
177
+ maxTurns: options.maxTurns,
178
+ ...(options.threadId ? { threadId: options.threadId } : {}),
179
+ initiatedBy: options.initiator,
180
+ participants: [
181
+ createSessionParticipant(sessions, selection.participants[0], "proposer", projectRoot, timeoutMs),
182
+ createSessionParticipant(sessions, selection.participants[1], "reviewer", projectRoot, timeoutMs),
183
+ ],
184
+ });
185
+ console.log(options.json
186
+ ? JSON.stringify(result, null, 2)
187
+ : formatDiscussion(result.state));
188
+ }
189
+ finally {
190
+ await sessions.stop();
191
+ await lease.release();
192
+ }
193
+ });
194
+ }
@@ -0,0 +1,197 @@
1
+ import { SessionManager } from "../core/coordination/session-manager.js";
2
+ import { createProviderAdapters } from "../core/coordination/runtime.js";
3
+ import { acquireBridgeLease } from "../core/coordination/bridge-lease.js";
4
+ const SUPPORTED_PROVIDERS = new Set(["claude-code", "codex"]);
5
+ export function parseProviderSessionRef(value) {
6
+ const separator = value.indexOf(":");
7
+ if (separator < 1) {
8
+ throw new Error(`Invalid session reference '${value}'; expected provider:session-id`);
9
+ }
10
+ const provider = value.slice(0, separator);
11
+ const sessionId = value.slice(separator + 1).trim();
12
+ if (!SUPPORTED_PROVIDERS.has(provider)) {
13
+ throw new Error(`Unsupported provider '${provider}'; supported providers: claude-code, codex`);
14
+ }
15
+ if (!sessionId) {
16
+ throw new Error(`Invalid session reference '${value}'; session id is empty`);
17
+ }
18
+ return { provider, sessionId };
19
+ }
20
+ function manager(projectRoot, autoSubmit, maxAutoTurnsPerSession = 20) {
21
+ return new SessionManager({
22
+ projectRoot,
23
+ adapters: createProviderAdapters(),
24
+ autoSubmit,
25
+ maxAutoTurnsPerSession,
26
+ onPendingEvents(session, events) {
27
+ console.error(`Could not route ${events.length} event(s) to ${session.provider}:${session.sessionId}; the session may be busy or unavailable.`);
28
+ },
29
+ ...(autoSubmit
30
+ ? {
31
+ onTurnCompleted(session, response) {
32
+ console.log(`\n[${session.provider}:${session.sessionId}] ${response ?? "Coordination turn completed."}`);
33
+ },
34
+ }
35
+ : {}),
36
+ });
37
+ }
38
+ async function withStoppedManager(projectRoot, operation) {
39
+ const sessions = manager(projectRoot, false);
40
+ await sessions.start();
41
+ try {
42
+ return await operation(sessions);
43
+ }
44
+ finally {
45
+ await sessions.stop();
46
+ }
47
+ }
48
+ /** Register opt-in provider-driven turns under `loadout coord agents`. */
49
+ export function registerCoordinationSessions(coord) {
50
+ const agents = coord
51
+ .command("agents")
52
+ .description("Bridge provider sessions so coordination events become follow-up turns (beta)");
53
+ agents
54
+ .command("detect")
55
+ .description("Detect supported provider runtimes")
56
+ .option("--json", "machine-readable JSON output")
57
+ .action(async (options) => {
58
+ const providers = await withStoppedManager(process.cwd(), async (sessions) => sessions.detectProviders());
59
+ if (options.json) {
60
+ console.log(JSON.stringify(providers, null, 2));
61
+ }
62
+ else if (providers.length === 0) {
63
+ console.log("No supported provider runtimes detected.");
64
+ }
65
+ else {
66
+ for (const provider of providers) {
67
+ console.log(`${provider.provider}: ${provider.version}`);
68
+ }
69
+ }
70
+ });
71
+ agents
72
+ .command("list")
73
+ .description("List sessions tracked by this project")
74
+ .option("--json", "machine-readable JSON output")
75
+ .action(async (options) => {
76
+ const tracked = await withStoppedManager(process.cwd(), async (sessions) => sessions.getSessions());
77
+ if (options.json) {
78
+ console.log(JSON.stringify(tracked, null, 2));
79
+ }
80
+ else if (tracked.length === 0) {
81
+ console.log("No provider sessions tracked for this project.");
82
+ }
83
+ else {
84
+ for (const session of tracked) {
85
+ console.log(`${session.provider}:${session.sessionId} cursor=${session.cursor} ${session.active ? "active" : "detached"}`);
86
+ }
87
+ }
88
+ });
89
+ agents
90
+ .command("start")
91
+ .description("Start and track a provider session (runs a paid agent turn)")
92
+ .argument("<provider>", "claude-code or codex")
93
+ .argument("[prompt...]", "initial task prompt")
94
+ .option("--json", "machine-readable JSON output")
95
+ .action(async (provider, prompt, options) => {
96
+ if (!SUPPORTED_PROVIDERS.has(provider)) {
97
+ throw new Error(`Unsupported provider '${provider}'; use claude-code or codex`);
98
+ }
99
+ const result = await withStoppedManager(process.cwd(), async (sessions) => {
100
+ const session = await sessions.startSession(provider, process.cwd(), prompt.join(" "));
101
+ return {
102
+ session,
103
+ response: sessions.getLastResponse(session.sessionId),
104
+ };
105
+ });
106
+ const { session, response } = result;
107
+ if (options.json) {
108
+ console.log(JSON.stringify({ session, response }, null, 2));
109
+ }
110
+ else {
111
+ console.log(`Started ${provider}:${session.sessionId}`);
112
+ if (response)
113
+ console.log(`\n${response}`);
114
+ console.log(`Keep it live with: loadout coord agents bridge ${provider}:${session.sessionId}`);
115
+ }
116
+ });
117
+ agents
118
+ .command("attach")
119
+ .description("Track an existing provider session without running a turn")
120
+ .argument("<session>", "provider:session-id")
121
+ .option("--json", "machine-readable JSON output")
122
+ .action(async (reference, options) => {
123
+ const parsed = parseProviderSessionRef(reference);
124
+ const session = await withStoppedManager(process.cwd(), async (sessions) => sessions.attachSession(parsed.provider, parsed.sessionId, process.cwd()));
125
+ if (options.json) {
126
+ console.log(JSON.stringify(session, null, 2));
127
+ }
128
+ else {
129
+ console.log(`Attached ${parsed.provider}:${parsed.sessionId}`);
130
+ }
131
+ });
132
+ agents
133
+ .command("send")
134
+ .description("Send one follow-up turn to a tracked provider session")
135
+ .argument("<session-id>", "tracked provider session id")
136
+ .argument("<message...>", "follow-up message")
137
+ .option("--json", "machine-readable JSON output")
138
+ .action(async (sessionId, message, options) => {
139
+ const result = await withStoppedManager(process.cwd(), async (sessions) => {
140
+ const tracked = sessions
141
+ .getSessions()
142
+ .find((session) => session.sessionId === sessionId);
143
+ if (!tracked) {
144
+ throw new Error(`Session ${sessionId} is not tracked; use 'loadout coord agents attach provider:${sessionId}' first`);
145
+ }
146
+ await sessions.resumeSession(tracked.provider, tracked.sessionId, process.cwd());
147
+ const sent = await sessions.submitTurn(sessionId, message.join(" "));
148
+ return {
149
+ sent,
150
+ response: sessions.getLastResponse(sessionId),
151
+ };
152
+ });
153
+ if (!result.sent) {
154
+ throw new Error(`Provider session ${sessionId} did not accept the turn`);
155
+ }
156
+ if (options.json) {
157
+ console.log(JSON.stringify({
158
+ sent: true,
159
+ sessionId,
160
+ response: result.response,
161
+ }));
162
+ }
163
+ else {
164
+ console.log(`Sent follow-up turn to ${sessionId}`);
165
+ if (result.response)
166
+ console.log(`\n${result.response}`);
167
+ }
168
+ });
169
+ agents
170
+ .command("bridge")
171
+ .description("Keep sessions connected and route coordination events as follow-up turns")
172
+ .argument("<sessions...>", "one or more provider:session-id references")
173
+ .option("--max-turns <n>", "maximum automatic turns per session before pausing", Number, 20)
174
+ .action(async (references, options) => {
175
+ const projectRoot = process.cwd();
176
+ const lease = await acquireBridgeLease(projectRoot);
177
+ const sessions = manager(projectRoot, true, options.maxTurns);
178
+ try {
179
+ await sessions.start();
180
+ for (const reference of references) {
181
+ const parsed = parseProviderSessionRef(reference);
182
+ await sessions.attachSession(parsed.provider, parsed.sessionId, projectRoot);
183
+ }
184
+ console.log(`Bridge active for ${references.join(", ")}. Coordination events will be delivered at safe turn boundaries.`);
185
+ console.log("Press Ctrl+C to stop.");
186
+ await new Promise((resolve) => {
187
+ const stop = () => resolve();
188
+ process.once("SIGINT", stop);
189
+ process.once("SIGTERM", stop);
190
+ });
191
+ }
192
+ finally {
193
+ await sessions.stop();
194
+ await lease.release();
195
+ }
196
+ });
197
+ }
@@ -47,7 +47,7 @@ export function registerInventory(program) {
47
47
  force: options.force,
48
48
  onProgress: (message) => console.log(`→ ${message}`),
49
49
  });
50
- console.log(`Removed ${result.removedPackages} managed package(s), ${result.removedRuntimeTools} runtime tool(s), daily jobs, cache, library, and Loadout state.`);
50
+ console.log(`Removed ${result.removedPackages} managed package(s), ${result.removedRuntimeTools} runtime tool(s), ${result.removedFirstPartySkills} first-party skill install(s), daily jobs, cache, library, and Loadout state.`);
51
51
  if (options.removeCli) {
52
52
  await uninstallGlobalCli();
53
53
  console.log("Removed the global loadout-ai CLI. Open a new shell before checking the command again.");
@@ -215,7 +215,9 @@ export function registerInventory(program) {
215
215
  });
216
216
  console.log(options.json
217
217
  ? JSON.stringify({ plan, result }, null, 2)
218
- : `${formatReconcilePlan(plan)}\n\nReconciled ${result.adopted} exact group(s) and ${result.updated} outdated group(s). Snapshot: ${result.snapshotId}`);
218
+ : result.snapshotId
219
+ ? `${formatReconcilePlan(plan)}\n\nReconciled ${result.adopted} exact group(s) and ${result.updated} outdated group(s). Snapshot: ${result.snapshotId}`
220
+ : `${formatReconcilePlan(plan)}\n\nNothing to reconcile. No snapshot was needed.`);
219
221
  });
220
222
  program
221
223
  .command("adopt")
@@ -5,6 +5,7 @@ import { formatSchemaError, providerModelConfigurationSchema, } from "../../shar
5
5
  import { writeFileAtomically } from "../install/atomic-file.js";
6
6
  import { ensureDirectory, loadoutHome } from "../agents/paths.js";
7
7
  import { runMutationTransaction } from "../install/transaction.js";
8
+ import { boundedJson } from "../runtime/bounded-json.js";
8
9
  export const defaultModelConfigurationPath = () => join(loadoutHome(), "models.json");
9
10
  /**
10
11
  * Validate a portable, provider-neutral model configuration. This is not a
@@ -87,7 +88,7 @@ export async function requestOpenRouter(configuration, selectionId, messages, op
87
88
  const token = await options.resolveCredential(selection.credential);
88
89
  if (!token)
89
90
  throw new Error("OpenRouter credential reference did not resolve");
90
- const response = await (options.fetcher ?? fetch)(`${selection.endpoint.replace(/\/$/, "")}/chat/completions`, {
91
+ const { response, value } = await boundedJson(options.fetcher ?? fetch, `${selection.endpoint.replace(/\/$/, "")}/chat/completions`, {
91
92
  method: "POST",
92
93
  headers: {
93
94
  accept: "application/json",
@@ -95,8 +96,12 @@ export async function requestOpenRouter(configuration, selectionId, messages, op
95
96
  authorization: `Bearer ${token}`,
96
97
  },
97
98
  body: JSON.stringify({ model: selection.model, messages }),
99
+ }, {
100
+ timeoutMs: 30_000,
101
+ maxBytes: 8 * 1024 * 1024,
102
+ label: "OpenRouter",
98
103
  });
99
104
  if (!response.ok)
100
105
  throw new Error(`OpenRouter request failed (${response.status})`);
101
- return response.json();
106
+ return value;
102
107
  }
@@ -14,6 +14,7 @@ function bundledCatalogPath() {
14
14
  const candidates = [
15
15
  join(moduleDirectory, "..", "..", "catalog", "packages.json"),
16
16
  join(moduleDirectory, "..", "..", "..", "catalog", "packages.json"),
17
+ join(moduleDirectory, "..", "..", "..", "..", "catalog", "packages.json"),
17
18
  join(process.cwd(), "catalog", "packages.json"),
18
19
  ];
19
20
  return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { cp, lstat, mkdir, readFile, readdir, rename, rm, writeFile, } from "node:fs/promises";
3
3
  import { join, relative, resolve, sep } from "node:path";
4
4
  import { loadoutHome } from "../agents/paths.js";
5
+ import { boundedJson } from "../runtime/bounded-json.js";
5
6
  import { analyzeUpdateSafety } from "./safety.js";
6
7
  const NAME = /^[a-z0-9][a-z0-9._-]*$/;
7
8
  /** A SHA-256 digest, and nothing that could traverse a path. */
@@ -272,26 +273,11 @@ function registryEndpoint(base, path) {
272
273
  const REGISTRY_TIMEOUT_MS = 30_000;
273
274
  const REGISTRY_MAX_BYTES = 32 * 1024 * 1024;
274
275
  async function registryJson(input, init = {}) {
275
- const response = await fetch(input, {
276
- ...init,
277
- signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS),
276
+ const { response, value } = await boundedJson(fetch, input, init, {
277
+ timeoutMs: REGISTRY_TIMEOUT_MS,
278
+ maxBytes: REGISTRY_MAX_BYTES,
279
+ label: "Remote registry",
278
280
  });
279
- // Trust the declared length when present, but still cap what is read: a
280
- // server may understate or omit it.
281
- const declared = Number(response.headers.get("content-length"));
282
- if (Number.isFinite(declared) && declared > REGISTRY_MAX_BYTES)
283
- throw new Error("Remote registry response exceeds the size limit");
284
- const body = await response.arrayBuffer();
285
- if (body.byteLength > REGISTRY_MAX_BYTES)
286
- throw new Error("Remote registry response exceeds the size limit");
287
- const text = new TextDecoder().decode(body);
288
- let value;
289
- try {
290
- value = JSON.parse(text);
291
- }
292
- catch {
293
- throw new Error("Remote registry returned a malformed JSON response");
294
- }
295
281
  return { ok: response.ok, status: response.status, value };
296
282
  }
297
283
  export async function fetchRemoteRegistryPackage(registry, name, version) {
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Claude Code adapter.
3
+ *
4
+ * Uses Claude's documented print-mode CLI form. A submitted prompt is a new
5
+ * turn; the CLI does not provide mid-turn injection or global session listing.
6
+ */
7
+ import { execFile } from "node:child_process";
8
+ import { promisify } from "node:util";
9
+ const exec = promisify(execFile);
10
+ const PROVIDER = "claude-code";
11
+ const CLI = "claude";
12
+ const defaultCommandDriver = async (command, args, options) => {
13
+ const { stdout } = await exec(command, [...args], options);
14
+ return { stdout: String(stdout) };
15
+ };
16
+ function parseSessionOutput(stdout) {
17
+ let parsed;
18
+ try {
19
+ parsed = JSON.parse(stdout);
20
+ }
21
+ catch {
22
+ throw new Error("Claude did not return JSON with a valid session_id");
23
+ }
24
+ if (typeof parsed !== "object" ||
25
+ parsed === null ||
26
+ !("session_id" in parsed) ||
27
+ typeof parsed.session_id !== "string" ||
28
+ parsed.session_id.trim().length === 0) {
29
+ throw new Error("Claude did not return JSON with a valid session_id");
30
+ }
31
+ return {
32
+ sessionId: parsed.session_id,
33
+ ...("result" in parsed && typeof parsed.result === "string"
34
+ ? { response: parsed.result }
35
+ : {}),
36
+ };
37
+ }
38
+ export class ClaudeCodeAdapter {
39
+ runCommand;
40
+ provider = PROVIDER;
41
+ sessions = new Map();
42
+ responses = new Map();
43
+ constructor(runCommand = defaultCommandDriver) {
44
+ this.runCommand = runCommand;
45
+ }
46
+ capabilities = {
47
+ canSubmitTurn: true,
48
+ canInjectDuringTurn: false,
49
+ canResume: true,
50
+ canStream: false, // CLI doesn't expose a streaming API we can consume
51
+ canStart: true,
52
+ };
53
+ async detect() {
54
+ try {
55
+ const { stdout } = await this.runCommand(CLI, ["--version"], {
56
+ timeout: 5000,
57
+ });
58
+ return stdout.trim();
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ async start(options) {
65
+ if (options.flags?.length) {
66
+ throw new Error("Claude adapter does not support additional CLI flags");
67
+ }
68
+ const args = ["-p", "--output-format", "json"];
69
+ if (options.resumeSessionId) {
70
+ args.push("--resume", options.resumeSessionId);
71
+ }
72
+ args.push(options.prompt ?? "");
73
+ const { stdout } = await this.runCommand(CLI, args, {
74
+ cwd: options.cwd,
75
+ timeout: options.timeout ?? 30000,
76
+ });
77
+ const output = parseSessionOutput(stdout);
78
+ const sessionId = output.sessionId;
79
+ const session = {
80
+ sessionId,
81
+ agent: PROVIDER,
82
+ provider: PROVIDER,
83
+ active: true,
84
+ busy: false,
85
+ cursor: -1,
86
+ startedAt: new Date().toISOString(),
87
+ cwd: options.cwd,
88
+ };
89
+ this.sessions.set(sessionId, session);
90
+ if (output.response)
91
+ this.responses.set(sessionId, output.response);
92
+ return session;
93
+ }
94
+ async resume(sessionId, cwd) {
95
+ const existing = this.sessions.get(sessionId);
96
+ const session = {
97
+ sessionId,
98
+ agent: PROVIDER,
99
+ provider: PROVIDER,
100
+ active: true,
101
+ busy: false,
102
+ cursor: existing?.cursor ?? -1,
103
+ startedAt: existing?.startedAt ?? new Date().toISOString(),
104
+ cwd,
105
+ };
106
+ this.sessions.set(sessionId, session);
107
+ return session;
108
+ }
109
+ async submitTurn(session, options) {
110
+ if (!session.active || session.busy)
111
+ return false;
112
+ session.busy = true;
113
+ try {
114
+ const args = [
115
+ "-p",
116
+ "--output-format",
117
+ "json",
118
+ "--resume",
119
+ session.sessionId,
120
+ options.message,
121
+ ];
122
+ const { stdout } = await this.runCommand(CLI, args, {
123
+ cwd: session.cwd,
124
+ timeout: options.timeout ?? 30000,
125
+ });
126
+ const output = parseSessionOutput(stdout);
127
+ if (output.response) {
128
+ this.responses.set(session.sessionId, output.response);
129
+ }
130
+ return output.sessionId === session.sessionId;
131
+ }
132
+ catch {
133
+ return false;
134
+ }
135
+ finally {
136
+ session.busy = false;
137
+ }
138
+ }
139
+ async stop(session) {
140
+ // Claude Code sessions end when the CLI process exits.
141
+ // We can't force-stop a running session from outside,
142
+ // but we can mark it inactive in our tracking.
143
+ session.active = false;
144
+ session.busy = false;
145
+ }
146
+ async listSessions(cwd) {
147
+ return [...this.sessions.values()]
148
+ .filter((session) => session.cwd === cwd)
149
+ .map((session) => ({ ...session }));
150
+ }
151
+ lastResponse(sessionId) {
152
+ return this.responses.get(sessionId);
153
+ }
154
+ }