@amistio/cli 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 (46) hide show
  1. package/README.md +12 -0
  2. package/dist/api-client.d.ts +175 -0
  3. package/dist/api-client.d.ts.map +1 -0
  4. package/dist/api-client.js +116 -0
  5. package/dist/api-client.js.map +1 -0
  6. package/dist/bootstrap.d.ts +11 -0
  7. package/dist/bootstrap.d.ts.map +1 -0
  8. package/dist/bootstrap.js +66 -0
  9. package/dist/bootstrap.js.map +1 -0
  10. package/dist/control-plane.d.ts +11 -0
  11. package/dist/control-plane.d.ts.map +1 -0
  12. package/dist/control-plane.js +91 -0
  13. package/dist/control-plane.js.map +1 -0
  14. package/dist/credential-store.d.ts +9 -0
  15. package/dist/credential-store.d.ts.map +1 -0
  16. package/dist/credential-store.js +32 -0
  17. package/dist/credential-store.js.map +1 -0
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +698 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/local-tool-runner.d.ts +93 -0
  23. package/dist/local-tool-runner.d.ts.map +1 -0
  24. package/dist/local-tool-runner.js +472 -0
  25. package/dist/local-tool-runner.js.map +1 -0
  26. package/dist/orchestrator.d.ts +7 -0
  27. package/dist/orchestrator.d.ts.map +1 -0
  28. package/dist/orchestrator.js +61 -0
  29. package/dist/orchestrator.js.map +1 -0
  30. package/dist/session-policy.d.ts +20 -0
  31. package/dist/session-policy.d.ts.map +1 -0
  32. package/dist/session-policy.js +125 -0
  33. package/dist/session-policy.js.map +1 -0
  34. package/dist/sync.d.ts +33 -0
  35. package/dist/sync.d.ts.map +1 -0
  36. package/dist/sync.js +279 -0
  37. package/dist/sync.js.map +1 -0
  38. package/dist/tool-session-store.d.ts +8 -0
  39. package/dist/tool-session-store.d.ts.map +1 -0
  40. package/dist/tool-session-store.js +38 -0
  41. package/dist/tool-session-store.js.map +1 -0
  42. package/dist/work-runner.d.ts +4 -0
  43. package/dist/work-runner.d.ts.map +1 -0
  44. package/dist/work-runner.js +100 -0
  45. package/dist/work-runner.js.map +1 -0
  46. package/package.json +40 -0
package/dist/index.js ADDED
@@ -0,0 +1,698 @@
1
+ #!/usr/bin/env node
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { Command } from "commander";
6
+ import { AMISTIO_API_URL_ENV, isOfficialAmistioApiUrl, officialAmistioApiUrl, parseRepositoryCloneUrl } from "@amistio/shared";
7
+ import { cloneOrValidateRepository } from "./bootstrap.js";
8
+ import { credentialKey, LocalCredentialStore } from "./credential-store.js";
9
+ import { initControlPlane, inspectControlPlane, readProjectLink, writeProjectLink } from "./control-plane.js";
10
+ import { ApiClient } from "./api-client.js";
11
+ import { createOrchestrationPrompt, writePromptFile } from "./orchestrator.js";
12
+ import { createToolRunPreview, detectLocalTools, runLocalTool } from "./local-tool-runner.js";
13
+ import { normalizeSessionPolicy, selectToolSession } from "./session-policy.js";
14
+ import { collectDirtyDocumentsForPush, collectSyncStatus, materializeBrainDocuments } from "./sync.js";
15
+ import { LocalToolSessionStore } from "./tool-session-store.js";
16
+ import { createWorkExecutionPrompt, parseBrainGenerationArtifacts } from "./work-runner.js";
17
+ const program = new Command();
18
+ const defaultRoot = process.env.INIT_CWD ?? process.cwd();
19
+ const apiUrlOptionDescription = `Amistio API URL override (or ${AMISTIO_API_URL_ENV})`;
20
+ program.name("amistio").description("Amistio project brain CLI").version("0.1.0");
21
+ const CLI_VERSION = "0.1.0";
22
+ program
23
+ .command("init")
24
+ .description("Create Amistio control-plane folders for a new project")
25
+ .option("--root <path>", "Repository root", defaultRoot)
26
+ .action(async (options) => {
27
+ const created = await initControlPlane(options.root);
28
+ console.log(created.length ? `Created ${created.length} control-plane folders.` : "Control-plane folders already exist.");
29
+ });
30
+ program
31
+ .command("onboard")
32
+ .description("Inspect and prepare an existing repository for Amistio")
33
+ .option("--root <path>", "Repository root", defaultRoot)
34
+ .action(async (options) => {
35
+ const result = await inspectControlPlane(options.root);
36
+ console.log(`Present: ${result.present.length ? result.present.join(", ") : "none"}`);
37
+ console.log(`Missing: ${result.missing.length ? result.missing.join(", ") : "none"}`);
38
+ if (result.missing.length) {
39
+ console.log("Run `amistio init` to create missing control-plane folders without overwriting existing files.");
40
+ }
41
+ });
42
+ program
43
+ .command("bootstrap")
44
+ .description("Clone a linked repository locally, prepare the control plane, and pair it with Amistio")
45
+ .requiredOption("--repo-url <url>", "Linked repository clone URL")
46
+ .requiredOption("--target <path>", "Local checkout target path")
47
+ .requiredOption("--account <accountId>", "Amistio account ID")
48
+ .requiredOption("--project <projectId>", "Amistio project ID")
49
+ .requiredOption("--repository-link <repositoryLinkId>", "Existing repository link ID")
50
+ .option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl())
51
+ .requiredOption("--pairing-code <code>", "Short-lived pairing code from the Amistio app")
52
+ .option("--default-branch <branch>", "Default branch", "main")
53
+ .action(async (options) => {
54
+ const parsedRepoUrl = parseRepositoryCloneUrl(options.repoUrl);
55
+ const checkout = await cloneOrValidateRepository({ repoUrl: parsedRepoUrl.cloneUrl, targetDir: options.target });
56
+ await initControlPlane(checkout.targetDir);
57
+ const pairing = await new ApiClient({
58
+ apiUrl: options.apiUrl,
59
+ accountId: options.account
60
+ }).consumePairingSession({
61
+ projectId: options.project,
62
+ pairingCode: options.pairingCode,
63
+ repositoryLinkId: options.repositoryLink,
64
+ repoName: parsedRepoUrl.repoName,
65
+ repoFingerprint: createRepoFingerprint(options.account, options.project, options.repositoryLink),
66
+ defaultBranch: options.defaultBranch
67
+ });
68
+ const filePath = await writeProjectLink(checkout.targetDir, {
69
+ amistioAccountId: options.account,
70
+ amistioProjectId: options.project,
71
+ repositoryLinkId: pairing.repositoryLink.repositoryLinkId,
72
+ defaultBranch: options.defaultBranch,
73
+ lastSyncedRevision: 0
74
+ });
75
+ await new LocalCredentialStore().set(credentialKey(options.account, options.project, pairing.repositoryLink.repositoryLinkId), pairing.token);
76
+ console.log(checkout.status === "cloned" ? `Cloned repository to ${checkout.targetDir}.` : `Validated existing checkout at ${checkout.targetDir}.`);
77
+ console.log(`Pairing confirmed for ${pairing.repositoryLink.repoName}.`);
78
+ console.log(`Wrote non-secret project metadata to ${filePath}.`);
79
+ console.log(`Next: cd ${formatShellArg(checkout.targetDir)} && amistio run${formatApiUrlFlag(options.apiUrl)} --watch`);
80
+ });
81
+ program
82
+ .command("pair")
83
+ .description("Pair this repository with an Amistio web project")
84
+ .requiredOption("--account <accountId>", "Amistio account ID")
85
+ .requiredOption("--project <projectId>", "Amistio project ID")
86
+ .option("--repository-link <repositoryLinkId>", "Existing repository link ID")
87
+ .option("--default-branch <branch>", "Default branch", "main")
88
+ .option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl())
89
+ .option("--pairing-code <code>", "Short-lived pairing code from the Amistio app")
90
+ .option("--token <token>", "Runner/device credential to store outside the repository")
91
+ .option("--root <path>", "Repository root", defaultRoot)
92
+ .action(async (options, command) => {
93
+ let repositoryLinkId = options.repositoryLink ?? `repo_${randomUUID()}`;
94
+ let credential = options.token;
95
+ if (options.pairingCode) {
96
+ const pairing = await new ApiClient({
97
+ apiUrl: options.apiUrl,
98
+ accountId: options.account,
99
+ ...(credential ? { token: credential } : {})
100
+ }).consumePairingSession({
101
+ projectId: options.project,
102
+ pairingCode: options.pairingCode,
103
+ repositoryLinkId,
104
+ repoName: inferRepoName(options.root),
105
+ repoFingerprint: createRepoFingerprint(options.account, options.project, repositoryLinkId),
106
+ defaultBranch: options.defaultBranch
107
+ });
108
+ repositoryLinkId = pairing.repositoryLink.repositoryLinkId;
109
+ credential = credential ?? pairing.token;
110
+ console.log(`Pairing confirmed for ${pairing.repositoryLink.repoName}.`);
111
+ }
112
+ const filePath = await writeProjectLink(options.root, {
113
+ amistioAccountId: options.account,
114
+ amistioProjectId: options.project,
115
+ repositoryLinkId,
116
+ defaultBranch: options.defaultBranch,
117
+ lastSyncedRevision: 0
118
+ });
119
+ if (credential) {
120
+ await new LocalCredentialStore().set(credentialKey(options.account, options.project, repositoryLinkId), credential);
121
+ }
122
+ if (!options.pairingCode && apiUrlOverrideWasRequested(command)) {
123
+ const session = await new ApiClient({
124
+ apiUrl: options.apiUrl,
125
+ accountId: options.account,
126
+ ...(credential ? { token: credential } : {})
127
+ }).createPairingSession(options.project);
128
+ console.log(`Pairing code: ${session.pairingCode}`);
129
+ console.log(`Expires at: ${session.expiresAt}`);
130
+ }
131
+ console.log(`Wrote non-secret project metadata to ${filePath}.`);
132
+ });
133
+ const sync = program.command("sync").description("Inspect or move project brain changes between the repo and Amistio");
134
+ sync
135
+ .command("status")
136
+ .description("Show local sync status")
137
+ .option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl())
138
+ .option("--root <path>", "Repository root", defaultRoot)
139
+ .action(async (options) => {
140
+ const metadata = await readProjectLink(options.root);
141
+ if (!metadata) {
142
+ console.log("Repository is not paired. Run `amistio pair` first.");
143
+ return;
144
+ }
145
+ const context = await loadPairedApiContext(options.root, options.apiUrl);
146
+ const webDocuments = context ? await context.client.listBrainDocuments(metadata.amistioProjectId).then((result) => result.documents).catch(() => []) : [];
147
+ const report = await collectSyncStatus(options.root, webDocuments);
148
+ console.log(`Paired project: ${metadata.amistioProjectId}`);
149
+ console.log(`Repository link: ${metadata.repositoryLinkId}`);
150
+ console.log(`Last synced revision: ${metadata.lastSyncedRevision}`);
151
+ console.log(`Sync status: ${report.status}`);
152
+ console.log(`Clean: ${report.counts.clean}; Pending: ${report.counts.pending}; Dirty: ${report.counts.dirty}; Conflicted: ${report.counts.conflicted}`);
153
+ for (const item of report.items.filter((entry) => entry.status !== "clean")) {
154
+ console.log(`${item.status}: ${item.repoPath} - ${item.reason}`);
155
+ }
156
+ });
157
+ sync
158
+ .command("pull")
159
+ .description("Pull approved web changes into the repository")
160
+ .option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl())
161
+ .option("--root <path>", "Repository root", defaultRoot)
162
+ .action(async (options) => {
163
+ const context = await loadPairedApiContext(options.root, options.apiUrl);
164
+ if (!context) {
165
+ console.log("Repository is not paired. Run `amistio pair` first.");
166
+ return;
167
+ }
168
+ const { documents } = await context.client.listBrainDocuments(context.metadata.amistioProjectId);
169
+ const approvedDocuments = documents.filter((document) => document.syncState === "approved" || document.syncState === "synced");
170
+ const result = await materializeBrainDocuments(options.root, approvedDocuments);
171
+ for (const conflict of result.conflicts) {
172
+ console.log(`conflicted: ${conflict}`);
173
+ }
174
+ if (result.conflicts.length) {
175
+ process.exitCode = 1;
176
+ return;
177
+ }
178
+ const latestRevision = Math.max(context.metadata.lastSyncedRevision, ...approvedDocuments.map((document) => document.revision));
179
+ if (latestRevision !== context.metadata.lastSyncedRevision) {
180
+ await writeProjectLink(options.root, { ...context.metadata, lastSyncedRevision: latestRevision });
181
+ }
182
+ console.log(result.written.length ? `Pulled ${result.written.length} approved document${result.written.length === 1 ? "" : "s"}.` : "No approved web changes were pulled.");
183
+ });
184
+ sync
185
+ .command("push")
186
+ .description("Push local brain changes to Amistio for review")
187
+ .option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl())
188
+ .option("--root <path>", "Repository root", defaultRoot)
189
+ .action(async (options) => {
190
+ const context = await loadPairedApiContext(options.root, options.apiUrl);
191
+ if (!context) {
192
+ console.log("Repository is not paired. Run `amistio pair` first.");
193
+ return;
194
+ }
195
+ const dirtyDocuments = await collectDirtyDocumentsForPush(options.root, context.metadata);
196
+ if (!dirtyDocuments.length) {
197
+ console.log("No local brain changes were pushed.");
198
+ return;
199
+ }
200
+ const { documents } = await context.client.pushBrainDocuments(context.metadata.amistioProjectId, dirtyDocuments);
201
+ const conflictedDocuments = documents.filter((document) => document.syncState === "conflicted" || document.status === "conflicted");
202
+ if (conflictedDocuments.length) {
203
+ for (const document of conflictedDocuments) {
204
+ console.log(`conflicted: ${document.repoPath} - web and repository revisions both changed`);
205
+ }
206
+ process.exitCode = 1;
207
+ return;
208
+ }
209
+ await materializeBrainDocuments(options.root, documents, { allowDirtyOverwrite: true });
210
+ console.log(`Pushed ${documents.length} local document${documents.length === 1 ? "" : "s"} for web review.`);
211
+ });
212
+ const work = program.command("work").description("Inspect approved work items");
213
+ work
214
+ .command("list")
215
+ .description("List queued work without claiming it")
216
+ .option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl())
217
+ .option("--root <path>", "Repository root", defaultRoot)
218
+ .action(async (options) => {
219
+ const context = await loadPairedApiContext(options.root, options.apiUrl);
220
+ if (!context) {
221
+ console.log("Repository is not paired. Run `amistio pair` first.");
222
+ return;
223
+ }
224
+ const { workItems } = await context.client.listWorkItems(context.metadata.amistioProjectId);
225
+ if (!workItems.length) {
226
+ console.log("No work items are queued for this project.");
227
+ return;
228
+ }
229
+ for (const item of workItems) {
230
+ console.log(`${item.workItemId} [${item.status}] ${item.title}`);
231
+ }
232
+ });
233
+ work
234
+ .command("prompt")
235
+ .description("Print or write an approved work prompt without claiming a runner lease")
236
+ .argument("[workItemId]", "Work item ID. Defaults to the newest approved work item.")
237
+ .option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl())
238
+ .option("--root <path>", "Repository root", defaultRoot)
239
+ .option("--out <path>", "Write the prompt to a file instead of stdout")
240
+ .action(async (workItemId, options) => {
241
+ const context = await loadPairedApiContext(options.root, options.apiUrl);
242
+ if (!context) {
243
+ console.log("Repository is not paired. Run `amistio pair` first.");
244
+ return;
245
+ }
246
+ const { workItems } = await context.client.listWorkItems(context.metadata.amistioProjectId);
247
+ const workItem = selectPromptWorkItem(workItems, workItemId);
248
+ if (!workItem) {
249
+ console.log(workItemId ? `No work item found for ${workItemId}.` : "No approved work item is ready for prompt export.");
250
+ return;
251
+ }
252
+ const prompt = createWorkExecutionPrompt(workItem);
253
+ if (options.out) {
254
+ await writeFile(options.out, prompt, "utf8");
255
+ console.log(`Wrote work prompt to ${options.out}.`);
256
+ }
257
+ else {
258
+ console.log(prompt);
259
+ }
260
+ });
261
+ program
262
+ .command("tools")
263
+ .description("List local AI coding tools that the Amistio CLI can use")
264
+ .action(async () => {
265
+ const tools = await detectLocalTools();
266
+ for (const tool of tools) {
267
+ const mode = tool.execution === "sdk" ? "sdk" : tool.execution === "command" ? "cli" : "--";
268
+ console.log(`${tool.available ? "yes" : "no "} ${mode} ${tool.name} - ${tool.description}`);
269
+ }
270
+ console.log("custom - pass --tool-command to use any other local runner command.");
271
+ });
272
+ program
273
+ .command("orchestrate")
274
+ .description("Update the Amistio control plane through a user-installed local AI tool")
275
+ .argument("[goal...]", "Goal or next-step instruction for the orchestration pass")
276
+ .option("--root <path>", "Repository root", defaultRoot)
277
+ .option("--tool <name>", "Local tool to use: auto, none, opencode, claude, codex, copilot, gemini, aider, cursor-agent", "auto")
278
+ .option("--tool-command <command>", "Custom local command. Use {promptFile} and {root} placeholders when supported")
279
+ .option("--session <policy>", "Tool session policy: auto, new, continue:<toolSessionId>, or none", "auto")
280
+ .option("--prompt-out <path>", "Write the generated orchestration prompt to a file before running")
281
+ .option("--dry-run", "Print the generated orchestration prompt without running a tool")
282
+ .option("--no-stream", "Capture local tool output instead of streaming it")
283
+ .action(async (goalParts, options) => {
284
+ const goal = goalParts?.join(" ").trim() || "Review the current repository state and update the Amistio control plane with the next useful orchestration steps.";
285
+ const prompt = await createOrchestrationPrompt({ rootDir: options.root, goal });
286
+ if (options.promptOut) {
287
+ const promptPath = await writePromptFile(options.promptOut, prompt);
288
+ console.log(`Wrote orchestration prompt to ${promptPath}.`);
289
+ }
290
+ if (options.dryRun || options.tool === "none") {
291
+ console.log(prompt);
292
+ return;
293
+ }
294
+ const sessionPolicy = normalizeSessionPolicy(options.session);
295
+ const preview = await createToolRunPreview({ rootDir: options.root, prompt, tool: options.tool, ...(options.toolCommand ? { toolCommand: options.toolCommand } : {}) });
296
+ console.log(`Running ${preview.toolName}: ${preview.displayCommand}`);
297
+ const result = await runLocalTool({
298
+ rootDir: options.root,
299
+ prompt,
300
+ tool: options.tool,
301
+ ...(options.toolCommand ? { toolCommand: options.toolCommand } : {}),
302
+ streamOutput: options.stream,
303
+ ...(sessionPolicy === "none" ? {} : { session: { toolSessionId: `local_orchestration_${randomUUID()}`, policy: sessionPolicy, decision: localSessionDecision(sessionPolicy) } })
304
+ });
305
+ if (!options.stream && result.stdout.trim()) {
306
+ console.log(result.stdout.trim());
307
+ }
308
+ if (!options.stream && result.stderr.trim()) {
309
+ console.error(result.stderr.trim());
310
+ }
311
+ if (result.exitCode !== 0) {
312
+ process.exitCode = result.exitCode;
313
+ }
314
+ });
315
+ program
316
+ .command("run")
317
+ .description("Claim and run approved Amistio work locally")
318
+ .option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl())
319
+ .option("--runner-id <runnerId>", "Stable runner ID", `runner_${randomUUID()}`)
320
+ .option("--root <path>", "Repository root", defaultRoot)
321
+ .option("--tool <name>", "Local tool to use: auto, none, opencode, claude, codex, copilot, gemini, aider, cursor-agent", "auto")
322
+ .option("--tool-command <command>", "Custom local command. Use {promptFile} and {root} placeholders when supported")
323
+ .option("--session <policy>", "Tool session policy: auto, new, continue:<toolSessionId>, or none", "auto")
324
+ .option("--dry-run", "Claim work and print the generated execution prompt without running a tool")
325
+ .option("--watch", "Keep polling for approved work until stopped")
326
+ .option("--interval-seconds <seconds>", "Polling interval for --watch", parsePositiveInteger, 10)
327
+ .option("--max-iterations <count>", "Stop watch mode after this many polling attempts", parsePositiveInteger)
328
+ .option("--no-stream", "Capture local tool output instead of streaming it")
329
+ .action(async (options) => {
330
+ const context = await loadPairedApiContext(options.root, options.apiUrl);
331
+ if (!context) {
332
+ console.log("Repository is not paired. Run `amistio pair` first.");
333
+ return;
334
+ }
335
+ if (!context.token) {
336
+ console.log("No local runner credential found. Run `amistio pair --pairing-code <code>` to store this machine credential.");
337
+ process.exitCode = 1;
338
+ return;
339
+ }
340
+ if (options.watch) {
341
+ console.log(`Runner ${options.runnerId} is watching ${context.metadata.amistioProjectId} every ${options.intervalSeconds}s. Press Ctrl+C to stop.`);
342
+ }
343
+ let iterations = 0;
344
+ while (true) {
345
+ iterations += 1;
346
+ const result = await runNextWorkItem({
347
+ apiClient: context.client,
348
+ projectId: context.metadata.amistioProjectId,
349
+ repositoryLinkId: context.metadata.repositoryLinkId,
350
+ runnerId: options.runnerId,
351
+ root: options.root,
352
+ sessionPolicy: normalizeSessionPolicy(options.session),
353
+ tool: options.tool,
354
+ ...(options.toolCommand ? { toolCommand: options.toolCommand } : {}),
355
+ dryRun: Boolean(options.dryRun),
356
+ stream: options.stream
357
+ });
358
+ if (!options.watch || options.dryRun) {
359
+ if (result.exitCode !== 0) {
360
+ process.exitCode = result.exitCode;
361
+ }
362
+ return;
363
+ }
364
+ if (options.maxIterations !== undefined && iterations >= options.maxIterations) {
365
+ console.log(`Runner stopped after ${iterations} polling attempt${iterations === 1 ? "" : "s"}.`);
366
+ return;
367
+ }
368
+ if (result.status === "idle") {
369
+ console.log(`No approved work item is available. Checking again in ${options.intervalSeconds}s.`);
370
+ }
371
+ await delay(options.intervalSeconds * 1000);
372
+ }
373
+ });
374
+ async function runNextWorkItem({ apiClient, dryRun, projectId, repositoryLinkId, root, runnerId, sessionPolicy, stream, tool, toolCommand }) {
375
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", CLI_VERSION);
376
+ const result = await apiClient.claimWork(projectId, runnerId, repositoryLinkId);
377
+ if (!result.workItem) {
378
+ console.log("No approved work item is available.");
379
+ return { status: "idle", exitCode: 0 };
380
+ }
381
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "running", CLI_VERSION);
382
+ const prompt = createWorkExecutionPrompt(result.workItem);
383
+ if (dryRun || tool === "none") {
384
+ console.log(prompt);
385
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", CLI_VERSION);
386
+ return { status: "preview", exitCode: 0 };
387
+ }
388
+ const preview = await createToolRunPreview({ rootDir: root, prompt, tool, ...(toolCommand ? { toolCommand } : {}) });
389
+ const sessionContext = await prepareToolSession({
390
+ apiClient,
391
+ projectId,
392
+ repositoryLinkId,
393
+ runnerId,
394
+ sessionPolicy: result.workItem.sessionPolicy ?? sessionPolicy,
395
+ toolName: preview.toolName,
396
+ supportsSessionReuse: preview.supportsSessionReuse,
397
+ resumabilityScope: preview.resumabilityScope,
398
+ workItem: result.workItem
399
+ });
400
+ console.log(`Claimed ${result.workItem.workItemId}. Running ${preview.toolName}: ${preview.displayCommand}`);
401
+ const startedAt = Date.now();
402
+ const providerSessionStore = new LocalToolSessionStore();
403
+ const providerSessionId = sessionContext.toolSession
404
+ ? await providerSessionStore.getProviderSessionId(sessionContext.toolSession.toolSessionId, preview.toolName)
405
+ : undefined;
406
+ const toolResult = await runLocalTool({
407
+ rootDir: root,
408
+ prompt,
409
+ tool,
410
+ ...(toolCommand ? { toolCommand } : {}),
411
+ streamOutput: stream,
412
+ ...(sessionContext.toolSession
413
+ ? {
414
+ session: {
415
+ toolSessionId: sessionContext.toolSession.toolSessionId,
416
+ policy: sessionContext.policy,
417
+ decision: sessionContext.decision,
418
+ ...(providerSessionId ? { providerSessionId } : {})
419
+ }
420
+ }
421
+ : {})
422
+ }).catch(async (error) => {
423
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "blocked", CLI_VERSION);
424
+ await markToolSessionBlocked(apiClient, projectId, sessionContext.toolSession, errorMessage(error));
425
+ throw error;
426
+ });
427
+ if (sessionContext.toolSession && toolResult.providerSessionId) {
428
+ await providerSessionStore.setProviderSessionId(sessionContext.toolSession.toolSessionId, preview.toolName, toolResult.providerSessionId);
429
+ }
430
+ if (!stream && toolResult.stdout.trim()) {
431
+ console.log(toolResult.stdout.trim());
432
+ }
433
+ if (!stream && toolResult.stderr.trim()) {
434
+ console.error(toolResult.stderr.trim());
435
+ }
436
+ if (result.workItem.workKind === "brainGeneration") {
437
+ return finalizeBrainGenerationWork({
438
+ apiClient,
439
+ durationMs: Date.now() - startedAt,
440
+ projectId,
441
+ repositoryLinkId,
442
+ runnerId,
443
+ sessionContext,
444
+ toolName: preview.toolName,
445
+ toolResult,
446
+ workItem: result.workItem
447
+ });
448
+ }
449
+ const finalStatus = toolResult.exitCode === 0 ? "completed" : "failed";
450
+ const durationMs = Date.now() - startedAt;
451
+ const failureExcerpt = toolResult.exitCode === 0 ? undefined : truncateLogExcerpt(toolResult.stderr || toolResult.stdout);
452
+ const updatedToolSession = await finalizeToolSession({
453
+ apiClient,
454
+ projectId,
455
+ status: finalStatus,
456
+ runnerId,
457
+ workItemId: result.workItem.workItemId,
458
+ stdout: toolResult.stdout,
459
+ ...(sessionContext.toolSession ? { session: sessionContext.toolSession } : {}),
460
+ ...(toolResult.messageCount !== undefined ? { messageCount: toolResult.messageCount } : {}),
461
+ ...(toolResult.tokensIn !== undefined ? { tokensIn: toolResult.tokensIn } : {}),
462
+ ...(toolResult.tokensOut !== undefined ? { tokensOut: toolResult.tokensOut } : {}),
463
+ ...(toolResult.costUsd !== undefined ? { costUsd: toolResult.costUsd } : {})
464
+ });
465
+ const statusResult = await apiClient.updateWorkStatus(projectId, result.workItem.workItemId, finalStatus, `run_${result.workItem.workItemId}_${randomUUID()}`, runnerId, {
466
+ tool: preview.toolName,
467
+ durationMs,
468
+ message: `${preview.toolName} exited with code ${toolResult.exitCode}.`,
469
+ sessionPolicy: sessionContext.policy,
470
+ sessionDecision: sessionContext.decision,
471
+ sessionDecisionReason: sessionContext.reason,
472
+ ...(updatedToolSession ? { toolSessionId: updatedToolSession.toolSessionId } : {}),
473
+ ...(updatedToolSession?.sessionGroupKey ? { sessionGroupKey: updatedToolSession.sessionGroupKey } : {}),
474
+ ...(toolResult.tokensIn !== undefined ? { tokensIn: toolResult.tokensIn } : {}),
475
+ ...(toolResult.tokensOut !== undefined ? { tokensOut: toolResult.tokensOut } : {}),
476
+ ...(toolResult.costUsd !== undefined ? { costUsd: toolResult.costUsd } : {}),
477
+ ...(failureExcerpt ? { error: failureExcerpt } : {})
478
+ });
479
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", CLI_VERSION);
480
+ const durationSeconds = Math.round(durationMs / 1000);
481
+ console.log(`Marked ${statusResult.workItem.workItemId} ${statusResult.workItem.status} after ${durationSeconds}s.`);
482
+ return { status: finalStatus, exitCode: toolResult.exitCode };
483
+ }
484
+ async function finalizeBrainGenerationWork({ apiClient, durationMs, projectId, repositoryLinkId, runnerId, sessionContext, toolName, toolResult, workItem }) {
485
+ let artifacts;
486
+ let generationError;
487
+ if (toolResult.exitCode === 0) {
488
+ try {
489
+ artifacts = parseBrainGenerationArtifacts(`${toolResult.stdout}\n${toolResult.stderr}`);
490
+ }
491
+ catch (error) {
492
+ generationError = errorMessage(error);
493
+ }
494
+ }
495
+ else {
496
+ generationError = truncateLogExcerpt(toolResult.stderr || toolResult.stdout) || `${toolName} exited with code ${toolResult.exitCode}.`;
497
+ }
498
+ const finalStatus = artifacts ? "completed" : "failed";
499
+ const updatedToolSession = await finalizeToolSession({
500
+ apiClient,
501
+ projectId,
502
+ status: finalStatus,
503
+ runnerId,
504
+ workItemId: workItem.workItemId,
505
+ stdout: toolResult.stdout,
506
+ ...(sessionContext.toolSession ? { session: sessionContext.toolSession } : {}),
507
+ ...(toolResult.messageCount !== undefined ? { messageCount: toolResult.messageCount } : {}),
508
+ ...(toolResult.tokensIn !== undefined ? { tokensIn: toolResult.tokensIn } : {}),
509
+ ...(toolResult.tokensOut !== undefined ? { tokensOut: toolResult.tokensOut } : {}),
510
+ ...(toolResult.costUsd !== undefined ? { costUsd: toolResult.costUsd } : {})
511
+ });
512
+ const sessionTelemetry = {
513
+ sessionPolicy: sessionContext.policy,
514
+ sessionDecision: sessionContext.decision,
515
+ sessionDecisionReason: sessionContext.reason,
516
+ ...(updatedToolSession ? { toolSessionId: updatedToolSession.toolSessionId } : {}),
517
+ ...(updatedToolSession?.sessionGroupKey ? { sessionGroupKey: updatedToolSession.sessionGroupKey } : {})
518
+ };
519
+ if (artifacts) {
520
+ const result = await apiClient.submitBrainGenerationResult(projectId, workItem.workItemId, {
521
+ status: "completed",
522
+ runnerId,
523
+ idempotencyKey: `generation_${workItem.workItemId}_${randomUUID()}`,
524
+ artifacts,
525
+ tool: toolName,
526
+ durationMs,
527
+ ...sessionTelemetry,
528
+ message: `${toolName} generated ${artifacts.length} brain artifact${artifacts.length === 1 ? "" : "s"}.`
529
+ });
530
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", CLI_VERSION);
531
+ console.log(`Generated ${result.documents.length} brain artifact${result.documents.length === 1 ? "" : "s"} for review.`);
532
+ return { status: "completed", exitCode: 0 };
533
+ }
534
+ await apiClient.submitBrainGenerationResult(projectId, workItem.workItemId, {
535
+ status: "failed",
536
+ runnerId,
537
+ idempotencyKey: `generation_${workItem.workItemId}_${randomUUID()}`,
538
+ tool: toolName,
539
+ durationMs,
540
+ ...sessionTelemetry,
541
+ message: `${toolName} did not produce valid brain artifacts.`,
542
+ ...(generationError ? { error: generationError } : {})
543
+ });
544
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", CLI_VERSION);
545
+ console.error(generationError ?? "Local runner generation failed.");
546
+ return { status: "failed", exitCode: toolResult.exitCode || 1 };
547
+ }
548
+ async function loadPairedApiContext(root, apiUrl) {
549
+ const metadata = await readProjectLink(root);
550
+ if (!metadata) {
551
+ return undefined;
552
+ }
553
+ const token = await new LocalCredentialStore().get(credentialKey(metadata.amistioAccountId, metadata.amistioProjectId, metadata.repositoryLinkId));
554
+ return {
555
+ metadata,
556
+ ...(token ? { token } : {}),
557
+ client: new ApiClient({
558
+ apiUrl,
559
+ accountId: metadata.amistioAccountId,
560
+ ...(token ? { token } : {})
561
+ })
562
+ };
563
+ }
564
+ function selectPromptWorkItem(workItems, workItemId) {
565
+ if (workItemId) {
566
+ return workItems.find((item) => item.workItemId === workItemId || item.id === workItemId);
567
+ }
568
+ return workItems.find((item) => isPromptReadyStatus(item.status));
569
+ }
570
+ function isPromptReadyStatus(status) {
571
+ return status === "approved" || status === "running" || status === "blocked" || status === "changesRequested";
572
+ }
573
+ async function prepareToolSession({ apiClient, projectId, repositoryLinkId, runnerId, sessionPolicy, supportsSessionReuse, resumabilityScope, toolName, workItem }) {
574
+ const { toolSessions } = await apiClient.listToolSessions(projectId);
575
+ const selection = selectToolSession({
576
+ policy: sessionPolicy,
577
+ workItem,
578
+ sessions: toolSessions,
579
+ toolName,
580
+ runnerId,
581
+ repositoryLinkId,
582
+ supportsSessionReuse
583
+ });
584
+ if (selection.decision === "skipped") {
585
+ return selection;
586
+ }
587
+ if (selection.toolSession) {
588
+ const { toolSession } = await apiClient.updateToolSession(projectId, selection.toolSession.toolSessionId, {
589
+ status: "active",
590
+ runnerId,
591
+ lastWorkItemId: workItem.workItemId,
592
+ reusePolicy: sessionPolicy
593
+ });
594
+ return { ...selection, toolSession };
595
+ }
596
+ const toolSessionId = `tool_session_${randomUUID()}`;
597
+ const { toolSession } = await apiClient.createToolSession(projectId, {
598
+ toolSessionId,
599
+ repositoryLinkId,
600
+ tool: toolName,
601
+ provider: toolName,
602
+ resumabilityScope: supportsSessionReuse ? resumabilityScope : "none",
603
+ title: workItem.title,
604
+ summary: selection.reason,
605
+ status: "active",
606
+ runnerId,
607
+ lastWorkItemId: workItem.workItemId,
608
+ messageCount: 0,
609
+ reusePolicy: sessionPolicy,
610
+ ...(workItem.sessionGroupKey ? { sessionGroupKey: workItem.sessionGroupKey } : {})
611
+ });
612
+ return { ...selection, toolSession };
613
+ }
614
+ async function finalizeToolSession({ apiClient, costUsd, messageCount, projectId, runnerId, session, status, stdout, tokensIn, tokensOut, workItemId }) {
615
+ if (!session) {
616
+ return undefined;
617
+ }
618
+ const summary = summarizeToolOutput(stdout) ?? session.summary;
619
+ const { toolSession } = await apiClient.updateToolSession(projectId, session.toolSessionId, {
620
+ status: status === "completed" ? "open" : "blocked",
621
+ runnerId,
622
+ lastWorkItemId: workItemId,
623
+ messageCount: (session.messageCount ?? 0) + (messageCount ?? 1),
624
+ ...(summary ? { summary } : {}),
625
+ ...(tokensIn !== undefined ? { estimatedInputTokens: (session.estimatedInputTokens ?? 0) + tokensIn } : {}),
626
+ ...(tokensOut !== undefined ? { estimatedOutputTokens: (session.estimatedOutputTokens ?? 0) + tokensOut } : {}),
627
+ ...(costUsd !== undefined ? { costUsd: (session.costUsd ?? 0) + costUsd } : {}),
628
+ ...(status === "failed" ? { closedReason: "Last run failed or returned a non-zero exit code." } : {})
629
+ });
630
+ return toolSession;
631
+ }
632
+ async function markToolSessionBlocked(apiClient, projectId, session, reason) {
633
+ if (!session) {
634
+ return;
635
+ }
636
+ await apiClient.updateToolSession(projectId, session.toolSessionId, {
637
+ status: "blocked",
638
+ summary: reason,
639
+ closedReason: reason
640
+ });
641
+ }
642
+ function localSessionDecision(policy) {
643
+ if (policy === "new") {
644
+ return "forcedNew";
645
+ }
646
+ if (typeof policy === "string" && policy.startsWith("continue:")) {
647
+ return "forcedContinue";
648
+ }
649
+ return "created";
650
+ }
651
+ function summarizeToolOutput(value) {
652
+ const trimmed = value.trim();
653
+ if (!trimmed) {
654
+ return undefined;
655
+ }
656
+ return trimmed.length > 300 ? `${trimmed.slice(0, 300)}...` : trimmed;
657
+ }
658
+ function errorMessage(error) {
659
+ return error instanceof Error ? error.message : String(error);
660
+ }
661
+ function truncateLogExcerpt(value) {
662
+ const trimmed = value.trim();
663
+ return trimmed.length > 1200 ? `${trimmed.slice(0, 1200)}...` : trimmed;
664
+ }
665
+ function parsePositiveInteger(value) {
666
+ const parsed = Number(value);
667
+ if (!Number.isInteger(parsed) || parsed <= 0) {
668
+ throw new Error(`Expected a positive integer, received ${value}.`);
669
+ }
670
+ return parsed;
671
+ }
672
+ function inferRepoName(root) {
673
+ return path.basename(path.resolve(root)) || "repository";
674
+ }
675
+ function createRepoFingerprint(accountId, projectId, repositoryLinkId) {
676
+ return createHash("sha256").update(`${accountId}:${projectId}:${repositoryLinkId}`).digest("hex");
677
+ }
678
+ function defaultApiUrl() {
679
+ const envApiUrl = process.env[AMISTIO_API_URL_ENV]?.trim();
680
+ return envApiUrl || officialAmistioApiUrl;
681
+ }
682
+ function apiUrlOverrideWasRequested(command) {
683
+ return command.getOptionValueSource("apiUrl") === "cli" || Boolean(process.env[AMISTIO_API_URL_ENV]?.trim());
684
+ }
685
+ function formatApiUrlFlag(apiUrl) {
686
+ return isOfficialAmistioApiUrl(apiUrl) ? "" : ` --api-url ${formatShellArg(apiUrl)}`;
687
+ }
688
+ function formatShellArg(value) {
689
+ return /^[A-Za-z0-9_./:@-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
690
+ }
691
+ async function delay(milliseconds) {
692
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
693
+ }
694
+ program.parseAsync().catch((error) => {
695
+ console.error(error instanceof Error ? error.message : String(error));
696
+ process.exitCode = 1;
697
+ });
698
+ //# sourceMappingURL=index.js.map