loadout-ai 0.7.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 (108) hide show
  1. package/CHANGELOG.md +148 -1
  2. package/README.md +160 -315
  3. package/SECURITY.md +21 -1
  4. package/catalog/discovered.json +31269 -26003
  5. package/catalog/packages.json +4 -4
  6. package/dist/src/cli.js +13 -0
  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 -367
  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/agents/agent-inspection.js +26 -4
  16. package/dist/src/core/{routing → agents}/model-config.js +7 -2
  17. package/dist/src/core/catalog/catalog.js +1 -0
  18. package/dist/src/core/catalog/registry.js +44 -10
  19. package/dist/src/core/catalog/safety.js +36 -7
  20. package/dist/src/core/coordination/adapters/claude-code.js +154 -0
  21. package/dist/src/core/coordination/adapters/codex.js +137 -0
  22. package/dist/src/core/coordination/adapters/types.js +64 -0
  23. package/dist/src/core/coordination/auth.js +59 -0
  24. package/dist/src/core/coordination/bridge-lease.js +79 -0
  25. package/dist/src/core/coordination/conflict-preview.js +167 -0
  26. package/dist/src/core/coordination/contract-diff.js +106 -0
  27. package/dist/src/core/coordination/coordinator.js +552 -0
  28. package/dist/src/core/coordination/crash-recovery.js +169 -0
  29. package/dist/src/core/coordination/daemon.js +667 -0
  30. package/dist/src/core/coordination/discussion.js +340 -0
  31. package/dist/src/core/coordination/events.js +161 -0
  32. package/dist/src/core/coordination/http-api.js +118 -0
  33. package/dist/src/core/coordination/interrupt-policy.js +89 -0
  34. package/dist/src/core/coordination/lock.js +110 -0
  35. package/dist/src/core/coordination/mcp-server.js +424 -0
  36. package/dist/src/core/coordination/redaction.js +85 -0
  37. package/dist/src/core/coordination/replay.js +188 -0
  38. package/dist/src/core/coordination/retention.js +128 -0
  39. package/dist/src/core/coordination/runtime.js +21 -0
  40. package/dist/src/core/coordination/session-manager.js +366 -0
  41. package/dist/src/core/coordination/watcher.js +128 -0
  42. package/dist/src/core/{routing → delegation}/first-party-skills.js +25 -13
  43. package/dist/src/core/{routing → delegation}/handoff.js +130 -63
  44. package/dist/src/core/discovery/candidate-intelligence-evidence.js +108 -0
  45. package/dist/src/core/discovery/candidate-intelligence-types.js +1 -0
  46. package/dist/src/core/discovery/candidate-intelligence-validation.js +108 -0
  47. package/dist/src/core/discovery/candidate-intelligence.js +3 -214
  48. package/dist/src/core/discovery/community.js +12 -4
  49. package/dist/src/core/discovery/github-discovery.js +6 -2
  50. package/dist/src/core/discovery/private-discovery.js +6 -2
  51. package/dist/src/core/install/reconcile.js +1 -1
  52. package/dist/src/core/install/source.js +21 -7
  53. package/dist/src/core/install/uninstall.js +39 -3
  54. package/dist/src/core/reporting/cli-guide.js +10 -6
  55. package/dist/src/core/reporting/completion.js +61 -109
  56. package/dist/src/core/reporting/doctor.js +4 -6
  57. package/dist/src/core/runtime/bounded-json.js +65 -0
  58. package/dist/src/core/runtime/github.js +30 -16
  59. package/dist/src/core/runtime/mcp-recipes.js +1 -1
  60. package/dist/src/core/workspace/active-policy.js +1 -1
  61. package/docs/CANDIDATE_INTELLIGENCE.md +9 -2
  62. package/docs/CATALOG.md +1 -1
  63. package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +1 -1
  64. package/docs/DEMO_SCRIPT.md +19 -23
  65. package/docs/DISCOVERED.md +251 -250
  66. package/docs/FEATURE_TEST_MATRIX.md +10 -263
  67. package/docs/GITHUB_AUTHORIZATION.md +5 -0
  68. package/docs/LIVE_COLLABORATION.md +234 -0
  69. package/docs/PROVENANCE_AND_COMPARISON.md +1 -1
  70. package/docs/REFERENCE.md +163 -0
  71. package/docs/RELEASE_REVIEW.md +1 -2
  72. package/docs/TESTING.md +17 -1
  73. package/docs/USER_TEST_GUIDE.md +138 -3
  74. package/docs/assets/loadout-discover-activate.webp +0 -0
  75. package/docs/assets/loadout-handoff-coordinate.webp +0 -0
  76. package/docs/assets/loadout-social-preview.png +0 -0
  77. package/docs/decisions/001-coordination-jsonl-locking.md +35 -0
  78. package/docs/decisions/002-local-daemon-authentication.md +32 -0
  79. package/docs/decisions/003-bounded-agent-discussions.md +91 -0
  80. package/docs/specs/BOUNDED_AGENT_DISCUSSIONS.md +169 -0
  81. package/docs/superpowers/plans/2026-09-03-coordination-hardening.md +231 -0
  82. package/docs/superpowers/plans/2026-09-03-release-readiness.md +232 -0
  83. package/docs/superpowers/plans/2026-09-04-bounded-agent-discussions.md +121 -0
  84. package/package.json +18 -7
  85. package/skills/loadout-handoff/SKILL.md +238 -0
  86. package/MASTER_PLAN.md +0 -2207
  87. package/dist/src/core/routing/route.js +0 -539
  88. package/docs/ACTIVE_SET.md +0 -53
  89. package/docs/COMPATIBILITY_POLICY.md +0 -22
  90. package/docs/CONVERSION_AND_SANDBOX.md +0 -27
  91. package/docs/EVALUATION_PROTOCOL_V1.md +0 -300
  92. package/docs/HEAD_TO_HEAD_EVALUATION.md +0 -79
  93. package/docs/PROVIDER_CONFIGURATION.md +0 -45
  94. package/docs/README_RESEARCH.md +0 -36
  95. package/docs/REPOSITORY_STABILIZATION.md +0 -190
  96. package/docs/SAFE_UPDATE_DEMO.md +0 -25
  97. package/docs/SCHEMA_DECISIONS.md +0 -25
  98. package/docs/SUBMISSION_COPY.md +0 -90
  99. package/docs/TEAM_POLICY.md +0 -18
  100. package/docs/assets/loadout-workflow.png +0 -0
  101. package/docs/superpowers/plans/2026-07-19-relatable-readme-hero.md +0 -283
  102. package/docs/superpowers/plans/2026-07-20-loadout-readme-explainer.md +0 -116
  103. package/docs/superpowers/plans/2026-07-20-project-activation-safety.md +0 -469
  104. package/docs/superpowers/specs/2026-07-19-relatable-readme-hero-design.md +0 -80
  105. package/docs/superpowers/specs/2026-07-20-loadout-readme-explainer-design.md +0 -55
  106. package/docs/superpowers/specs/2026-07-20-project-activation-safety-design.md +0 -228
  107. package/skills/loadout-router/SKILL.md +0 -120
  108. /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")
@@ -136,6 +136,30 @@ export async function inspectAgent(agent) {
136
136
  export async function inspectAgents(agents) {
137
137
  return Promise.all(agents.map(inspectAgent));
138
138
  }
139
+ /**
140
+ * Count installable units rather than raw filesystem entries. A skill is one
141
+ * directory containing SKILL.md, so counting every nested file reports a number
142
+ * many times larger than the number of skills the user actually has.
143
+ */
144
+ export function countComponentUnits(component) {
145
+ if (!component.scanned || !component.directoryExists)
146
+ return 0;
147
+ const skillRoots = new Set();
148
+ let looseFiles = 0;
149
+ for (const entry of component.entries) {
150
+ if (entry.kind !== "file")
151
+ continue;
152
+ const segments = entry.path.split("/");
153
+ const name = segments[segments.length - 1];
154
+ if (name === "SKILL.md") {
155
+ // Depth 1 means SKILL.md sits at the component root, which is one unit.
156
+ skillRoots.add(segments.slice(0, -1).join("/") || ".");
157
+ }
158
+ else if (segments.length === 1)
159
+ looseFiles += 1;
160
+ }
161
+ return skillRoots.size || looseFiles;
162
+ }
139
163
  /**
140
164
  * One agent's inventory. The default is a two-line summary: what is installed
141
165
  * and which component kinds the adapter supports. Adapter internals — the
@@ -155,16 +179,14 @@ export function formatAgentInventory(inventory, options = {}) {
155
179
  }
156
180
  }
157
181
  else {
158
- const items = inventory.components
159
- .filter((component) => component.scanned && component.directoryExists)
160
- .reduce((total, component) => total + component.entries.length, 0);
182
+ const items = inventory.components.reduce((total, component) => total + countComponentUnits(component), 0);
161
183
  const supported = inventory.components
162
184
  .filter((component) => component.compatibility !== "unsupported")
163
185
  .map((component) => component.type);
164
186
  const skills = inventory.components.find((component) => component.type === "skill");
165
187
  if (skills?.directory)
166
188
  lines.push(` ${skills.directory}`);
167
- lines.push(` ${items} items | supports: ${supported.join(", ")}`);
189
+ lines.push(` ${items} skills | supports: ${supported.join(", ")}`);
168
190
  }
169
191
  for (const warning of inventory.warnings)
170
192
  lines.push(` ! ${warning}`);
@@ -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];
@@ -1,9 +1,12 @@
1
- import { createHash } from "node:crypto";
2
- import { cp, lstat, mkdir, readFile, readdir, rm, writeFile, } from "node:fs/promises";
1
+ import { createHash, randomUUID } from "node:crypto";
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._-]*$/;
8
+ /** A SHA-256 digest, and nothing that could traverse a path. */
9
+ const DIGEST = /^[a-f0-9]{64}$/;
7
10
  const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/;
8
11
  export function parsePackageDescriptor(value) {
9
12
  if (!value || typeof value !== "object")
@@ -266,18 +269,39 @@ function registryEndpoint(base, path) {
266
269
  endpoint.hash = "";
267
270
  return endpoint;
268
271
  }
272
+ /** Registry responses are untrusted input: bound the wait and the body. */
273
+ const REGISTRY_TIMEOUT_MS = 30_000;
274
+ const REGISTRY_MAX_BYTES = 32 * 1024 * 1024;
275
+ async function registryJson(input, init = {}) {
276
+ const { response, value } = await boundedJson(fetch, input, init, {
277
+ timeoutMs: REGISTRY_TIMEOUT_MS,
278
+ maxBytes: REGISTRY_MAX_BYTES,
279
+ label: "Remote registry",
280
+ });
281
+ return { ok: response.ok, status: response.status, value };
282
+ }
269
283
  export async function fetchRemoteRegistryPackage(registry, name, version) {
270
284
  if (!NAME.test(name) || !VERSION.test(version))
271
285
  throw new Error("Invalid remote registry package name or version");
272
286
  const base = registryUrl(registry);
273
- const response = await fetch(registryEndpoint(base, `/v1/packages/${encodeURIComponent(name)}/${encodeURIComponent(version)}`));
287
+ const response = await registryJson(registryEndpoint(base, `/v1/packages/${encodeURIComponent(name)}/${encodeURIComponent(version)}`));
274
288
  if (!response.ok)
275
289
  throw new Error(`Remote registry returned ${response.status}`);
276
- const bundle = (await response.json());
290
+ const bundle = response.value;
291
+ // `digest` arrives from a remote server and is used to build a path that is
292
+ // later deleted recursively. Validate its shape before it can become one:
293
+ // a value such as "../../.." would otherwise escape the cache root.
294
+ if (typeof bundle?.digest !== "string" || !DIGEST.test(bundle.digest))
295
+ throw new Error("Remote registry returned a bundle whose digest is not a SHA-256 hex string");
277
296
  const key = createHash("sha256")
278
297
  .update(base.origin + base.pathname)
279
298
  .digest("hex");
280
- const target = join(loadoutHome(), "cache", "registry", key, name, version, bundle.digest);
299
+ const root = join(loadoutHome(), "cache", "registry", key, name, version);
300
+ const target = resolve(root, bundle.digest);
301
+ // Belt and braces: even with a validated digest, never operate on a path
302
+ // that resolved outside the directory it was meant to live in.
303
+ if (target !== root && !target.startsWith(root + sep))
304
+ throw new Error("Remote registry bundle resolved outside the cache root");
281
305
  try {
282
306
  const packed = await packPackage(target);
283
307
  if (packed.digest === bundle.digest)
@@ -286,9 +310,19 @@ export async function fetchRemoteRegistryPackage(registry, name, version) {
286
310
  catch {
287
311
  /* import below */
288
312
  }
289
- await mkdir(resolve(target, ".."), { recursive: true });
290
- await rm(target, { recursive: true, force: true });
291
- await importRegistryBundle(bundle, target);
313
+ // Import into a scratch directory and swap it in, so a failed or malicious
314
+ // import cannot leave the cache holding a half-written package.
315
+ await mkdir(root, { recursive: true });
316
+ const staging = `${target}.incoming-${randomUUID().slice(0, 8)}`;
317
+ try {
318
+ await importRegistryBundle(bundle, staging);
319
+ await rm(target, { recursive: true, force: true });
320
+ await rename(staging, target);
321
+ }
322
+ catch (error) {
323
+ await rm(staging, { recursive: true, force: true });
324
+ throw error;
325
+ }
292
326
  return { path: target, digest: bundle.digest };
293
327
  }
294
328
  export async function publishRemotePackage(root, registry, token, options = {}) {
@@ -296,7 +330,7 @@ export async function publishRemotePackage(root, registry, token, options = {})
296
330
  throw new Error("Remote registry token is required");
297
331
  const bundle = await createRegistryBundle(root);
298
332
  const base = registryUrl(registry);
299
- const response = await fetch(registryEndpoint(base, "/v1/packages"), {
333
+ const response = await registryJson(registryEndpoint(base, "/v1/packages"), {
300
334
  method: "POST",
301
335
  headers: {
302
336
  authorization: `Bearer ${token}`,
@@ -305,7 +339,7 @@ export async function publishRemotePackage(root, registry, token, options = {})
305
339
  },
306
340
  body: JSON.stringify(bundle),
307
341
  });
308
- const result = (await response.json());
342
+ const result = response.value;
309
343
  if (!response.ok)
310
344
  throw new Error(result.error ?? `Remote registry returned ${response.status}`);
311
345
  return {