@amistio/cli 0.1.0 → 0.1.2

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 +2 -2
  2. package/dist/index.d.ts +0 -1
  3. package/dist/index.js +3493 -642
  4. package/dist/index.js.map +7 -1
  5. package/package.json +9 -6
  6. package/dist/api-client.d.ts +0 -175
  7. package/dist/api-client.d.ts.map +0 -1
  8. package/dist/api-client.js +0 -116
  9. package/dist/api-client.js.map +0 -1
  10. package/dist/bootstrap.d.ts +0 -11
  11. package/dist/bootstrap.d.ts.map +0 -1
  12. package/dist/bootstrap.js +0 -66
  13. package/dist/bootstrap.js.map +0 -1
  14. package/dist/control-plane.d.ts +0 -11
  15. package/dist/control-plane.d.ts.map +0 -1
  16. package/dist/control-plane.js +0 -91
  17. package/dist/control-plane.js.map +0 -1
  18. package/dist/credential-store.d.ts +0 -9
  19. package/dist/credential-store.d.ts.map +0 -1
  20. package/dist/credential-store.js +0 -32
  21. package/dist/credential-store.js.map +0 -1
  22. package/dist/index.d.ts.map +0 -1
  23. package/dist/local-tool-runner.d.ts +0 -93
  24. package/dist/local-tool-runner.d.ts.map +0 -1
  25. package/dist/local-tool-runner.js +0 -472
  26. package/dist/local-tool-runner.js.map +0 -1
  27. package/dist/orchestrator.d.ts +0 -7
  28. package/dist/orchestrator.d.ts.map +0 -1
  29. package/dist/orchestrator.js +0 -61
  30. package/dist/orchestrator.js.map +0 -1
  31. package/dist/session-policy.d.ts +0 -20
  32. package/dist/session-policy.d.ts.map +0 -1
  33. package/dist/session-policy.js +0 -125
  34. package/dist/session-policy.js.map +0 -1
  35. package/dist/sync.d.ts +0 -33
  36. package/dist/sync.d.ts.map +0 -1
  37. package/dist/sync.js +0 -279
  38. package/dist/sync.js.map +0 -1
  39. package/dist/tool-session-store.d.ts +0 -8
  40. package/dist/tool-session-store.d.ts.map +0 -1
  41. package/dist/tool-session-store.js +0 -38
  42. package/dist/tool-session-store.js.map +0 -1
  43. package/dist/work-runner.d.ts +0 -4
  44. package/dist/work-runner.d.ts.map +0 -1
  45. package/dist/work-runner.js +0 -100
  46. package/dist/work-runner.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,698 +1,3549 @@
1
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";
2
+
3
+ // src/index.ts
4
+ import { spawn as spawn3 } from "node:child_process";
5
+ import { createHash as createHash3, randomUUID } from "node:crypto";
6
+ import { writeFile as writeFile8 } from "node:fs/promises";
7
+ import os5 from "node:os";
8
+ import path9 from "node:path";
5
9
  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
10
+
11
+ // ../shared/src/schemas.ts
12
+ import { z } from "zod";
13
+ var isoDateTimeSchema = z.string().datetime({ offset: true });
14
+ var itemTypeSchema = z.enum([
15
+ "account",
16
+ "accountUser",
17
+ "accountInvite",
18
+ "accountSettings",
19
+ "user",
20
+ "project",
21
+ "repositoryLink",
22
+ "brainDocument",
23
+ "generatedDraft",
24
+ "syncCursor",
25
+ "syncConflict",
26
+ "workItem",
27
+ "runnerHeartbeat",
28
+ "runnerExecutionLog",
29
+ "runnerCredential",
30
+ "runnerCommand",
31
+ "planReviewMessage",
32
+ "toolSession",
33
+ "activityEvent",
34
+ "pairingSession"
35
+ ]);
36
+ var documentTypeSchema = z.enum([
37
+ "architecture",
38
+ "context",
39
+ "decision",
40
+ "feature",
41
+ "memory",
42
+ "plan",
43
+ "prompt",
44
+ "workflow"
45
+ ]);
46
+ var syncStateSchema = z.enum([
47
+ "draft",
48
+ "approved",
49
+ "synced",
50
+ "dirtyInRepo",
51
+ "dirtyInWeb",
52
+ "conflicted",
53
+ "archived"
54
+ ]);
55
+ var workStatusSchema = z.enum([
56
+ "drafted",
57
+ "approved",
58
+ "synced",
59
+ "running",
60
+ "blocked",
61
+ "completed",
62
+ "changesRequested",
63
+ "failed",
64
+ "conflicted"
65
+ ]);
66
+ var sourceSchema = z.enum(["web", "repo", "generated", "runner"]);
67
+ var executionModeSchema = z.enum(["localRunner", "cloudSandbox"]);
68
+ var workKindSchema = z.enum(["brainGeneration", "implementation", "planRevision"]);
69
+ var generatedDraftStatusSchema = z.enum(["queued", "generating", "reviewing", "approved", "rejected", "changesRequested", "failed"]);
70
+ var generatedBrainArtifactSchema = z.object({
71
+ documentType: documentTypeSchema,
72
+ title: z.string().trim().min(1),
73
+ repoPath: z.string().trim().min(1),
74
+ content: z.string().min(1)
75
+ });
76
+ var brainGenerationResultSchema = z.object({
77
+ artifacts: z.array(generatedBrainArtifactSchema).min(1),
78
+ summary: z.string().optional()
79
+ });
80
+ var sessionPolicySchema = z.union([z.enum(["auto", "new", "none"]), z.string().regex(/^continue:[A-Za-z0-9_.:-]+$/)]);
81
+ var sessionDecisionSchema = z.enum(["created", "continued", "forcedNew", "forcedContinue", "notSupported", "skipped"]);
82
+ var toolSessionStatusSchema = z.enum(["open", "active", "closed", "archived", "blocked", "unavailable"]);
83
+ var sessionResumabilityScopeSchema = z.enum(["none", "localMachine", "repository", "account", "providerCloud"]);
84
+ var runnerToolNames = ["opencode", "claude", "codex", "copilot", "gemini", "aider", "cursor-agent"];
85
+ var runnerToolNameSchema = z.enum(runnerToolNames);
86
+ var runnerToolSelectionSchema = z.union([runnerToolNameSchema, z.literal("auto")]);
87
+ var runnerPreferenceScopeSchema = z.enum(["account", "project"]);
88
+ var runnerPreferenceSourceSchema = z.enum(["cli", "project", "account", "default"]);
89
+ var runnerPreferenceStatusSchema = z.enum(["resolved", "unavailable", "modelUnsupported", "custom", "none"]);
90
+ var runnerToolModelPreferenceSchema = z.object({
91
+ tool: runnerToolSelectionSchema.optional(),
92
+ model: z.string().trim().min(1).max(160).optional()
93
+ });
94
+ var runnerToolCapabilitySchema = z.object({
95
+ name: runnerToolNameSchema,
96
+ description: z.string().min(1),
97
+ available: z.boolean(),
98
+ sdkAvailable: z.boolean(),
99
+ commandAvailable: z.boolean(),
100
+ execution: z.enum(["sdk", "command", "unavailable"]),
101
+ supportsSessionReuse: z.boolean(),
102
+ resumabilityScope: sessionResumabilityScopeSchema,
103
+ supportsModelSelection: z.boolean()
104
+ });
105
+ var repositoryLinkSourceSchema = z.enum(["web", "cli"]);
106
+ var repositoryCloneStatusSchema = z.enum(["notCloned", "cloned", "validated", "failed"]);
107
+ var baseItemSchema = z.object({
108
+ id: z.string().min(1),
109
+ type: itemTypeSchema,
110
+ schemaVersion: z.number().int().positive().default(1),
111
+ accountId: z.string().min(1),
112
+ projectId: z.string().min(1).optional(),
113
+ createdAt: isoDateTimeSchema,
114
+ updatedAt: isoDateTimeSchema
115
+ });
116
+ var accountItemSchema = baseItemSchema.extend({
117
+ type: z.literal("account"),
118
+ name: z.string().min(1),
119
+ slug: z.string().min(1)
120
+ });
121
+ var userItemSchema = baseItemSchema.extend({
122
+ type: z.literal("user"),
123
+ userId: z.string().min(1),
124
+ email: z.string().email(),
125
+ firstName: z.string(),
126
+ lastName: z.string()
127
+ });
128
+ var projectItemSchema = baseItemSchema.extend({
129
+ type: z.literal("project"),
130
+ projectId: z.string().min(1),
131
+ name: z.string().min(1),
132
+ slug: z.string().min(1),
133
+ description: z.string().optional()
134
+ });
135
+ var repositoryLinkItemSchema = baseItemSchema.extend({
136
+ type: z.literal("repositoryLink"),
137
+ projectId: z.string().min(1),
138
+ repositoryLinkId: z.string().min(1),
139
+ repoName: z.string().min(1),
140
+ repoFingerprint: z.string().min(1).optional(),
141
+ defaultBranch: z.string().min(1),
142
+ cloneUrl: z.string().min(1).optional(),
143
+ provider: z.string().min(1).optional(),
144
+ repoOwner: z.string().min(1).optional(),
145
+ repoFullName: z.string().min(1).optional(),
146
+ linkedByUserId: z.string().min(1).optional(),
147
+ linkSource: repositoryLinkSourceSchema.optional(),
148
+ cloneStatus: repositoryCloneStatusSchema.optional(),
149
+ lastValidatedAt: isoDateTimeSchema.optional(),
150
+ status: z.enum(["active", "revoked"]).default("active")
151
+ });
152
+ var brainDocumentItemSchema = baseItemSchema.extend({
153
+ type: z.literal("brainDocument"),
154
+ projectId: z.string().min(1),
155
+ documentId: z.string().min(1),
156
+ documentType: documentTypeSchema,
157
+ title: z.string().min(1),
158
+ status: z.string().min(1),
159
+ repoPath: z.string().min(1),
160
+ content: z.string(),
161
+ contentHash: z.string().min(1),
162
+ frontmatter: z.record(z.string(), z.unknown()).default({}),
163
+ revision: z.number().int().nonnegative(),
164
+ approvedRevision: z.number().int().nonnegative().optional(),
165
+ source: sourceSchema,
166
+ syncState: syncStateSchema
167
+ });
168
+ var generatedDraftItemSchema = baseItemSchema.extend({
169
+ type: z.literal("generatedDraft"),
170
+ projectId: z.string().min(1),
171
+ wishId: z.string().min(1),
172
+ title: z.string().min(1),
173
+ status: generatedDraftStatusSchema,
174
+ documentIds: z.array(z.string()).default([]),
175
+ wish: z.string().min(1).optional(),
176
+ requestedByUserId: z.string().min(1).optional(),
177
+ workItemId: z.string().min(1).optional(),
178
+ runnerId: z.string().min(1).optional(),
179
+ generationAttempt: z.number().int().nonnegative().optional(),
180
+ lastGenerationError: z.string().optional()
181
+ });
182
+ var syncCursorItemSchema = baseItemSchema.extend({
183
+ type: z.literal("syncCursor"),
184
+ projectId: z.string().min(1),
185
+ repositoryLinkId: z.string().min(1),
186
+ lastSyncedRevision: z.number().int().nonnegative(),
187
+ lastSyncedAt: isoDateTimeSchema.optional()
188
+ });
189
+ var syncConflictItemSchema = baseItemSchema.extend({
190
+ type: z.literal("syncConflict"),
191
+ projectId: z.string().min(1),
192
+ conflictId: z.string().min(1),
193
+ documentId: z.string().min(1),
194
+ repoPath: z.string().min(1),
195
+ webRevision: z.number().int().nonnegative(),
196
+ repoContentHash: z.string().min(1),
197
+ status: z.enum(["open", "resolved", "ignored"])
198
+ });
199
+ var workItemSchema = baseItemSchema.extend({
200
+ type: z.literal("workItem"),
201
+ projectId: z.string().min(1),
202
+ workItemId: z.string().min(1),
203
+ workKind: workKindSchema.optional(),
204
+ status: workStatusSchema,
205
+ executionMode: executionModeSchema.optional(),
206
+ title: z.string().min(1),
207
+ requestedBy: z.string().min(1),
208
+ approvedBy: z.string().min(1).optional(),
209
+ sourceWish: z.string().min(1).optional(),
210
+ generatedDraftId: z.string().min(1).optional(),
211
+ reviewThreadId: z.string().min(1).optional(),
212
+ reviewDocumentId: z.string().min(1).optional(),
213
+ reviewDocumentRevision: z.number().int().nonnegative().optional(),
214
+ reviewMessageId: z.string().min(1).optional(),
215
+ claimedByRunnerId: z.string().optional(),
216
+ leaseExpiresAt: isoDateTimeSchema.optional(),
217
+ attempt: z.number().int().nonnegative().default(0),
218
+ idempotencyKey: z.string().min(1),
219
+ sessionPolicy: sessionPolicySchema.optional(),
220
+ sessionGroupKey: z.string().min(1).optional(),
221
+ toolSessionId: z.string().min(1).optional(),
222
+ sessionDecision: sessionDecisionSchema.optional(),
223
+ sessionDecisionReason: z.string().min(1).optional(),
224
+ lastStatusMessage: z.string().optional(),
225
+ lastStatusAt: isoDateTimeSchema
226
+ });
227
+ var runnerHeartbeatItemSchema = baseItemSchema.extend({
228
+ type: z.literal("runnerHeartbeat"),
229
+ projectId: z.string().min(1),
230
+ runnerId: z.string().min(1),
231
+ repositoryLinkId: z.string().min(1),
232
+ status: z.enum(["online", "offline", "running", "blocked"]),
233
+ version: z.string().optional(),
234
+ mode: z.enum(["foreground", "background"]).optional(),
235
+ hostname: z.string().min(1).optional(),
236
+ runnerName: z.string().min(1).optional(),
237
+ capabilities: z.array(runnerToolCapabilitySchema).optional(),
238
+ requestedTool: runnerToolSelectionSchema.optional(),
239
+ effectiveTool: z.union([runnerToolNameSchema, z.literal("custom")]).optional(),
240
+ effectiveModel: z.string().min(1).optional(),
241
+ preferenceSource: runnerPreferenceSourceSchema.optional(),
242
+ preferenceStatus: runnerPreferenceStatusSchema.optional(),
243
+ preferenceMessage: z.string().optional(),
244
+ lastSeenAt: isoDateTimeSchema
245
+ });
246
+ var runnerSettingsItemSchema = baseItemSchema.extend({
247
+ type: z.literal("accountSettings"),
248
+ projectId: z.string().min(1),
249
+ settingsType: z.literal("runnerPreferences"),
250
+ scope: runnerPreferenceScopeSchema,
251
+ preferences: runnerToolModelPreferenceSchema
252
+ });
253
+ var runnerExecutionLogItemSchema = baseItemSchema.extend({
254
+ type: z.literal("runnerExecutionLog"),
255
+ projectId: z.string().min(1),
256
+ runnerLogId: z.string().min(1),
257
+ runnerId: z.string().min(1),
258
+ workKind: workKindSchema.optional(),
259
+ workItemId: z.string().min(1).optional(),
260
+ workTitle: z.string().min(1).optional(),
261
+ initiatedBy: z.string().min(1).optional(),
262
+ approvedBy: z.string().min(1).optional(),
263
+ status: z.enum(["claimed", "running", "completed", "failed", "blocked", "idle"]),
264
+ executionMode: executionModeSchema.optional(),
265
+ tool: z.string().min(1).optional(),
266
+ model: z.string().min(1).optional(),
267
+ attempt: z.number().int().nonnegative().optional(),
268
+ startedAt: isoDateTimeSchema.optional(),
269
+ endedAt: isoDateTimeSchema.optional(),
270
+ durationMs: z.number().int().nonnegative().optional(),
271
+ costUsd: z.number().nonnegative().optional(),
272
+ tokensIn: z.number().int().nonnegative().optional(),
273
+ tokensOut: z.number().int().nonnegative().optional(),
274
+ sessionPolicy: sessionPolicySchema.optional(),
275
+ sessionGroupKey: z.string().min(1).optional(),
276
+ toolSessionId: z.string().min(1).optional(),
277
+ sessionDecision: sessionDecisionSchema.optional(),
278
+ sessionDecisionReason: z.string().min(1).optional(),
279
+ message: z.string().optional(),
280
+ error: z.string().optional()
281
+ });
282
+ var runnerCredentialItemSchema = baseItemSchema.extend({
283
+ type: z.literal("runnerCredential"),
284
+ projectId: z.string().min(1),
285
+ runnerCredentialId: z.string().min(1),
286
+ repositoryLinkId: z.string().min(1),
287
+ tokenHash: z.string().min(32),
288
+ issuedAt: isoDateTimeSchema,
289
+ lastUsedAt: isoDateTimeSchema.optional(),
290
+ status: z.enum(["active", "revoked"]).default("active")
291
+ });
292
+ var runnerCommandKindSchema = z.enum(["update", "restart", "remove"]);
293
+ var runnerCommandStatusSchema = z.enum(["pending", "acknowledged", "running", "completed", "failed", "expired", "cancelled"]);
294
+ var runnerCommandItemSchema = baseItemSchema.extend({
295
+ type: z.literal("runnerCommand"),
296
+ projectId: z.string().min(1),
297
+ commandId: z.string().min(1),
298
+ commandKind: runnerCommandKindSchema,
299
+ status: runnerCommandStatusSchema,
300
+ runnerId: z.string().min(1),
301
+ repositoryLinkId: z.string().min(1),
302
+ requestedByUserId: z.string().min(1),
303
+ idempotencyKey: z.string().min(1),
304
+ lastStatusIdempotencyKey: z.string().min(1).optional(),
305
+ expiresAt: isoDateTimeSchema,
306
+ acknowledgedAt: isoDateTimeSchema.optional(),
307
+ startedAt: isoDateTimeSchema.optional(),
308
+ completedAt: isoDateTimeSchema.optional(),
309
+ cancelledAt: isoDateTimeSchema.optional(),
310
+ message: z.string().optional(),
311
+ error: z.string().optional()
312
+ });
313
+ var planReviewMessageRoleSchema = z.enum(["user", "assistant", "system"]);
314
+ var planReviewMessageIntentSchema = z.enum(["ask", "revisionRequest", "revisionResult"]);
315
+ var planReviewMessageStatusSchema = z.enum(["posted", "queued", "running", "completed", "failed"]);
316
+ var planReviewMessageItemSchema = baseItemSchema.extend({
317
+ type: z.literal("planReviewMessage"),
318
+ projectId: z.string().min(1),
319
+ messageId: z.string().min(1),
320
+ threadId: z.string().min(1),
321
+ generatedDraftId: z.string().min(1),
322
+ documentId: z.string().min(1),
323
+ documentRevision: z.number().int().nonnegative(),
324
+ role: planReviewMessageRoleSchema,
325
+ intent: planReviewMessageIntentSchema,
326
+ status: planReviewMessageStatusSchema,
327
+ content: z.string().min(1),
328
+ workItemId: z.string().min(1).optional(),
329
+ responseToMessageId: z.string().min(1).optional(),
330
+ createdByUserId: z.string().min(1).optional(),
331
+ runnerId: z.string().min(1).optional()
332
+ });
333
+ var toolSessionItemSchema = baseItemSchema.extend({
334
+ type: z.literal("toolSession"),
335
+ projectId: z.string().min(1),
336
+ toolSessionId: z.string().min(1),
337
+ repositoryLinkId: z.string().min(1).optional(),
338
+ tool: z.string().min(1),
339
+ provider: z.string().min(1).optional(),
340
+ model: z.string().min(1).optional(),
341
+ resumabilityScope: sessionResumabilityScopeSchema,
342
+ title: z.string().min(1),
343
+ summary: z.string().optional(),
344
+ tags: z.array(z.string().min(1)).default([]),
345
+ relatedDocumentIds: z.array(z.string().min(1)).default([]),
346
+ status: toolSessionStatusSchema,
347
+ createdByUserId: z.string().min(1).optional(),
348
+ runnerId: z.string().min(1).optional(),
349
+ lastWorkItemId: z.string().min(1).optional(),
350
+ lastActivityAt: isoDateTimeSchema,
351
+ messageCount: z.number().int().nonnegative().optional(),
352
+ estimatedInputTokens: z.number().int().nonnegative().optional(),
353
+ estimatedOutputTokens: z.number().int().nonnegative().optional(),
354
+ costUsd: z.number().nonnegative().optional(),
355
+ sessionGroupKey: z.string().min(1).optional(),
356
+ reusePolicy: sessionPolicySchema.optional(),
357
+ closedReason: z.string().optional()
358
+ });
359
+ var pairingSessionItemSchema = baseItemSchema.extend({
360
+ type: z.literal("pairingSession"),
361
+ pairingCodeHash: z.string().min(1),
362
+ projectId: z.string().min(1),
363
+ createdByUserId: z.string().min(1),
364
+ expiresAt: isoDateTimeSchema,
365
+ status: z.enum(["pending", "confirmed", "expired", "revoked"])
366
+ });
367
+ var projectItemUnionSchema = z.discriminatedUnion("type", [
368
+ projectItemSchema,
369
+ repositoryLinkItemSchema,
370
+ brainDocumentItemSchema,
371
+ generatedDraftItemSchema,
372
+ syncCursorItemSchema,
373
+ syncConflictItemSchema,
374
+ workItemSchema,
375
+ runnerHeartbeatItemSchema,
376
+ runnerSettingsItemSchema,
377
+ runnerExecutionLogItemSchema,
378
+ runnerCredentialItemSchema,
379
+ runnerCommandItemSchema,
380
+ planReviewMessageItemSchema,
381
+ toolSessionItemSchema
382
+ ]);
383
+
384
+ // ../shared/src/api-url.ts
385
+ var AMISTIO_API_URL_ENV = "AMISTIO_API_URL";
386
+ var OFFICIAL_AMISTIO_WEB_ORIGIN = "https://www.amistio.com";
387
+ var API_PATH = "/api";
388
+ var officialAmistioApiUrl = normalizeAmistioApiUrl(new URL(API_PATH, OFFICIAL_AMISTIO_WEB_ORIGIN).toString());
389
+ function normalizeAmistioApiUrl(value) {
390
+ const parsed = new URL(value);
391
+ parsed.hash = "";
392
+ parsed.search = "";
393
+ if (parsed.pathname.length > 1 && parsed.pathname.endsWith("/")) {
394
+ parsed.pathname = parsed.pathname.slice(0, -1);
395
+ }
396
+ return parsed.toString().replace(/\/$/, "");
397
+ }
398
+ function isOfficialAmistioApiUrl(value) {
399
+ try {
400
+ return normalizeAmistioApiUrl(value) === officialAmistioApiUrl;
401
+ } catch {
402
+ return false;
403
+ }
404
+ }
405
+
406
+ // ../shared/src/repo-metadata.ts
407
+ import { z as z2 } from "zod";
408
+ var repoLinkMetadataSchema = z2.object({
409
+ amistioProjectId: z2.string().min(1),
410
+ amistioAccountId: z2.string().min(1),
411
+ repositoryLinkId: z2.string().min(1),
412
+ defaultBranch: z2.string().min(1).default("main"),
413
+ lastSyncedRevision: z2.coerce.number().int().nonnegative().default(0)
414
+ });
415
+ var syncedDocumentFrontmatterSchema = z2.object({
416
+ amistioDocumentId: z2.string().min(1),
417
+ amistioDocumentType: z2.string().min(1),
418
+ amistioRevision: z2.coerce.number().int().nonnegative(),
419
+ amistioContentHash: z2.string().min(1),
420
+ status: z2.string().optional()
421
+ });
422
+ function createProjectLinkMarkdown(metadata) {
423
+ return [
424
+ "---",
425
+ `amistioProjectId: ${metadata.amistioProjectId}`,
426
+ `amistioAccountId: ${metadata.amistioAccountId}`,
427
+ `repositoryLinkId: ${metadata.repositoryLinkId}`,
428
+ `defaultBranch: ${metadata.defaultBranch}`,
429
+ `lastSyncedRevision: ${metadata.lastSyncedRevision}`,
430
+ "---",
431
+ "",
432
+ "# Amistio Project Link",
433
+ "",
434
+ "This repository is paired with an Amistio project. Credentials are stored outside the repository on each user's machine.",
435
+ ""
436
+ ].join("\n");
437
+ }
438
+
439
+ // ../shared/src/repository-link.ts
440
+ var scpStyleSshPattern = /^([A-Za-z0-9._-]+)@([A-Za-z0-9.-]+):(.+)$/;
441
+ function parseRepositoryCloneUrl(input) {
442
+ const cloneUrl = input.trim();
443
+ if (!cloneUrl) {
444
+ throw new Error("Repository URL is required.");
445
+ }
446
+ if (isLocalPathLike(cloneUrl)) {
447
+ throw new Error("Use an HTTPS or SSH repository clone URL, not a local path.");
448
+ }
449
+ const scpMatch = scpStyleSshPattern.exec(cloneUrl);
450
+ if (scpMatch) {
451
+ const host = scpMatch[2];
452
+ const rawPath = scpMatch[3];
453
+ if (!host || !rawPath) {
454
+ throw new Error("Repository URL must include an owner and repository name.");
455
+ }
456
+ return buildParsedRepositoryCloneUrl({ cloneUrl, protocol: "ssh", host, rawPath });
457
+ }
458
+ let url;
459
+ try {
460
+ url = new URL(cloneUrl);
461
+ } catch {
462
+ throw new Error("Repository URL must be a valid HTTPS or SSH clone URL.");
463
+ }
464
+ if (url.protocol === "file:") {
465
+ throw new Error("Use an HTTPS or SSH repository clone URL, not a local path.");
466
+ }
467
+ if (url.protocol !== "https:" && url.protocol !== "ssh:") {
468
+ throw new Error("Repository URL must use HTTPS or SSH.");
469
+ }
470
+ if (url.protocol === "https:" && (url.username || url.password)) {
471
+ throw new Error("Repository URL must not include embedded credentials.");
472
+ }
473
+ if (url.protocol === "ssh:" && url.password) {
474
+ throw new Error("Repository SSH URL must not include a password.");
475
+ }
476
+ if (url.search || url.hash) {
477
+ throw new Error("Repository URL must not include query strings or fragments.");
478
+ }
479
+ return buildParsedRepositoryCloneUrl({
480
+ cloneUrl,
481
+ protocol: url.protocol === "https:" ? "https" : "ssh",
482
+ host: url.hostname,
483
+ rawPath: url.pathname
484
+ });
485
+ }
486
+ function repositoryCloneUrlsMatch(firstUrl, secondUrl) {
487
+ try {
488
+ return parseRepositoryCloneUrl(firstUrl).normalizedKey === parseRepositoryCloneUrl(secondUrl).normalizedKey;
489
+ } catch {
490
+ return false;
491
+ }
492
+ }
493
+ function buildParsedRepositoryCloneUrl({ cloneUrl, host, protocol, rawPath }) {
494
+ const normalizedHost = host.trim().toLowerCase();
495
+ if (!normalizedHost) {
496
+ throw new Error("Repository URL must include a host.");
497
+ }
498
+ const pathWithoutSlash = normalizeRepoPath(rawPath);
499
+ const segments = pathWithoutSlash.split("/").filter(Boolean);
500
+ if (segments.length < 2) {
501
+ throw new Error("Repository URL must include an owner and repository name.");
502
+ }
503
+ const repoSegment = segments[segments.length - 1];
504
+ const ownerSegment = segments[segments.length - 2];
505
+ const repoName = stripGitSuffix(repoSegment ?? "");
506
+ if (!repoName || !ownerSegment) {
507
+ throw new Error("Repository URL must include an owner and repository name.");
508
+ }
509
+ const normalizedSegments = [...segments.slice(0, -1), repoName].map((segment) => segment.toLowerCase());
510
+ const provider = inferRepositoryProvider(normalizedHost);
511
+ const repoOwner = ownerSegment;
512
+ const repoFullName = `${repoOwner}/${repoName}`;
513
+ return {
514
+ cloneUrl,
515
+ protocol,
516
+ host: normalizedHost,
517
+ path: pathWithoutSlash,
518
+ repoName,
519
+ normalizedKey: `${normalizedHost}/${normalizedSegments.join("/")}`,
520
+ ...provider ? { provider } : {},
521
+ repoOwner,
522
+ repoFullName
523
+ };
524
+ }
525
+ function normalizeRepoPath(rawPath) {
526
+ const withoutLeadingSlash = rawPath.trim().replace(/^\/+/, "").replace(/\/+$/, "");
527
+ if (!withoutLeadingSlash || withoutLeadingSlash.includes("..")) {
528
+ throw new Error("Repository URL path is not supported.");
529
+ }
530
+ return withoutLeadingSlash;
531
+ }
532
+ function stripGitSuffix(value) {
533
+ return value.replace(/\.git$/i, "");
534
+ }
535
+ function inferRepositoryProvider(host) {
536
+ if (host === "github.com" || host.endsWith(".github.com")) return "github";
537
+ if (host === "gitlab.com" || host.endsWith(".gitlab.com")) return "gitlab";
538
+ if (host === "bitbucket.org" || host.endsWith(".bitbucket.org")) return "bitbucket";
539
+ if (host.endsWith("dev.azure.com") || host.endsWith("visualstudio.com")) return "azureDevOps";
540
+ return "other";
541
+ }
542
+ function isLocalPathLike(value) {
543
+ return value.startsWith("/") || value.startsWith("./") || value.startsWith("../") || value.startsWith("~/") || /^[A-Za-z]:[\\/]/.test(value);
544
+ }
545
+
546
+ // ../shared/src/sync.ts
547
+ import { createHash } from "node:crypto";
548
+ function sha256ContentHash(content) {
549
+ return createHash("sha256").update(content, "utf8").digest("hex");
550
+ }
551
+ function decideSyncState(state) {
552
+ const webChanged = Boolean(state.webHash && state.webHash !== state.lastSyncedHash);
553
+ const repoChanged = Boolean(state.repoHash && state.repoHash !== state.lastSyncedHash);
554
+ if (webChanged && repoChanged && state.webHash !== state.repoHash) {
555
+ return "conflict";
556
+ }
557
+ if (webChanged) {
558
+ return "pullWeb";
559
+ }
560
+ if (repoChanged) {
561
+ return "pushRepo";
562
+ }
563
+ return "clean";
564
+ }
565
+
566
+ // ../shared/src/next-action.ts
567
+ var nextActionRunnerHeartbeatFreshMs = 15 * 60 * 1e3;
568
+ function computeProjectNextAction(input) {
569
+ const nowMs = input.nowMs ?? Date.now();
570
+ if (!input.projectId) {
571
+ return {
572
+ kind: "noProject",
573
+ actor: "user",
574
+ tone: "neutral",
575
+ title: "Create a project",
576
+ message: "Create a project before linking a repository or starting runner work."
577
+ };
578
+ }
579
+ const activeRepositoryLinks = input.repositoryLinks.filter((link) => link.status !== "revoked");
580
+ const repositoryLink = activeRepositoryLinks[0];
581
+ if (!repositoryLink) {
582
+ return {
583
+ kind: "linkRepository",
584
+ actor: "user",
585
+ tone: "warning",
586
+ title: "Link a repository",
587
+ message: "Add the project repository so Amistio can pair a local runner."
588
+ };
589
+ }
590
+ const pairedRepositoryLinks = activeRepositoryLinks.filter((link) => Boolean(link.repoFingerprint));
591
+ if (!pairedRepositoryLinks.length) {
592
+ return {
593
+ kind: "pairRepository",
594
+ actor: "user",
595
+ tone: "warning",
596
+ title: "Pair the repository",
597
+ message: `Run the pairing command from ${repositoryLink.repoName} before queueing runner work.`,
598
+ repositoryLinkId: repositoryLink.repositoryLinkId,
599
+ updatedAt: repositoryLink.updatedAt
600
+ };
601
+ }
602
+ const runningWork = latestWorkItem(input.workItems.filter((item) => item.status === "running"));
603
+ if (runningWork) {
604
+ if (runningWork.workKind === "brainGeneration") {
605
+ return workAction(runningWork, "brainGenerationRunning", "runner", "warning", "Generating project brain", "The local runner is generating draft brain artifacts for review.");
606
+ }
607
+ if (runningWork.workKind === "planRevision") {
608
+ return workAction(runningWork, "planRevisionRunning", "runner", "warning", "Revising the plan", "The local runner is revising the generated plan from the conversation.");
609
+ }
610
+ return workAction(runningWork, "runnerRunningWork", "runner", "warning", "Runner is working", "The local runner has claimed approved implementation work.");
611
+ }
612
+ const generatedReview = generatedReviewState(input.documents);
613
+ if (generatedReview.unapproved.length > 0) {
614
+ const partial = generatedReview.approved.length > 0;
615
+ const title = partial ? "Finish generated review" : "Review generated artifacts";
616
+ const message = partial ? `${generatedReview.unapproved.length} generated artifact${generatedReview.unapproved.length === 1 ? "" : "s"} still need approval before implementation work can queue.` : `${generatedReview.unapproved.length} generated artifact${generatedReview.unapproved.length === 1 ? "" : "s"} need approval in the web app before the runner can implement them.`;
617
+ return {
618
+ kind: partial ? "generatedArtifactsPartiallyApproved" : "generatedArtifactsReview",
619
+ actor: "user",
620
+ tone: "warning",
621
+ title,
622
+ message,
623
+ count: generatedReview.unapproved.length,
624
+ ...generatedReview.unapproved[0]?.documentId ? { documentId: generatedReview.unapproved[0].documentId } : {},
625
+ ...latestTimestamp(generatedReview.unapproved.map((document) => document.updatedAt)) ? { updatedAt: latestTimestamp(generatedReview.unapproved.map((document) => document.updatedAt)) } : {}
626
+ };
627
+ }
628
+ const failedBrainGeneration = latestWorkItem(input.workItems.filter((item) => item.workKind === "brainGeneration" && item.status === "failed"));
629
+ if (failedBrainGeneration) {
630
+ return workAction(failedBrainGeneration, "brainGenerationFailed", "user", "danger", "Brain generation failed", failedBrainGeneration.lastStatusMessage ?? "Retry generation after checking the runner output.");
631
+ }
632
+ const failedPlanRevision = latestWorkItem(input.workItems.filter((item) => item.workKind === "planRevision" && item.status === "failed"));
633
+ if (failedPlanRevision) {
634
+ return workAction(failedPlanRevision, "planRevisionFailed", "user", "danger", "Plan revision failed", failedPlanRevision.lastStatusMessage ?? "Review the conversation and request another revision if needed.");
635
+ }
636
+ const blockedWork = latestWorkItem(input.workItems.filter((item) => item.status === "blocked" || item.status === "changesRequested"));
637
+ if (blockedWork) {
638
+ return workAction(blockedWork, "workBlocked", "user", "danger", "Work is blocked", blockedWork.lastStatusMessage ?? "Review the blocked work item before the runner can continue.");
639
+ }
640
+ const failedWork = latestWorkItem(input.workItems.filter((item) => item.status === "failed"));
641
+ if (failedWork) {
642
+ return workAction(failedWork, "workFailed", "user", "danger", "Work failed", failedWork.lastStatusMessage ?? "Review the failure and retry or update the plan.");
643
+ }
644
+ const queuedBrainGeneration = latestWorkItem(input.workItems.filter((item) => item.workKind === "brainGeneration" && item.status === "approved"));
645
+ const queuedPlanRevision = latestWorkItem(input.workItems.filter((item) => item.workKind === "planRevision" && item.status === "approved"));
646
+ const queuedImplementation = latestWorkItem(input.workItems.filter((item) => item.workKind !== "brainGeneration" && item.workKind !== "planRevision" && item.status === "approved"));
647
+ const queuedWork = queuedBrainGeneration ?? queuedPlanRevision ?? queuedImplementation;
648
+ const readiness = getSharedRunnerReadiness(pairedRepositoryLinks, input.runnerHeartbeats, nowMs);
649
+ if (queuedWork && !readiness.ready) {
650
+ const title = queuedWork.workKind === "brainGeneration" ? "Brain generation is queued" : queuedWork.workKind === "planRevision" ? "Plan revision is queued" : "Implementation is queued";
651
+ return runnerWaitAction(readiness, title);
652
+ }
653
+ if (queuedBrainGeneration) {
654
+ return workAction(queuedBrainGeneration, "brainGenerationQueued", "runner", "warning", "Brain generation queued", "The local runner can claim this queued brain-generation work.");
655
+ }
656
+ if (queuedPlanRevision) {
657
+ return workAction(queuedPlanRevision, "planRevisionQueued", "runner", "warning", "Plan revision queued", "The local runner can claim the requested plan revision.");
658
+ }
659
+ if (queuedImplementation) {
660
+ return workAction(queuedImplementation, "implementationQueued", "runner", "warning", "Implementation queued", "The local runner can claim approved implementation work.");
661
+ }
662
+ if (!readiness.ready) {
663
+ return runnerWaitAction(readiness, "Runner setup needed");
664
+ }
665
+ const completedWork = latestWorkItem(input.workItems.filter((item) => item.status === "completed"));
666
+ if (completedWork) {
667
+ return workAction(completedWork, "workCompleted", "none", "success", "Work completed", completedWork.lastStatusMessage ?? "The latest runner work is complete.");
668
+ }
669
+ return {
670
+ kind: "idle",
671
+ actor: "user",
672
+ tone: "success",
673
+ title: "Ready for the next task",
674
+ message: "The repository and runner are ready for a new project-brain request.",
675
+ ...readiness.repositoryLink?.repositoryLinkId ? { repositoryLinkId: readiness.repositoryLink.repositoryLinkId } : {},
676
+ ...readiness.heartbeat?.runnerId ? { runnerId: readiness.heartbeat.runnerId } : {},
677
+ ...readiness.heartbeat?.lastSeenAt ? { updatedAt: readiness.heartbeat.lastSeenAt } : {}
678
+ };
679
+ }
680
+ function formatProjectNextAction(action) {
681
+ return `${action.title}: ${action.message}`;
682
+ }
683
+ function runnerWaitAction(readiness, fallbackTitle) {
684
+ const repositoryName = readiness.repositoryLink?.repoName ?? "the paired repository";
685
+ if (readiness.reason === "runnerOffline") {
686
+ return {
687
+ kind: "runnerOffline",
688
+ actor: "user",
689
+ tone: "danger",
690
+ title: "Restart the runner",
691
+ message: `The runner for ${repositoryName} is offline. Start amistio run --watch from the paired checkout.`,
692
+ ...readiness.repositoryLink?.repositoryLinkId ? { repositoryLinkId: readiness.repositoryLink.repositoryLinkId } : {},
693
+ ...readiness.heartbeat?.runnerId ? { runnerId: readiness.heartbeat.runnerId } : {},
694
+ ...readiness.heartbeat?.lastSeenAt ? { updatedAt: readiness.heartbeat.lastSeenAt } : {}
695
+ };
696
+ }
697
+ if (readiness.reason === "runnerStale") {
698
+ return {
699
+ kind: "runnerStale",
700
+ actor: "user",
701
+ tone: "warning",
702
+ title: "Refresh the runner",
703
+ message: `The runner for ${repositoryName} has not checked in recently. Restart amistio run --watch from the paired checkout.`,
704
+ ...readiness.repositoryLink?.repositoryLinkId ? { repositoryLinkId: readiness.repositoryLink.repositoryLinkId } : {},
705
+ ...readiness.heartbeat?.runnerId ? { runnerId: readiness.heartbeat.runnerId } : {},
706
+ ...readiness.heartbeat?.lastSeenAt ? { updatedAt: readiness.heartbeat.lastSeenAt } : {}
707
+ };
708
+ }
709
+ return {
710
+ kind: "runnerMissing",
711
+ actor: "user",
712
+ tone: "warning",
713
+ title: fallbackTitle,
714
+ message: `Start amistio run --watch from ${repositoryName} so the local runner can claim work.`,
715
+ ...readiness.repositoryLink?.repositoryLinkId ? { repositoryLinkId: readiness.repositoryLink.repositoryLinkId } : {}
716
+ };
717
+ }
718
+ function getSharedRunnerReadiness(repositoryLinks, runnerHeartbeats, nowMs) {
719
+ const repositoryLinkIds = new Set(repositoryLinks.map((link) => link.repositoryLinkId));
720
+ const latestHeartbeat = runnerHeartbeats.filter((heartbeat) => repositoryLinkIds.has(heartbeat.repositoryLinkId)).sort((first, second) => heartbeatTime(second) - heartbeatTime(first))[0];
721
+ const repositoryLink = latestHeartbeat ? repositoryLinks.find((link) => link.repositoryLinkId === latestHeartbeat.repositoryLinkId) ?? repositoryLinks[0] : repositoryLinks[0];
722
+ if (!latestHeartbeat) {
723
+ return { ready: false, reason: "runnerMissing", ...repositoryLink ? { repositoryLink } : {} };
724
+ }
725
+ if (latestHeartbeat.status === "offline") {
726
+ return { ready: false, reason: "runnerOffline", ...repositoryLink ? { repositoryLink } : {}, heartbeat: latestHeartbeat };
727
+ }
728
+ if (!isFreshHeartbeat(latestHeartbeat, nowMs)) {
729
+ return { ready: false, reason: "runnerStale", ...repositoryLink ? { repositoryLink } : {}, heartbeat: latestHeartbeat };
730
+ }
731
+ return { ready: true, reason: "ready", ...repositoryLink ? { repositoryLink } : {}, heartbeat: latestHeartbeat };
732
+ }
733
+ function generatedReviewState(documents) {
734
+ const generated = documents.filter(isGeneratedDocument);
735
+ return {
736
+ approved: generated.filter((document) => document.status === "approved"),
737
+ unapproved: generated.filter((document) => document.status !== "approved" && document.status !== "rejected" && document.syncState !== "archived")
738
+ };
739
+ }
740
+ function isGeneratedDocument(document) {
741
+ const generatedDraftId = document.frontmatter.generatedDraftId;
742
+ return typeof generatedDraftId === "string" && generatedDraftId.length > 0;
743
+ }
744
+ function workAction(workItem, kind, actor, tone, title, message) {
745
+ return {
746
+ kind,
747
+ actor,
748
+ tone,
749
+ title,
750
+ message,
751
+ workItemId: workItem.workItemId,
752
+ ...workItem.claimedByRunnerId ? { runnerId: workItem.claimedByRunnerId } : {},
753
+ updatedAt: workItem.lastStatusAt
754
+ };
755
+ }
756
+ function latestWorkItem(workItems) {
757
+ return [...workItems].sort((first, second) => Date.parse(second.updatedAt) - Date.parse(first.updatedAt))[0];
758
+ }
759
+ function latestTimestamp(values) {
760
+ return values.sort((first, second) => Date.parse(second) - Date.parse(first))[0];
761
+ }
762
+ function heartbeatTime(heartbeat) {
763
+ const timestamp = Date.parse(heartbeat.lastSeenAt);
764
+ return Number.isNaN(timestamp) ? 0 : timestamp;
765
+ }
766
+ function isFreshHeartbeat(heartbeat, nowMs) {
767
+ const lastSeenMs = heartbeatTime(heartbeat);
768
+ return lastSeenMs > 0 && nowMs - lastSeenMs <= nextActionRunnerHeartbeatFreshMs;
769
+ }
770
+
771
+ // src/bootstrap.ts
772
+ import { execFile } from "node:child_process";
773
+ import { mkdir, readdir, stat } from "node:fs/promises";
774
+ import path from "node:path";
775
+ import { promisify } from "node:util";
776
+ var execFileAsync = promisify(execFile);
777
+ function buildGitCloneArgs(repoUrl, targetDir) {
778
+ return ["clone", repoUrl, targetDir];
779
+ }
780
+ async function cloneOrValidateRepository({ repoUrl, targetDir }) {
781
+ const resolvedTarget = path.resolve(targetDir);
782
+ const targetStat = await stat(resolvedTarget).catch((error) => {
783
+ if (isNotFoundError(error)) return void 0;
784
+ throw error;
785
+ });
786
+ if (!targetStat) {
787
+ await mkdir(path.dirname(resolvedTarget), { recursive: true });
788
+ await runGit(buildGitCloneArgs(repoUrl, resolvedTarget), "clone");
789
+ return { status: "cloned", targetDir: resolvedTarget };
790
+ }
791
+ if (!targetStat.isDirectory()) {
792
+ throw new Error("Bootstrap target exists and is not a directory.");
793
+ }
794
+ if (await isGitCheckout(resolvedTarget)) {
795
+ const originUrl = await runGit(["-C", resolvedTarget, "remote", "get-url", "origin"], "remote");
796
+ if (!repositoryCloneUrlsMatch(repoUrl, originUrl)) {
797
+ throw new Error("Bootstrap target is a Git checkout for a different repository.");
798
+ }
799
+ return { status: "validated", targetDir: resolvedTarget, originUrl };
800
+ }
801
+ const entries = await readdir(resolvedTarget);
802
+ if (entries.length > 0) {
803
+ throw new Error("Bootstrap target exists, is not empty, and is not the linked repository checkout.");
804
+ }
805
+ await runGit(buildGitCloneArgs(repoUrl, resolvedTarget), "clone");
806
+ return { status: "cloned", targetDir: resolvedTarget };
807
+ }
808
+ async function isGitCheckout(targetDir) {
809
+ try {
810
+ const result = await runGit(["-C", targetDir, "rev-parse", "--is-inside-work-tree"], "rev-parse");
811
+ return result.trim() === "true";
812
+ } catch {
813
+ return false;
814
+ }
815
+ }
816
+ async function runGit(args, operation) {
817
+ try {
818
+ const { stdout } = await execFileAsync("git", args, { maxBuffer: 1024 * 1024 });
819
+ return stdout.trim();
820
+ } catch {
821
+ if (operation === "clone") {
822
+ throw new Error("git clone failed. Confirm local Git credentials can access the repository.");
823
+ }
824
+ if (operation === "remote") {
825
+ throw new Error("Could not read the target checkout origin remote.");
826
+ }
827
+ throw new Error("Could not inspect the target Git checkout.");
828
+ }
829
+ }
830
+ function isNotFoundError(error) {
831
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
832
+ }
833
+
834
+ // src/credential-store.ts
835
+ import { chmod, mkdir as mkdir2, readFile, writeFile } from "node:fs/promises";
836
+ import os from "node:os";
837
+ import path2 from "node:path";
838
+ var LocalCredentialStore = class {
839
+ constructor(filePath = path2.join(os.homedir(), ".config", "amistio", "credentials.json")) {
840
+ this.filePath = filePath;
841
+ }
842
+ filePath;
843
+ async set(key, value) {
844
+ const data = await this.read();
845
+ data.credentials[key] = value;
846
+ await mkdir2(path2.dirname(this.filePath), { recursive: true });
847
+ await writeFile(this.filePath, JSON.stringify(data, null, 2), { encoding: "utf8", mode: 384 });
848
+ await chmod(this.filePath, 384);
849
+ }
850
+ async get(key) {
851
+ const data = await this.read();
852
+ return data.credentials[key];
853
+ }
854
+ async delete(key) {
855
+ const data = await this.read();
856
+ if (!(key in data.credentials)) {
857
+ return;
858
+ }
859
+ delete data.credentials[key];
860
+ await mkdir2(path2.dirname(this.filePath), { recursive: true });
861
+ await writeFile(this.filePath, JSON.stringify(data, null, 2), { encoding: "utf8", mode: 384 });
862
+ await chmod(this.filePath, 384);
863
+ }
864
+ async read() {
865
+ try {
866
+ return JSON.parse(await readFile(this.filePath, "utf8"));
867
+ } catch {
868
+ return { credentials: {} };
869
+ }
870
+ }
871
+ };
872
+ function credentialKey(accountId, projectId, repositoryLinkId) {
873
+ return `${accountId}:${projectId}:${repositoryLinkId}`;
874
+ }
875
+
876
+ // src/control-plane.ts
877
+ import { mkdir as mkdir3, readFile as readFile2, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
878
+ import path3 from "node:path";
879
+ var controlPlaneFolders = [
880
+ "architecture",
881
+ "context",
882
+ "decisions",
883
+ "features",
884
+ "memory",
885
+ "plans",
886
+ path3.join("prompts", "shared"),
887
+ "workflows"
888
+ ];
889
+ async function initControlPlane(rootDir) {
890
+ const created = [];
891
+ for (const folder of controlPlaneFolders) {
892
+ const fullPath = path3.join(rootDir, folder);
893
+ if (!await exists(fullPath)) {
894
+ await mkdir3(fullPath, { recursive: true });
895
+ created.push(folder);
896
+ }
897
+ }
898
+ await writeIfMissing(
899
+ path3.join(rootDir, "AGENTS.md"),
900
+ "# Agents\n\nRoot control-plane folders are the project brain. Keep architecture, context, decisions, features, memory, plans, prompts, and workflows in sync with product intent.\n"
901
+ );
902
+ await writeIfMissing(path3.join(rootDir, "context", "product.md"), "# Product Context\n\nDescribe the product direction here.\n");
903
+ await writeIfMissing(path3.join(rootDir, "architecture", "overview.md"), "# Architecture Overview\n\nDescribe the system shape here.\n");
904
+ return created;
905
+ }
906
+ async function inspectControlPlane(rootDir) {
907
+ const present = [];
908
+ const missing = [];
909
+ for (const folder of controlPlaneFolders) {
910
+ if (await exists(path3.join(rootDir, folder))) {
911
+ present.push(folder);
912
+ } else {
913
+ missing.push(folder);
914
+ }
915
+ }
916
+ return { present, missing };
917
+ }
918
+ async function writeProjectLink(rootDir, metadata) {
919
+ const contextDir = path3.join(rootDir, "context");
920
+ await mkdir3(contextDir, { recursive: true });
921
+ const filePath = path3.join(contextDir, "amistio-project.md");
922
+ await writeFile2(filePath, createProjectLinkMarkdown(metadata), "utf8");
923
+ return filePath;
924
+ }
925
+ async function readProjectLink(rootDir) {
926
+ const filePath = path3.join(rootDir, "context", "amistio-project.md");
927
+ if (!await exists(filePath)) {
928
+ return void 0;
929
+ }
930
+ const content = await readFile2(filePath, "utf8");
931
+ const frontmatter = parseFrontmatter(content);
932
+ return repoLinkMetadataSchema.parse(frontmatter);
933
+ }
934
+ function parseFrontmatter(content) {
935
+ if (!content.startsWith("---\n")) {
936
+ return {};
937
+ }
938
+ const end = content.indexOf("\n---", 4);
939
+ if (end === -1) {
940
+ return {};
941
+ }
942
+ const lines = content.slice(4, end).split("\n");
943
+ return Object.fromEntries(
944
+ lines.map((line) => {
945
+ const separator = line.indexOf(":");
946
+ if (separator === -1) {
947
+ return void 0;
948
+ }
949
+ return [line.slice(0, separator).trim(), line.slice(separator + 1).trim()];
950
+ }).filter((entry) => Boolean(entry))
951
+ );
952
+ }
953
+ async function writeIfMissing(filePath, content) {
954
+ if (await exists(filePath)) {
955
+ return;
956
+ }
957
+ await writeFile2(filePath, content, "utf8");
958
+ }
959
+ async function exists(filePath) {
960
+ try {
961
+ await stat2(filePath);
962
+ return true;
963
+ } catch {
964
+ return false;
965
+ }
966
+ }
967
+
968
+ // src/api-client.ts
969
+ import { z as z3 } from "zod";
970
+ var ApiClient = class {
971
+ constructor(options) {
972
+ this.options = options;
973
+ }
974
+ options;
975
+ async createPairingSession(projectId) {
976
+ return this.request(
977
+ "pairing-sessions",
978
+ z3.object({ pairingCode: z3.string(), expiresAt: z3.string() }),
979
+ {
980
+ method: "POST",
981
+ body: JSON.stringify({ projectId })
982
+ }
983
+ );
984
+ }
985
+ async consumePairingSession(input) {
986
+ return this.request(
987
+ "pairing-sessions",
988
+ z3.object({ repositoryLink: repositoryLinkItemSchema, token: z3.string().min(1) }),
989
+ {
990
+ method: "PATCH",
991
+ body: JSON.stringify(input)
992
+ }
993
+ );
994
+ }
995
+ async claimWork(projectId, runnerId, repositoryLinkId, leaseSeconds = 300) {
996
+ return this.request(
997
+ `/projects/${projectId}/work-items/claim`,
998
+ z3.object({ workItem: workItemSchema.optional() }).transform(({ workItem }) => ({ workItem })),
999
+ {
1000
+ method: "POST",
1001
+ body: JSON.stringify({ runnerId, repositoryLinkId, leaseSeconds })
1002
+ }
1003
+ );
1004
+ }
1005
+ async listWorkItems(projectId) {
1006
+ return this.request(
1007
+ `/projects/${projectId}/work-items`,
1008
+ z3.object({ workItems: z3.array(workItemSchema) }),
1009
+ { method: "GET" }
1010
+ );
1011
+ }
1012
+ async listBrainDocuments(projectId) {
1013
+ return this.request(
1014
+ `/projects/${projectId}/brain-documents`,
1015
+ z3.object({ documents: z3.array(brainDocumentItemSchema) }),
1016
+ { method: "GET" }
1017
+ );
1018
+ }
1019
+ async listPlanReviewMessages(projectId, documentId) {
1020
+ const suffix = documentId ? `?documentId=${encodeURIComponent(documentId)}` : "";
1021
+ return this.request(
1022
+ `/projects/${projectId}/plan-review-messages${suffix}`,
1023
+ z3.object({ messages: z3.array(planReviewMessageItemSchema) }),
1024
+ { method: "GET" }
1025
+ );
1026
+ }
1027
+ async pushBrainDocuments(projectId, documents) {
1028
+ return this.request(
1029
+ `/projects/${projectId}/brain-documents`,
1030
+ z3.object({ documents: z3.array(brainDocumentItemSchema) }),
1031
+ {
1032
+ method: "POST",
1033
+ body: JSON.stringify({ documents })
1034
+ }
1035
+ );
1036
+ }
1037
+ async listRunners(projectId) {
1038
+ return this.request(
1039
+ `/projects/${projectId}/runners`,
1040
+ z3.object({ runners: z3.array(runnerHeartbeatItemSchema) }),
1041
+ { method: "GET" }
1042
+ );
1043
+ }
1044
+ async listRunnerCommands(projectId, runnerId, repositoryLinkId) {
1045
+ return this.request(
1046
+ `/projects/${projectId}/runner-commands?runnerId=${encodeURIComponent(runnerId)}&repositoryLinkId=${encodeURIComponent(repositoryLinkId)}`,
1047
+ z3.object({ commands: z3.array(runnerCommandItemSchema) }),
1048
+ { method: "GET" }
1049
+ );
1050
+ }
1051
+ async updateRunnerCommand(projectId, commandId, input) {
1052
+ return this.request(
1053
+ `/projects/${projectId}/runner-commands/${commandId}`,
1054
+ z3.object({ command: runnerCommandItemSchema }),
1055
+ {
1056
+ method: "PATCH",
1057
+ body: JSON.stringify(input)
1058
+ }
1059
+ );
1060
+ }
1061
+ async getRunnerPreferences(projectId) {
1062
+ const response = await this.request(
1063
+ `/projects/${projectId}/runner-preferences`,
1064
+ z3.object({
1065
+ account: runnerSettingsItemSchema.optional(),
1066
+ project: runnerSettingsItemSchema.optional(),
1067
+ effective: z3.object({
1068
+ tool: runnerToolSelectionSchema,
1069
+ model: z3.string().optional(),
1070
+ source: runnerPreferenceSourceSchema
1071
+ })
1072
+ }),
1073
+ { method: "GET" }
1074
+ );
1075
+ return {
1076
+ ...response.account ? { account: response.account } : {},
1077
+ ...response.project ? { project: response.project } : {},
1078
+ effective: response.effective
1079
+ };
1080
+ }
1081
+ async sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, status, metadata) {
1082
+ const heartbeatMetadata = typeof metadata === "string" ? { version: metadata } : metadata ?? {};
1083
+ return this.request(
1084
+ `/projects/${projectId}/runners`,
1085
+ z3.object({ runner: runnerHeartbeatItemSchema }),
1086
+ {
1087
+ method: "POST",
1088
+ body: JSON.stringify({ runnerId, repositoryLinkId, status, ...heartbeatMetadata, lastSeenAt: (/* @__PURE__ */ new Date()).toISOString() })
1089
+ }
1090
+ );
1091
+ }
1092
+ async listToolSessions(projectId) {
1093
+ return this.request(
1094
+ `/projects/${projectId}/tool-sessions`,
1095
+ z3.object({ toolSessions: z3.array(toolSessionItemSchema) }),
1096
+ { method: "GET" }
1097
+ );
1098
+ }
1099
+ async createToolSession(projectId, session) {
1100
+ return this.request(
1101
+ `/projects/${projectId}/tool-sessions`,
1102
+ z3.object({ toolSession: toolSessionItemSchema }),
1103
+ {
1104
+ method: "POST",
1105
+ body: JSON.stringify(session)
1106
+ }
1107
+ );
1108
+ }
1109
+ async updateToolSession(projectId, toolSessionId, session) {
1110
+ return this.request(
1111
+ `/projects/${projectId}/tool-sessions/${toolSessionId}`,
1112
+ z3.object({ toolSession: toolSessionItemSchema }),
1113
+ {
1114
+ method: "PATCH",
1115
+ body: JSON.stringify(session)
1116
+ }
1117
+ );
1118
+ }
1119
+ async updateWorkStatus(projectId, workItemId, status, idempotencyKey, runnerId, telemetry = {}) {
1120
+ return this.request(
1121
+ `/projects/${projectId}/work-items/${workItemId}/status`,
1122
+ z3.object({ workItem: workItemSchema }),
1123
+ {
1124
+ method: "PATCH",
1125
+ body: JSON.stringify({ status, idempotencyKey, ...runnerId ? { runnerId } : {}, ...telemetry })
1126
+ }
1127
+ );
1128
+ }
1129
+ async submitBrainGenerationResult(projectId, workItemId, result) {
1130
+ return this.request(
1131
+ `/projects/${projectId}/work-items/${workItemId}/generation-result`,
1132
+ z3.object({ draft: generatedDraftItemSchema, documents: z3.array(brainDocumentItemSchema), workItem: workItemSchema }),
1133
+ {
1134
+ method: "POST",
1135
+ body: JSON.stringify(result)
1136
+ }
1137
+ );
1138
+ }
1139
+ async request(urlPath, schema, init) {
1140
+ const response = await fetch(resolveApiUrl(this.options.apiUrl, urlPath), {
1141
+ ...init,
1142
+ headers: {
1143
+ "content-type": "application/json",
1144
+ "x-amistio-account-id": this.options.accountId,
1145
+ ...this.options.token ? { authorization: `Bearer ${this.options.token}` } : {}
1146
+ }
118
1147
  });
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) } })
1148
+ if (!response.ok) {
1149
+ const detail = await response.text().catch(() => "");
1150
+ throw new Error(`Amistio API request failed: ${response.status} ${response.statusText}${detail ? ` - ${detail}` : ""}`);
1151
+ }
1152
+ return schema.parse(await response.json());
1153
+ }
1154
+ };
1155
+ var toolSessionMutationSchema = z3.object({
1156
+ toolSessionId: z3.string().min(1).optional(),
1157
+ repositoryLinkId: z3.string().min(1).optional(),
1158
+ tool: z3.string().min(1).optional(),
1159
+ provider: z3.string().min(1).optional(),
1160
+ model: z3.string().min(1).optional(),
1161
+ resumabilityScope: sessionResumabilityScopeSchema.optional(),
1162
+ title: z3.string().min(1).optional(),
1163
+ summary: z3.string().optional(),
1164
+ tags: z3.array(z3.string()).optional(),
1165
+ relatedDocumentIds: z3.array(z3.string()).optional(),
1166
+ status: toolSessionStatusSchema.optional(),
1167
+ createdByUserId: z3.string().min(1).optional(),
1168
+ runnerId: z3.string().min(1).optional(),
1169
+ lastWorkItemId: z3.string().min(1).optional(),
1170
+ messageCount: z3.number().int().nonnegative().optional(),
1171
+ estimatedInputTokens: z3.number().int().nonnegative().optional(),
1172
+ estimatedOutputTokens: z3.number().int().nonnegative().optional(),
1173
+ costUsd: z3.number().nonnegative().optional(),
1174
+ sessionGroupKey: z3.string().min(1).optional(),
1175
+ reusePolicy: sessionPolicySchema.optional(),
1176
+ sessionDecision: sessionDecisionSchema.optional(),
1177
+ closedReason: z3.string().optional()
1178
+ });
1179
+ function resolveApiUrl(apiUrl, urlPath) {
1180
+ const base = apiUrl.endsWith("/") ? apiUrl.slice(0, -1) : apiUrl;
1181
+ const path10 = urlPath.startsWith("/") ? urlPath : `/${urlPath}`;
1182
+ return new URL(`${base}${path10}`);
1183
+ }
1184
+
1185
+ // src/orchestrator.ts
1186
+ import { mkdir as mkdir4, writeFile as writeFile3 } from "node:fs/promises";
1187
+ import path4 from "node:path";
1188
+ async function createOrchestrationPrompt(options) {
1189
+ const controlPlane = await inspectControlPlane(options.rootDir);
1190
+ return [
1191
+ "# Amistio Orchestration Task",
1192
+ "",
1193
+ "You are running locally through the Amistio CLI inside the user's repository.",
1194
+ "Your job is to keep future agents on the same track by updating the Amistio control plane, not by rewriting the project from scratch.",
1195
+ "",
1196
+ "## Goal",
1197
+ "",
1198
+ options.goal,
1199
+ "",
1200
+ "## Orchestrator Principle",
1201
+ "",
1202
+ "The root control-plane files are the project brain. They exist so agentic coding sessions can continue from established context, decisions, memory, plans, workflows, and prompts instead of drifting into a fresh interpretation each time.",
1203
+ "",
1204
+ "## Repository Rules",
1205
+ "",
1206
+ "- Read AGENTS.md first when it exists.",
1207
+ "- Read existing architecture, context, decisions, features, memory, plans, prompts, and workflows before proposing changes.",
1208
+ "- Preserve existing intent. Add the next useful orchestration layer instead of replacing old content wholesale.",
1209
+ "- Prefer small additive edits, dated notes, explicit decisions, focused plans, and executable prompts.",
1210
+ "- Do not modify product source code unless the user's goal explicitly asks for implementation.",
1211
+ "- Do not create a repo-local .amistio folder.",
1212
+ "- Do not write access tokens, API keys, refresh tokens, local filesystem secrets, or machine-specific credentials into the repository.",
1213
+ "- Do not commit changes.",
1214
+ "",
1215
+ "## Control-Plane Areas",
1216
+ "",
1217
+ "- AGENTS.md for agent operating rules.",
1218
+ "- architecture/**/*.md for system shape and boundaries.",
1219
+ "- context/**/*.md for product, stack, and implementation context.",
1220
+ "- decisions/**/*.md for ADRs and durable choices.",
1221
+ "- features/**/*.md for behavior specs and acceptance criteria.",
1222
+ "- memory/**/*.md for lessons, patterns, and known pitfalls.",
1223
+ "- plans/**/*.md for near-term execution plans.",
1224
+ "- prompts/**/*.md for model-agnostic implementation prompts.",
1225
+ "- workflows/**/*.md for repeatable procedures.",
1226
+ "",
1227
+ "## Current Control-Plane Folder Status",
1228
+ "",
1229
+ `Present: ${controlPlane.present.length ? controlPlane.present.join(", ") : "none"}`,
1230
+ `Missing: ${controlPlane.missing.length ? controlPlane.missing.join(", ") : "none"}`,
1231
+ "",
1232
+ "## Expected Result",
1233
+ "",
1234
+ "- Update the smallest useful set of control-plane files needed to orient the next coding agent.",
1235
+ "- Record any new decision, plan, prompt, workflow, memory, or context that prevents future drift.",
1236
+ "- Keep old decisions visible. If something is superseded, say so explicitly instead of silently deleting it.",
1237
+ "- Summarize changed files, why they changed, and the recommended next agent action."
1238
+ ].join("\n");
1239
+ }
1240
+ async function writePromptFile(filePath, prompt) {
1241
+ await mkdir4(path4.dirname(filePath), { recursive: true });
1242
+ await writeFile3(filePath, prompt, "utf8");
1243
+ return filePath;
1244
+ }
1245
+
1246
+ // src/local-tool-runner.ts
1247
+ import { spawn } from "node:child_process";
1248
+ import { mkdtemp, rm, writeFile as writeFile4 } from "node:fs/promises";
1249
+ import os2 from "node:os";
1250
+ import path5 from "node:path";
1251
+ var localToolNames = runnerToolNames;
1252
+ var localToolAdapters = [
1253
+ {
1254
+ name: "opencode",
1255
+ description: "opencode SDK adapter using @opencode-ai/sdk, with CLI fallback.",
1256
+ sdkPackageName: "@opencode-ai/sdk",
1257
+ sdkDisplayCommand: "@opencode-ai/sdk createOpencode().client.session.prompt()",
1258
+ sdkRequiresExecutable: true,
1259
+ executable: "opencode",
1260
+ supportsSessionReuse: true,
1261
+ resumabilityScope: "localMachine",
1262
+ runWithSdk: runOpencodeSdk,
1263
+ buildInvocation: ({ prompt }) => ({
1264
+ command: "opencode",
1265
+ args: ["run", prompt],
1266
+ displayCommand: "opencode run <generated prompt>"
1267
+ })
1268
+ },
1269
+ {
1270
+ name: "claude",
1271
+ description: "Claude Agent SDK adapter using @anthropic-ai/claude-agent-sdk, with CLI fallback.",
1272
+ sdkPackageName: "@anthropic-ai/claude-agent-sdk",
1273
+ sdkDisplayCommand: "@anthropic-ai/claude-agent-sdk query()",
1274
+ executable: "claude",
1275
+ supportsSessionReuse: false,
1276
+ resumabilityScope: "none",
1277
+ runWithSdk: runClaudeSdk,
1278
+ buildInvocation: ({ prompt }) => ({
1279
+ command: "claude",
1280
+ args: ["-p", prompt],
1281
+ displayCommand: "claude -p <generated prompt>"
1282
+ })
1283
+ },
1284
+ {
1285
+ name: "codex",
1286
+ description: "Codex SDK adapter using @openai/codex-sdk, with CLI fallback.",
1287
+ sdkPackageName: "@openai/codex-sdk",
1288
+ sdkDisplayCommand: "@openai/codex-sdk Codex.startThread().run()",
1289
+ executable: "codex",
1290
+ supportsSessionReuse: false,
1291
+ resumabilityScope: "none",
1292
+ runWithSdk: runCodexSdk,
1293
+ buildInvocation: ({ prompt }) => ({
1294
+ command: "codex",
1295
+ args: ["exec", prompt],
1296
+ displayCommand: "codex exec <generated prompt>"
1297
+ })
1298
+ },
1299
+ {
1300
+ name: "copilot",
1301
+ description: "GitHub Copilot SDK adapter using @github/copilot-sdk.",
1302
+ sdkPackageName: "@github/copilot-sdk",
1303
+ sdkDisplayCommand: "@github/copilot-sdk CopilotClient.createSession().sendAndWait()",
1304
+ supportsModelSelection: true,
1305
+ supportsSessionReuse: false,
1306
+ resumabilityScope: "none",
1307
+ runWithSdk: runCopilotSdk
1308
+ },
1309
+ {
1310
+ name: "gemini",
1311
+ description: "Gemini CLI adapter using prompt mode.",
1312
+ executable: "gemini",
1313
+ supportsSessionReuse: false,
1314
+ resumabilityScope: "none",
1315
+ buildInvocation: ({ prompt }) => ({
1316
+ command: "gemini",
1317
+ args: ["-p", prompt],
1318
+ displayCommand: "gemini -p <generated prompt>"
1319
+ })
1320
+ },
1321
+ {
1322
+ name: "aider",
1323
+ description: "Aider CLI adapter using a one-shot message.",
1324
+ executable: "aider",
1325
+ supportsSessionReuse: false,
1326
+ resumabilityScope: "none",
1327
+ buildInvocation: ({ prompt }) => ({
1328
+ command: "aider",
1329
+ args: ["--yes", "--message", prompt],
1330
+ displayCommand: "aider --yes --message <generated prompt>"
1331
+ })
1332
+ },
1333
+ {
1334
+ name: "cursor-agent",
1335
+ description: "Cursor agent CLI adapter using prompt mode when installed.",
1336
+ executable: "cursor-agent",
1337
+ supportsSessionReuse: false,
1338
+ resumabilityScope: "none",
1339
+ buildInvocation: ({ prompt }) => ({
1340
+ command: "cursor-agent",
1341
+ args: ["-p", prompt],
1342
+ displayCommand: "cursor-agent -p <generated prompt>"
1343
+ })
1344
+ }
1345
+ ];
1346
+ function isLocalToolName(value) {
1347
+ return localToolNames.includes(value);
1348
+ }
1349
+ async function detectLocalTools() {
1350
+ return Promise.all(
1351
+ localToolAdapters.map(async (adapter) => {
1352
+ const sdkAvailable = await isSdkAvailable(adapter);
1353
+ const commandAvailable = adapter.executable ? await commandExists(adapter.executable) : false;
1354
+ return {
1355
+ name: adapter.name,
1356
+ description: adapter.description,
1357
+ available: sdkAvailable || commandAvailable,
1358
+ sdkAvailable,
1359
+ commandAvailable,
1360
+ execution: sdkAvailable ? "sdk" : commandAvailable ? "command" : "unavailable",
1361
+ supportsSessionReuse: Boolean(adapter.supportsSessionReuse),
1362
+ resumabilityScope: adapter.resumabilityScope ?? "none",
1363
+ supportsModelSelection: Boolean(adapter.supportsModelSelection)
1364
+ };
1365
+ })
1366
+ );
1367
+ }
1368
+ async function runLocalTool(options) {
1369
+ const promptTempDir = await mkdtemp(path5.join(os2.tmpdir(), "amistio-prompt-"));
1370
+ const promptFilePath = path5.join(promptTempDir, "prompt.md");
1371
+ await writeFile4(promptFilePath, options.prompt, "utf8");
1372
+ try {
1373
+ const runnerOptions = {
1374
+ rootDir: options.rootDir,
1375
+ prompt: options.prompt,
1376
+ promptFilePath,
1377
+ tool: options.tool ?? "auto",
1378
+ ...options.model ? { model: options.model } : {}
1379
+ };
1380
+ if (options.toolCommand) {
1381
+ runnerOptions.toolCommand = options.toolCommand;
1382
+ }
1383
+ const runner2 = await createToolRunner(runnerOptions);
1384
+ const result = await executeToolRunner(runner2, {
1385
+ rootDir: options.rootDir,
1386
+ prompt: options.prompt,
1387
+ promptFilePath,
1388
+ streamOutput: Boolean(options.streamOutput),
1389
+ ...options.session ? { session: options.session } : {}
304
1390
  });
305
- if (!options.stream && result.stdout.trim()) {
306
- console.log(result.stdout.trim());
1391
+ return {
1392
+ toolName: runner2.toolName,
1393
+ displayCommand: runner2.kind === "sdk" ? runner2.displayCommand : runner2.invocation.displayCommand,
1394
+ supportsSessionReuse: runner2.kind === "sdk" ? Boolean(runner2.adapter.supportsSessionReuse) : false,
1395
+ resumabilityScope: runner2.kind === "sdk" ? runner2.adapter.resumabilityScope ?? "none" : "none",
1396
+ ...options.model ? { model: options.model } : {},
1397
+ ...result
1398
+ };
1399
+ } finally {
1400
+ await rm(promptTempDir, { recursive: true, force: true });
1401
+ }
1402
+ }
1403
+ async function createToolRunPreview(options) {
1404
+ const promptFilePath = path5.join(os2.tmpdir(), "amistio-generated-prompt.md");
1405
+ const runnerOptions = {
1406
+ rootDir: options.rootDir,
1407
+ prompt: options.prompt,
1408
+ promptFilePath,
1409
+ tool: options.tool ?? "auto",
1410
+ ...options.model ? { model: options.model } : {}
1411
+ };
1412
+ if (options.toolCommand) {
1413
+ runnerOptions.toolCommand = options.toolCommand;
1414
+ }
1415
+ const runner2 = await createToolRunner(runnerOptions);
1416
+ return {
1417
+ toolName: runner2.toolName,
1418
+ displayCommand: runner2.kind === "sdk" ? runner2.displayCommand : runner2.invocation.displayCommand,
1419
+ supportsSessionReuse: runner2.kind === "sdk" ? Boolean(runner2.adapter.supportsSessionReuse) : false,
1420
+ resumabilityScope: runner2.kind === "sdk" ? runner2.adapter.resumabilityScope ?? "none" : "none",
1421
+ ...options.model ? { model: options.model } : {}
1422
+ };
1423
+ }
1424
+ function createCustomToolInvocation(commandTemplate, input) {
1425
+ const displayCommand = commandTemplate.replaceAll("{promptFile}", shellQuote(input.promptFilePath)).replaceAll("{root}", shellQuote(input.rootDir));
1426
+ return {
1427
+ command: displayCommand,
1428
+ args: [],
1429
+ displayCommand,
1430
+ shell: true,
1431
+ ...commandTemplate.includes("{promptFile}") ? {} : { stdin: input.prompt }
1432
+ };
1433
+ }
1434
+ async function createToolRunner(options) {
1435
+ if (options.toolCommand) {
1436
+ return {
1437
+ toolName: "custom",
1438
+ kind: "command",
1439
+ invocation: createCustomToolInvocation(options.toolCommand, options)
1440
+ };
1441
+ }
1442
+ const tool = normalizeToolRequest(options.tool);
1443
+ if (tool === "none") {
1444
+ throw new Error("No local tool selected. Use --tool auto, a supported tool name, or --tool-command.");
1445
+ }
1446
+ const adapter = tool === "auto" ? await selectFirstAvailableAdapter(Boolean(options.model)) : await selectRequestedAdapter(tool);
1447
+ if (options.model && !adapter.supportsModelSelection) {
1448
+ throw new Error(`Model selection is not supported by ${adapter.name}. Remove --model or choose a model-aware adapter.`);
1449
+ }
1450
+ if (adapter.runWithSdk && await isSdkAvailable(adapter)) {
1451
+ return {
1452
+ toolName: adapter.name,
1453
+ kind: "sdk",
1454
+ displayCommand: adapter.sdkDisplayCommand ?? `${adapter.name} SDK`,
1455
+ adapter
1456
+ };
1457
+ }
1458
+ if (adapter.buildInvocation && adapter.executable && await commandExists(adapter.executable)) {
1459
+ return {
1460
+ toolName: adapter.name,
1461
+ kind: "command",
1462
+ invocation: adapter.buildInvocation(options)
1463
+ };
1464
+ }
1465
+ throw new Error(`The ${adapter.name} SDK or executable was not found. Install the SDK/runtime or pass --tool-command.`);
1466
+ }
1467
+ async function executeToolRunner(runner2, input) {
1468
+ if (runner2.kind === "command") {
1469
+ return executeToolInvocation(runner2.invocation, input.rootDir, input.streamOutput);
1470
+ }
1471
+ try {
1472
+ return await runner2.adapter.runWithSdk(input);
1473
+ } catch (error) {
1474
+ if (runner2.adapter.buildInvocation && runner2.adapter.executable && await commandExists(runner2.adapter.executable)) {
1475
+ const fallback = runner2.adapter.buildInvocation(input);
1476
+ const result = await executeToolInvocation(fallback, input.rootDir, input.streamOutput);
1477
+ const sdkFailure = `SDK execution for ${runner2.adapter.name} failed, fell back to ${fallback.displayCommand}: ${errorMessage(error)}`;
1478
+ return {
1479
+ ...result,
1480
+ stderr: result.stderr ? `${sdkFailure}
1481
+ ${result.stderr}` : sdkFailure
1482
+ };
307
1483
  }
308
- if (!options.stream && result.stderr.trim()) {
309
- console.error(result.stderr.trim());
1484
+ throw error;
1485
+ }
1486
+ }
1487
+ function normalizeToolRequest(value) {
1488
+ if (value === "auto" || value === "none" || isLocalToolName(value)) {
1489
+ return value;
1490
+ }
1491
+ throw new Error(`Unsupported local tool: ${value}. Supported tools: auto, none, ${localToolNames.join(", ")}.`);
1492
+ }
1493
+ async function selectFirstAvailableAdapter(requiresModelSelection = false) {
1494
+ for (const adapter of localToolAdapters) {
1495
+ if (requiresModelSelection && !adapter.supportsModelSelection) {
1496
+ continue;
310
1497
  }
311
- if (result.exitCode !== 0) {
312
- process.exitCode = result.exitCode;
1498
+ const sdkAvailable = await isSdkAvailable(adapter);
1499
+ const commandAvailable = adapter.executable ? await commandExists(adapter.executable) : false;
1500
+ if (sdkAvailable || commandAvailable) {
1501
+ return adapter;
313
1502
  }
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
1503
+ }
1504
+ throw new Error(
1505
+ requiresModelSelection ? "No installed local AI tool supports model selection. Remove --model or choose a model-aware adapter." : `No supported local AI tool was found. Install one of ${localToolNames.join(", ")} or pass --tool-command "your-tool --prompt-file {promptFile}".`
1506
+ );
1507
+ }
1508
+ async function selectRequestedAdapter(tool) {
1509
+ const adapter = localToolAdapters.find((candidate) => candidate.name === tool);
1510
+ if (!adapter) {
1511
+ throw new Error(`Unsupported local tool: ${tool}.`);
1512
+ }
1513
+ if (await isSdkAvailable(adapter)) {
1514
+ return adapter;
1515
+ }
1516
+ if (adapter.executable && await commandExists(adapter.executable)) {
1517
+ return adapter;
1518
+ }
1519
+ throw new Error(`The ${tool} SDK or executable was not found. Install it or pass --tool-command.`);
1520
+ }
1521
+ async function isSdkAvailable(adapter) {
1522
+ if (!adapter.sdkPackageName || !adapter.runWithSdk) {
1523
+ return false;
1524
+ }
1525
+ if (adapter.sdkRequiresExecutable && adapter.executable && !await commandExists(adapter.executable)) {
1526
+ return false;
1527
+ }
1528
+ return packageAvailable(adapter.sdkPackageName);
1529
+ }
1530
+ async function packageAvailable(packageName) {
1531
+ try {
1532
+ await import(packageName);
1533
+ return true;
1534
+ } catch {
1535
+ return false;
1536
+ }
1537
+ }
1538
+ async function commandExists(command) {
1539
+ const lookupCommand = process.platform === "win32" ? "where" : "which";
1540
+ return new Promise((resolve) => {
1541
+ const lookup = spawn(lookupCommand, [command], { stdio: "ignore" });
1542
+ lookup.on("error", () => resolve(false));
1543
+ lookup.on("close", (exitCode) => resolve(exitCode === 0));
1544
+ });
1545
+ }
1546
+ async function executeToolInvocation(invocation, rootDir, streamOutput) {
1547
+ return new Promise((resolve, reject) => {
1548
+ const child = spawn(invocation.command, invocation.args, {
1549
+ cwd: rootDir,
1550
+ env: process.env,
1551
+ shell: invocation.shell ?? false,
1552
+ stdio: ["pipe", "pipe", "pipe"]
399
1553
  });
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;
1554
+ let stdout = "";
1555
+ let stderr = "";
1556
+ child.on("error", reject);
1557
+ child.stdout.setEncoding("utf8");
1558
+ child.stderr.setEncoding("utf8");
1559
+ child.stdout.on("data", (chunk) => {
1560
+ stdout += chunk;
1561
+ if (streamOutput) {
1562
+ process.stdout.write(chunk);
1563
+ }
426
1564
  });
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 } : {})
1565
+ child.stderr.on("data", (chunk) => {
1566
+ stderr += chunk;
1567
+ if (streamOutput) {
1568
+ process.stderr.write(chunk);
1569
+ }
464
1570
  });
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 } : {})
1571
+ child.stdin.on("error", () => void 0);
1572
+ if (invocation.stdin) {
1573
+ child.stdin.write(invocation.stdin);
1574
+ }
1575
+ child.stdin.end();
1576
+ child.on("close", (exitCode) => {
1577
+ resolve({ exitCode: exitCode ?? 1, stdout, stderr });
478
1578
  });
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}`);
1579
+ });
1580
+ }
1581
+ async function runOpencodeSdk(input) {
1582
+ const { createOpencode } = await import("@opencode-ai/sdk");
1583
+ const previousDirectory = process.cwd();
1584
+ process.chdir(input.rootDir);
1585
+ try {
1586
+ const opencode = await createOpencode({ timeout: 1e4 });
1587
+ try {
1588
+ let providerSessionId = input.session?.providerSessionId;
1589
+ if (!providerSessionId) {
1590
+ const sessionResult = await opencode.client.session.create({
1591
+ query: { directory: input.rootDir },
1592
+ body: { title: "Amistio orchestration" }
1593
+ });
1594
+ if (sessionResult.error || !sessionResult.data) {
1595
+ throw new Error(`opencode session create failed: ${JSON.stringify(sessionResult.error ?? "missing data")}`);
490
1596
  }
491
- catch (error) {
492
- generationError = errorMessage(error);
1597
+ providerSessionId = sessionResult.data.id;
1598
+ }
1599
+ const promptResult = await opencode.client.session.prompt({
1600
+ path: { id: providerSessionId },
1601
+ query: { directory: input.rootDir },
1602
+ body: { parts: [{ type: "text", text: input.prompt }] }
1603
+ });
1604
+ if (promptResult.error || !promptResult.data) {
1605
+ throw new Error(`opencode prompt failed: ${JSON.stringify(promptResult.error ?? "missing data")}`);
1606
+ }
1607
+ const stdout = extractTextParts(promptResult.data.parts);
1608
+ if (input.streamOutput && stdout) {
1609
+ process.stdout.write(stdout);
1610
+ }
1611
+ return { exitCode: 0, stdout, stderr: "", providerSessionId, messageCount: 1 };
1612
+ } finally {
1613
+ opencode.server.close();
1614
+ }
1615
+ } finally {
1616
+ process.chdir(previousDirectory);
1617
+ }
1618
+ }
1619
+ async function runClaudeSdk(input) {
1620
+ const { query } = await import("@anthropic-ai/claude-agent-sdk");
1621
+ let stdout = "";
1622
+ let stderr = "";
1623
+ let exitCode = 0;
1624
+ for await (const message of query({
1625
+ prompt: input.prompt,
1626
+ options: {
1627
+ cwd: input.rootDir,
1628
+ permissionMode: "default",
1629
+ tools: { type: "preset", preset: "claude_code" },
1630
+ env: {
1631
+ ...process.env,
1632
+ CLAUDE_AGENT_SDK_CLIENT_APP: "amistio-cli/0.1.0"
1633
+ }
1634
+ }
1635
+ })) {
1636
+ if (isRecord(message) && message.type === "result") {
1637
+ if (message.subtype === "success" && typeof message.result === "string") {
1638
+ stdout += message.result;
1639
+ if (input.streamOutput) {
1640
+ process.stdout.write(message.result);
493
1641
  }
1642
+ } else {
1643
+ exitCode = 1;
1644
+ const resultMessage = message;
1645
+ const errors = Array.isArray(resultMessage.errors) ? resultMessage.errors.map(String).join("\n") : "Claude Agent SDK execution failed.";
1646
+ stderr += errors;
1647
+ }
494
1648
  }
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 } : {})
1649
+ }
1650
+ return { exitCode, stdout, stderr };
1651
+ }
1652
+ async function runCodexSdk(input) {
1653
+ const { Codex } = await import("@openai/codex-sdk");
1654
+ const codex = new Codex();
1655
+ const thread = codex.startThread({
1656
+ workingDirectory: input.rootDir,
1657
+ sandboxMode: "workspace-write",
1658
+ approvalPolicy: "on-request",
1659
+ skipGitRepoCheck: true
1660
+ });
1661
+ const result = await thread.run(input.prompt);
1662
+ if (input.streamOutput && result.finalResponse) {
1663
+ process.stdout.write(result.finalResponse);
1664
+ }
1665
+ return { exitCode: 0, stdout: result.finalResponse, stderr: "" };
1666
+ }
1667
+ async function runCopilotSdk(input) {
1668
+ const { CopilotClient, approveAll } = await import("@github/copilot-sdk");
1669
+ const client = new CopilotClient({
1670
+ cwd: input.rootDir,
1671
+ logLevel: "error"
1672
+ });
1673
+ try {
1674
+ await client.start();
1675
+ const session = await client.createSession({
1676
+ clientName: "amistio-cli",
1677
+ model: input.model ?? process.env.AMISTIO_COPILOT_MODEL ?? "gpt-5",
1678
+ workingDirectory: input.rootDir,
1679
+ enableConfigDiscovery: true,
1680
+ streaming: input.streamOutput,
1681
+ onPermissionRequest: approveAll
511
1682
  });
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 } : {})
1683
+ try {
1684
+ let streamedOutput = "";
1685
+ const unsubscribe = input.streamOutput ? session.on("assistant.message_delta", (event) => {
1686
+ streamedOutput += event.data.deltaContent;
1687
+ process.stdout.write(event.data.deltaContent);
1688
+ }) : void 0;
1689
+ try {
1690
+ const response = await session.sendAndWait({ prompt: input.prompt }, 10 * 60 * 1e3);
1691
+ const stdout = response?.data.content ?? streamedOutput;
1692
+ return { exitCode: 0, stdout, stderr: "" };
1693
+ } finally {
1694
+ unsubscribe?.();
1695
+ }
1696
+ } finally {
1697
+ await session.disconnect();
1698
+ }
1699
+ } finally {
1700
+ await client.stop();
1701
+ }
1702
+ }
1703
+ function extractTextParts(parts) {
1704
+ if (!Array.isArray(parts)) {
1705
+ return "";
1706
+ }
1707
+ return parts.filter(isRecord).map((part) => part.type === "text" && typeof part.text === "string" ? part.text : "").filter(Boolean).join("\n");
1708
+ }
1709
+ function isRecord(value) {
1710
+ return typeof value === "object" && value !== null;
1711
+ }
1712
+ function errorMessage(error) {
1713
+ return error instanceof Error ? error.message : String(error);
1714
+ }
1715
+ function shellQuote(value) {
1716
+ return `'${value.replaceAll("'", "'\\''")}'`;
1717
+ }
1718
+
1719
+ // src/runner-daemon.ts
1720
+ import { spawn as spawn2 } from "node:child_process";
1721
+ import { createHash as createHash2 } from "node:crypto";
1722
+ import { openSync } from "node:fs";
1723
+ import { mkdir as mkdir5, readdir as readdir2, readFile as readFile3, writeFile as writeFile5 } from "node:fs/promises";
1724
+ import os3 from "node:os";
1725
+ import path6 from "node:path";
1726
+ function currentRunnerMode() {
1727
+ return process.env.AMISTIO_RUNNER_MODE === "background" ? "background" : "foreground";
1728
+ }
1729
+ function defaultRunnerMetadataDir() {
1730
+ return path6.join(os3.homedir(), ".config", "amistio", "runners");
1731
+ }
1732
+ async function startRunnerDaemon(input) {
1733
+ const metadataDir = input.metadataDir ?? defaultRunnerMetadataDir();
1734
+ const existing = await readRunnerDaemonMetadata(input, metadataDir);
1735
+ if (existing?.status === "running" && isProcessRunning(existing.pid)) {
1736
+ throw new Error(`Background runner ${existing.runnerId} is already running with PID ${existing.pid}.`);
1737
+ }
1738
+ await mkdir5(metadataDir, { recursive: true });
1739
+ const logPath = path6.join(metadataDir, `${runnerDaemonKey(input)}.log`);
1740
+ const logFd = openSync(logPath, "a");
1741
+ const child = spawn2(input.executablePath ?? process.execPath, [input.scriptPath ?? process.argv[1], ...input.args], {
1742
+ cwd: input.rootDir,
1743
+ detached: true,
1744
+ env: {
1745
+ ...process.env,
1746
+ AMISTIO_RUNNER_MODE: "background"
1747
+ },
1748
+ stdio: ["ignore", logFd, logFd]
1749
+ });
1750
+ if (!child.pid) {
1751
+ throw new Error("Failed to start background runner process.");
1752
+ }
1753
+ child.unref();
1754
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1755
+ const metadata = {
1756
+ schemaVersion: 1,
1757
+ accountId: input.accountId,
1758
+ projectId: input.projectId,
1759
+ repositoryLinkId: input.repositoryLinkId,
1760
+ runnerId: input.runnerId,
1761
+ rootDir: path6.resolve(input.rootDir),
1762
+ apiUrl: input.apiUrl,
1763
+ pid: child.pid,
1764
+ status: "running",
1765
+ startedAt: now,
1766
+ updatedAt: now,
1767
+ hostname: os3.hostname(),
1768
+ logPath
1769
+ };
1770
+ await writeRunnerDaemonMetadata(metadata, metadataDir);
1771
+ return metadata;
1772
+ }
1773
+ async function restartRunnerDaemonProcess(metadata, args, input = {}) {
1774
+ const metadataDir = input.metadataDir ?? defaultRunnerMetadataDir();
1775
+ await mkdir5(metadataDir, { recursive: true });
1776
+ const logPath = metadata.logPath ?? path6.join(metadataDir, `${runnerDaemonKey(metadata)}.log`);
1777
+ const logFd = openSync(logPath, "a");
1778
+ const child = spawn2(input.executablePath ?? process.execPath, [input.scriptPath ?? process.argv[1], ...args], {
1779
+ cwd: metadata.rootDir,
1780
+ detached: true,
1781
+ env: {
1782
+ ...process.env,
1783
+ AMISTIO_RUNNER_MODE: "background"
1784
+ },
1785
+ stdio: ["ignore", logFd, logFd]
1786
+ });
1787
+ if (!child.pid) {
1788
+ throw new Error("Failed to start replacement background runner process.");
1789
+ }
1790
+ child.unref();
1791
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1792
+ const replacement = {
1793
+ ...metadata,
1794
+ pid: child.pid,
1795
+ status: "running",
1796
+ startedAt: now,
1797
+ updatedAt: now,
1798
+ hostname: os3.hostname(),
1799
+ logPath
1800
+ };
1801
+ await writeRunnerDaemonMetadata(replacement, metadataDir);
1802
+ return replacement;
1803
+ }
1804
+ async function listRunnerDaemonMetadata(input, metadataDir = defaultRunnerMetadataDir()) {
1805
+ let entries;
1806
+ try {
1807
+ entries = await readdir2(metadataDir);
1808
+ } catch {
1809
+ return [];
1810
+ }
1811
+ const records = await Promise.all(
1812
+ entries.filter((entry) => entry.endsWith(".json")).map(async (entry) => readRunnerDaemonMetadataFile(path6.join(metadataDir, entry)))
1813
+ );
1814
+ return records.filter((record) => Boolean(record)).filter((record) => record.accountId === input.accountId && record.projectId === input.projectId && record.repositoryLinkId === input.repositoryLinkId).filter((record) => !input.runnerId || record.runnerId === input.runnerId).sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
1815
+ }
1816
+ async function readRunnerDaemonMetadata(input, metadataDir = defaultRunnerMetadataDir()) {
1817
+ return readRunnerDaemonMetadataFile(runnerDaemonMetadataPath(input, metadataDir));
1818
+ }
1819
+ async function writeRunnerDaemonMetadata(metadata, metadataDir = defaultRunnerMetadataDir()) {
1820
+ await mkdir5(metadataDir, { recursive: true });
1821
+ await writeFile5(runnerDaemonMetadataPath(metadata, metadataDir), JSON.stringify(metadata, null, 2), { encoding: "utf8", mode: 384 });
1822
+ }
1823
+ async function markRunnerDaemonStopped(metadata, metadataDir = defaultRunnerMetadataDir()) {
1824
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1825
+ const stopped = {
1826
+ ...metadata,
1827
+ status: "stopped",
1828
+ stoppedAt: now,
1829
+ updatedAt: now
1830
+ };
1831
+ await writeRunnerDaemonMetadata(stopped, metadataDir);
1832
+ return stopped;
1833
+ }
1834
+ async function stopRunnerDaemonProcess(metadata) {
1835
+ if (!isProcessRunning(metadata.pid)) {
1836
+ return "not-running";
1837
+ }
1838
+ process.kill(metadata.pid, "SIGTERM");
1839
+ for (let attempt = 0; attempt < 30; attempt += 1) {
1840
+ if (!isProcessRunning(metadata.pid)) {
1841
+ return "stopped";
1842
+ }
1843
+ await new Promise((resolve) => setTimeout(resolve, 100));
1844
+ }
1845
+ return isProcessRunning(metadata.pid) ? "not-running" : "stopped";
1846
+ }
1847
+ function isProcessRunning(pid) {
1848
+ if (!Number.isInteger(pid) || pid <= 0) {
1849
+ return false;
1850
+ }
1851
+ try {
1852
+ process.kill(pid, 0);
1853
+ return true;
1854
+ } catch (error) {
1855
+ const code = typeof error === "object" && error && "code" in error ? error.code : void 0;
1856
+ return code === "EPERM";
1857
+ }
1858
+ }
1859
+ function runnerDaemonRuntimeStatus(metadata) {
1860
+ if (metadata.status === "stopped") {
1861
+ return "stopped";
1862
+ }
1863
+ return isProcessRunning(metadata.pid) ? "running" : "stale";
1864
+ }
1865
+ function runnerDaemonUptime(metadata, now = Date.now()) {
1866
+ const startedAt = Date.parse(metadata.startedAt);
1867
+ if (!Number.isFinite(startedAt)) {
1868
+ return "unknown";
1869
+ }
1870
+ const totalSeconds = Math.max(0, Math.floor((now - startedAt) / 1e3));
1871
+ const hours = Math.floor(totalSeconds / 3600);
1872
+ const minutes = Math.floor(totalSeconds % 3600 / 60);
1873
+ const seconds = totalSeconds % 60;
1874
+ if (hours > 0) {
1875
+ return `${hours}h ${minutes}m`;
1876
+ }
1877
+ if (minutes > 0) {
1878
+ return `${minutes}m ${seconds}s`;
1879
+ }
1880
+ return `${seconds}s`;
1881
+ }
1882
+ function runnerDaemonMetadataPath(input, metadataDir) {
1883
+ return path6.join(metadataDir, `${runnerDaemonKey(input)}.json`);
1884
+ }
1885
+ function runnerDaemonKey(input) {
1886
+ return createHash2("sha256").update(`${input.accountId}:${input.projectId}:${input.repositoryLinkId}:${input.runnerId}`).digest("hex");
1887
+ }
1888
+ async function readRunnerDaemonMetadataFile(filePath) {
1889
+ try {
1890
+ const parsed = JSON.parse(await readFile3(filePath, "utf8"));
1891
+ if (parsed.schemaVersion !== 1 || !parsed.runnerId || !parsed.projectId || !parsed.repositoryLinkId) {
1892
+ return void 0;
1893
+ }
1894
+ return parsed;
1895
+ } catch {
1896
+ return void 0;
1897
+ }
1898
+ }
1899
+
1900
+ // src/session-policy.ts
1901
+ var maxIdleMs = 24 * 60 * 60 * 1e3;
1902
+ var maxTotalMs = 7 * 24 * 60 * 60 * 1e3;
1903
+ var maxMessageCount = 80;
1904
+ var maxEstimatedTokens = 12e4;
1905
+ var maxCostUsd = 25;
1906
+ var relatednessThreshold = 20;
1907
+ function normalizeSessionPolicy(value) {
1908
+ if (!value || value === "auto" || value === "new" || value === "none") {
1909
+ return value ?? "auto";
1910
+ }
1911
+ if (/^continue:[A-Za-z0-9_.:-]+$/.test(value)) {
1912
+ return value;
1913
+ }
1914
+ throw new Error(`Unsupported session policy: ${value}. Use auto, new, continue:<toolSessionId>, or none.`);
1915
+ }
1916
+ function selectToolSession(input) {
1917
+ if (input.policy === "none") {
1918
+ return { policy: input.policy, decision: "skipped", reason: "Session reuse was disabled for this run." };
1919
+ }
1920
+ if (!input.supportsSessionReuse) {
1921
+ return {
1922
+ policy: input.policy,
1923
+ decision: "notSupported",
1924
+ reason: `${input.toolName} does not expose reusable provider sessions; Amistio will record this as a one-shot tool session.`
518
1925
  };
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 } : {})
1926
+ }
1927
+ if (input.policy === "new") {
1928
+ return { policy: input.policy, decision: "forcedNew", reason: "The user requested a fresh tool session." };
1929
+ }
1930
+ const forcedSessionId = forcedContinueSessionId(input.policy);
1931
+ if (forcedSessionId) {
1932
+ const forced = input.sessions.find((session) => session.toolSessionId === forcedSessionId);
1933
+ if (!forced) {
1934
+ return { policy: input.policy, decision: "forcedNew", reason: `Requested session ${forcedSessionId} was not found; creating a fresh session.` };
1935
+ }
1936
+ const ineligibleReason = sessionIneligibleReason(forced, input, input.now ?? /* @__PURE__ */ new Date());
1937
+ if (ineligibleReason) {
1938
+ return { policy: input.policy, decision: "forcedNew", reason: `Requested session ${forcedSessionId} cannot be resumed: ${ineligibleReason}` };
1939
+ }
1940
+ return { policy: input.policy, decision: "forcedContinue", reason: `The user requested session ${forcedSessionId}.`, toolSession: forced };
1941
+ }
1942
+ const now = input.now ?? /* @__PURE__ */ new Date();
1943
+ const candidates = input.sessions.map((session) => ({ session, ineligibleReason: sessionIneligibleReason(session, input, now), score: relatednessScore(session, input.workItem) })).filter((candidate) => !candidate.ineligibleReason).sort((a, b) => b.score - a.score || Date.parse(b.session.lastActivityAt) - Date.parse(a.session.lastActivityAt));
1944
+ const best = candidates[0];
1945
+ if (!best || best.score < relatednessThreshold) {
1946
+ return {
1947
+ policy: input.policy,
1948
+ decision: "created",
1949
+ reason: best ? `Best related session score ${best.score} was below ${relatednessThreshold}; creating a fresh session.` : "No eligible related session was found."
1950
+ };
1951
+ }
1952
+ return {
1953
+ policy: input.policy,
1954
+ decision: "continued",
1955
+ reason: `Reusing ${best.session.toolSessionId}; relatedness score ${best.score}.`,
1956
+ toolSession: best.session
1957
+ };
1958
+ }
1959
+ function forcedContinueSessionId(policy) {
1960
+ return typeof policy === "string" && policy.startsWith("continue:") ? policy.slice("continue:".length) : void 0;
1961
+ }
1962
+ function sessionIneligibleReason(session, input, now) {
1963
+ if (session.tool !== input.toolName) {
1964
+ return `tool mismatch (${session.tool} != ${input.toolName})`;
1965
+ }
1966
+ if (session.repositoryLinkId && session.repositoryLinkId !== input.repositoryLinkId) {
1967
+ return "repository link mismatch";
1968
+ }
1969
+ if (session.resumabilityScope === "localMachine" && session.runnerId && session.runnerId !== input.runnerId) {
1970
+ return "session is scoped to another runner machine";
1971
+ }
1972
+ if (session.status === "closed" || session.status === "archived" || session.status === "blocked" || session.status === "unavailable") {
1973
+ return `session is ${session.status}`;
1974
+ }
1975
+ if (Date.parse(session.lastActivityAt) + maxIdleMs < now.getTime()) {
1976
+ return "session is idle past the reuse window";
1977
+ }
1978
+ if (Date.parse(session.createdAt) + maxTotalMs < now.getTime()) {
1979
+ return "session is older than the reuse window";
1980
+ }
1981
+ if ((session.messageCount ?? 0) >= maxMessageCount) {
1982
+ return "session message count is too high";
1983
+ }
1984
+ if ((session.estimatedInputTokens ?? 0) + (session.estimatedOutputTokens ?? 0) >= maxEstimatedTokens) {
1985
+ return "session token estimate is too high";
1986
+ }
1987
+ if ((session.costUsd ?? 0) >= maxCostUsd) {
1988
+ return "session cost is too high";
1989
+ }
1990
+ return void 0;
1991
+ }
1992
+ function relatednessScore(session, workItem) {
1993
+ let score = 0;
1994
+ if (session.sessionGroupKey && workItem.sessionGroupKey && session.sessionGroupKey === workItem.sessionGroupKey) {
1995
+ score += 60;
1996
+ }
1997
+ if (session.lastWorkItemId && session.lastWorkItemId === workItem.workItemId) {
1998
+ score += 40;
1999
+ }
2000
+ const titleOverlap = tokenOverlap(session.title, workItem.title);
2001
+ score += titleOverlap * 8;
2002
+ if (session.summary) {
2003
+ score += tokenOverlap(session.summary, workItem.title) * 4;
2004
+ }
2005
+ for (const tag of session.tags) {
2006
+ if (workItem.title.toLowerCase().includes(tag.toLowerCase())) {
2007
+ score += 10;
2008
+ }
2009
+ }
2010
+ return score;
2011
+ }
2012
+ function tokenOverlap(firstValue, secondValue) {
2013
+ const firstTokens = new Set(tokens(firstValue));
2014
+ return tokens(secondValue).filter((token) => firstTokens.has(token)).length;
2015
+ }
2016
+ function tokens(value) {
2017
+ return value.toLowerCase().split(/[^a-z0-9]+/).filter((token) => token.length >= 4);
2018
+ }
2019
+
2020
+ // src/sync.ts
2021
+ import { mkdir as mkdir6, readdir as readdir3, readFile as readFile4, stat as stat3, writeFile as writeFile6 } from "node:fs/promises";
2022
+ import path7 from "node:path";
2023
+ var syncRoots = ["architecture", "context", "decisions", "features", "memory", "plans", "prompts", "workflows"];
2024
+ async function collectSyncStatus(rootDir, webDocuments = []) {
2025
+ const localDocuments = await readLocalSyncedDocuments(rootDir);
2026
+ const webByDocumentId = new Map(webDocuments.map((document) => [document.documentId, document]));
2027
+ const pullableWebDocuments = webDocuments.filter((document) => document.status === "approved" || document.syncState === "approved" || document.syncState === "synced");
2028
+ const localByDocumentId = new Map(localDocuments.map((document) => [document.frontmatter.amistioDocumentId, document]));
2029
+ const items = [];
2030
+ for (const localDocument of localDocuments) {
2031
+ const webDocument = webByDocumentId.get(localDocument.frontmatter.amistioDocumentId);
2032
+ const localDirty = localDocument.contentHash !== localDocument.frontmatter.amistioContentHash;
2033
+ const webHash = webDocument?.contentHash;
2034
+ const decision = decideSyncState({
2035
+ lastSyncedHash: localDocument.frontmatter.amistioContentHash,
2036
+ repoHash: localDocument.contentHash,
2037
+ ...webHash ? { webHash } : {}
543
2038
  });
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 };
2039
+ if (decision === "conflict") {
2040
+ items.push({
2041
+ repoPath: localDocument.repoPath,
2042
+ documentId: localDocument.frontmatter.amistioDocumentId,
2043
+ status: "conflicted",
2044
+ reason: "web and repository revisions both changed"
2045
+ });
2046
+ } else if (localDirty || decision === "pushRepo") {
2047
+ items.push({
2048
+ repoPath: localDocument.repoPath,
2049
+ documentId: localDocument.frontmatter.amistioDocumentId,
2050
+ status: "dirty",
2051
+ reason: "repository content changed since last synced hash"
2052
+ });
2053
+ } else if (decision === "pullWeb") {
2054
+ items.push({
2055
+ repoPath: localDocument.repoPath,
2056
+ documentId: localDocument.frontmatter.amistioDocumentId,
2057
+ status: "pending",
2058
+ reason: "approved web revision is newer"
2059
+ });
2060
+ } else if (localDocument.frontmatter.status && !["approved", "synced"].includes(localDocument.frontmatter.status)) {
2061
+ items.push({
2062
+ repoPath: localDocument.repoPath,
2063
+ documentId: localDocument.frontmatter.amistioDocumentId,
2064
+ status: "pending",
2065
+ reason: `local document is ${localDocument.frontmatter.status}`
2066
+ });
2067
+ } else {
2068
+ items.push({
2069
+ repoPath: localDocument.repoPath,
2070
+ documentId: localDocument.frontmatter.amistioDocumentId,
2071
+ status: "clean",
2072
+ reason: "repository and web hashes match"
2073
+ });
2074
+ }
2075
+ }
2076
+ for (const webDocument of pullableWebDocuments) {
2077
+ if (!localByDocumentId.has(webDocument.documentId)) {
2078
+ items.push({
2079
+ repoPath: webDocument.repoPath,
2080
+ documentId: webDocument.documentId,
2081
+ status: "pending",
2082
+ reason: "approved web document has not been pulled"
2083
+ });
2084
+ }
2085
+ }
2086
+ const counts = {
2087
+ clean: items.filter((item) => item.status === "clean").length,
2088
+ pending: items.filter((item) => item.status === "pending").length,
2089
+ dirty: items.filter((item) => item.status === "dirty").length,
2090
+ conflicted: items.filter((item) => item.status === "conflicted").length
2091
+ };
2092
+ const status = counts.conflicted ? "conflicted" : counts.dirty ? "dirty" : counts.pending ? "pending" : "clean";
2093
+ return { status, items, counts };
547
2094
  }
548
- async function loadPairedApiContext(root, apiUrl) {
549
- const metadata = await readProjectLink(root);
550
- if (!metadata) {
551
- return undefined;
2095
+ async function readLocalSyncedDocuments(rootDir) {
2096
+ const markdownFiles = await findMarkdownFiles(rootDir);
2097
+ const documents = [];
2098
+ for (const fullPath of markdownFiles) {
2099
+ const raw = await readFile4(fullPath, "utf8");
2100
+ const parsed = parseSyncedMarkdown(raw);
2101
+ if (!parsed) {
2102
+ continue;
2103
+ }
2104
+ documents.push({
2105
+ fullPath,
2106
+ repoPath: toRepoPath(rootDir, fullPath),
2107
+ frontmatter: parsed.frontmatter,
2108
+ content: parsed.content,
2109
+ contentHash: sha256ContentHash(parsed.content)
2110
+ });
2111
+ }
2112
+ return documents;
2113
+ }
2114
+ async function materializeBrainDocuments(rootDir, documents, options = {}) {
2115
+ const result = { written: [], skipped: [], conflicts: [] };
2116
+ for (const inputDocument of documents) {
2117
+ const document = brainDocumentItemSchema.parse(inputDocument);
2118
+ const fullPath = safeRepoPath(rootDir, document.repoPath);
2119
+ if (!isControlPlanePath(document.repoPath)) {
2120
+ result.conflicts.push(`${document.repoPath}: refusing to write outside root control-plane folders`);
2121
+ continue;
2122
+ }
2123
+ const existing = await readExistingSyncedDocument(fullPath);
2124
+ if (existing.exists && !existing.document) {
2125
+ result.conflicts.push(`${document.repoPath}: file exists without Amistio document metadata`);
2126
+ continue;
2127
+ }
2128
+ if (existing.document && existing.document.frontmatter.amistioDocumentId !== document.documentId) {
2129
+ result.conflicts.push(`${document.repoPath}: file belongs to ${existing.document.frontmatter.amistioDocumentId}`);
2130
+ continue;
2131
+ }
2132
+ if (existing.document && existing.document.contentHash !== existing.document.frontmatter.amistioContentHash && !options.allowDirtyOverwrite) {
2133
+ result.conflicts.push(`${document.repoPath}: local edits would be overwritten`);
2134
+ continue;
2135
+ }
2136
+ if (existing.document && existing.document.frontmatter.amistioRevision >= document.revision && existing.document.contentHash === document.contentHash) {
2137
+ result.skipped.push(document.repoPath);
2138
+ continue;
2139
+ }
2140
+ await mkdir6(path7.dirname(fullPath), { recursive: true });
2141
+ await writeFile6(fullPath, createSyncedDocumentMarkdown(document), "utf8");
2142
+ result.written.push(document.repoPath);
2143
+ }
2144
+ return result;
2145
+ }
2146
+ async function collectDirtyDocumentsForPush(rootDir, metadata) {
2147
+ const localDocuments = await readLocalSyncedDocuments(rootDir);
2148
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2149
+ return localDocuments.filter((document) => document.contentHash !== document.frontmatter.amistioContentHash).map((document) => {
2150
+ const documentType = documentTypeSchema.parse(document.frontmatter.amistioDocumentType);
2151
+ return brainDocumentItemSchema.parse({
2152
+ id: document.frontmatter.amistioDocumentId,
2153
+ type: "brainDocument",
2154
+ schemaVersion: 1,
2155
+ accountId: metadata.amistioAccountId,
2156
+ projectId: metadata.amistioProjectId,
2157
+ documentId: document.frontmatter.amistioDocumentId,
2158
+ documentType,
2159
+ title: inferTitle(document.content, document.repoPath),
2160
+ status: "reviewing",
2161
+ repoPath: document.repoPath,
2162
+ content: document.content,
2163
+ contentHash: document.contentHash,
2164
+ frontmatter: parseFrontmatterFromSyncedDocument(document.frontmatter),
2165
+ revision: document.frontmatter.amistioRevision + 1,
2166
+ source: "repo",
2167
+ syncState: "dirtyInRepo",
2168
+ createdAt: now,
2169
+ updatedAt: now
2170
+ });
2171
+ });
2172
+ }
2173
+ function createSyncedDocumentMarkdown(document) {
2174
+ return [
2175
+ "---",
2176
+ `amistioDocumentId: ${document.documentId}`,
2177
+ `amistioDocumentType: ${document.documentType}`,
2178
+ `amistioRevision: ${document.revision}`,
2179
+ `amistioContentHash: ${document.contentHash}`,
2180
+ `status: ${document.status}`,
2181
+ "---",
2182
+ document.content,
2183
+ ""
2184
+ ].join("\n");
2185
+ }
2186
+ function parseSyncedMarkdown(content) {
2187
+ const rawFrontmatter = parseFrontmatter(content);
2188
+ const frontmatter = syncedDocumentFrontmatterSchema.safeParse(rawFrontmatter);
2189
+ if (!frontmatter.success) {
2190
+ return void 0;
2191
+ }
2192
+ const closingMarker = content.indexOf("\n---", 4);
2193
+ if (closingMarker === -1) {
2194
+ return void 0;
2195
+ }
2196
+ const closingLineEnd = content.indexOf("\n", closingMarker + 4);
2197
+ const bodyStart = closingLineEnd === -1 ? content.length : closingLineEnd + 1;
2198
+ return { frontmatter: frontmatter.data, content: content.slice(bodyStart).replace(/\n$/, "") };
2199
+ }
2200
+ async function readExistingSyncedDocument(fullPath) {
2201
+ try {
2202
+ const raw = await readFile4(fullPath, "utf8");
2203
+ const parsed = parseSyncedMarkdown(raw);
2204
+ if (!parsed) {
2205
+ return { exists: true };
552
2206
  }
553
- const token = await new LocalCredentialStore().get(credentialKey(metadata.amistioAccountId, metadata.amistioProjectId, metadata.repositoryLinkId));
554
2207
  return {
555
- metadata,
556
- ...(token ? { token } : {}),
557
- client: new ApiClient({
558
- apiUrl,
559
- accountId: metadata.amistioAccountId,
560
- ...(token ? { token } : {})
561
- })
2208
+ exists: true,
2209
+ document: {
2210
+ fullPath,
2211
+ repoPath: fullPath,
2212
+ frontmatter: parsed.frontmatter,
2213
+ content: parsed.content,
2214
+ contentHash: sha256ContentHash(parsed.content)
2215
+ }
562
2216
  };
2217
+ } catch (error) {
2218
+ if (error.code === "ENOENT") {
2219
+ return { exists: false };
2220
+ }
2221
+ throw error;
2222
+ }
563
2223
  }
564
- function selectPromptWorkItem(workItems, workItemId) {
565
- if (workItemId) {
566
- return workItems.find((item) => item.workItemId === workItemId || item.id === workItemId);
2224
+ async function findMarkdownFiles(rootDir) {
2225
+ const files = [];
2226
+ for (const syncRoot of syncRoots) {
2227
+ const fullRoot = path7.join(rootDir, syncRoot);
2228
+ if (!await exists2(fullRoot)) {
2229
+ continue;
567
2230
  }
568
- return workItems.find((item) => isPromptReadyStatus(item.status));
2231
+ await walkMarkdownFiles(fullRoot, files);
2232
+ }
2233
+ return files;
569
2234
  }
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
2235
+ async function walkMarkdownFiles(directory, files) {
2236
+ for (const entry of await readdir3(directory, { withFileTypes: true })) {
2237
+ const fullPath = path7.join(directory, entry.name);
2238
+ if (entry.isDirectory()) {
2239
+ await walkMarkdownFiles(fullPath, files);
2240
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
2241
+ files.push(fullPath);
2242
+ }
2243
+ }
2244
+ }
2245
+ function safeRepoPath(rootDir, repoPath) {
2246
+ if (path7.isAbsolute(repoPath)) {
2247
+ throw new Error(`Refusing to use absolute repo path: ${repoPath}`);
2248
+ }
2249
+ const normalized = path7.normalize(repoPath);
2250
+ if (normalized === ".." || normalized.startsWith(`..${path7.sep}`)) {
2251
+ throw new Error(`Refusing to use path outside the repository: ${repoPath}`);
2252
+ }
2253
+ const root = path7.resolve(rootDir);
2254
+ const fullPath = path7.resolve(root, normalized);
2255
+ if (!fullPath.startsWith(`${root}${path7.sep}`)) {
2256
+ throw new Error(`Refusing to use path outside the repository: ${repoPath}`);
2257
+ }
2258
+ return fullPath;
2259
+ }
2260
+ function isControlPlanePath(repoPath) {
2261
+ const normalized = path7.normalize(repoPath);
2262
+ return syncRoots.some((syncRoot) => normalized === syncRoot || normalized.startsWith(`${syncRoot}${path7.sep}`));
2263
+ }
2264
+ function toRepoPath(rootDir, fullPath) {
2265
+ return path7.relative(rootDir, fullPath).split(path7.sep).join("/");
2266
+ }
2267
+ function inferTitle(content, repoPath) {
2268
+ const heading = content.split("\n").find((line) => line.startsWith("# "))?.replace(/^#\s+/, "").trim();
2269
+ return heading || path7.basename(repoPath, path7.extname(repoPath));
2270
+ }
2271
+ function parseFrontmatterFromSyncedDocument(frontmatter) {
2272
+ return {
2273
+ amistioDocumentId: frontmatter.amistioDocumentId,
2274
+ amistioDocumentType: frontmatter.amistioDocumentType,
2275
+ amistioRevision: frontmatter.amistioRevision,
2276
+ amistioContentHash: frontmatter.amistioContentHash,
2277
+ ...frontmatter.status ? { status: frontmatter.status } : {}
2278
+ };
2279
+ }
2280
+ async function exists2(filePath) {
2281
+ try {
2282
+ await stat3(filePath);
2283
+ return true;
2284
+ } catch {
2285
+ return false;
2286
+ }
2287
+ }
2288
+
2289
+ // src/tool-session-store.ts
2290
+ import { mkdir as mkdir7, readFile as readFile5, writeFile as writeFile7 } from "node:fs/promises";
2291
+ import os4 from "node:os";
2292
+ import path8 from "node:path";
2293
+ var LocalToolSessionStore = class {
2294
+ constructor(filePath = defaultSessionStorePath()) {
2295
+ this.filePath = filePath;
2296
+ }
2297
+ filePath;
2298
+ async getProviderSessionId(toolSessionId, toolName) {
2299
+ const data = await this.read();
2300
+ const record = data[toolSessionId];
2301
+ return record?.toolName === toolName ? record.providerSessionId : void 0;
2302
+ }
2303
+ async setProviderSessionId(toolSessionId, toolName, providerSessionId) {
2304
+ const data = await this.read();
2305
+ data[toolSessionId] = { toolName, providerSessionId, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
2306
+ await mkdir7(path8.dirname(this.filePath), { recursive: true });
2307
+ await writeFile7(this.filePath, JSON.stringify(data, null, 2), "utf8");
2308
+ }
2309
+ async read() {
2310
+ try {
2311
+ return JSON.parse(await readFile5(this.filePath, "utf8"));
2312
+ } catch {
2313
+ return {};
2314
+ }
2315
+ }
2316
+ };
2317
+ function defaultSessionStorePath() {
2318
+ if (process.platform === "darwin") {
2319
+ return path8.join(os4.homedir(), "Library", "Application Support", "Amistio", "tool-sessions.json");
2320
+ }
2321
+ if (process.platform === "win32") {
2322
+ return path8.join(process.env.APPDATA ?? os4.homedir(), "Amistio", "tool-sessions.json");
2323
+ }
2324
+ return path8.join(process.env.XDG_STATE_HOME ?? path8.join(os4.homedir(), ".local", "state"), "amistio", "tool-sessions.json");
2325
+ }
2326
+
2327
+ // src/work-runner.ts
2328
+ var generationResultStart = "AMISTIO_BRAIN_GENERATION_RESULT_START";
2329
+ var generationResultEnd = "AMISTIO_BRAIN_GENERATION_RESULT_END";
2330
+ function createWorkExecutionPrompt(workItem, context) {
2331
+ if (workItem.workKind === "brainGeneration") {
2332
+ return createBrainGenerationPrompt(workItem);
2333
+ }
2334
+ if (workItem.workKind === "planRevision") {
2335
+ return createPlanRevisionPrompt(workItem, context?.planRevision);
2336
+ }
2337
+ return [
2338
+ "# Amistio Work Execution",
2339
+ "",
2340
+ "You are running locally through the Amistio CLI inside the user's repository.",
2341
+ "Execute the approved work item below using the repository's existing orchestration files, instructions, and constraints.",
2342
+ "",
2343
+ "## Work Item",
2344
+ "",
2345
+ `Title: ${workItem.title}`,
2346
+ `Work item ID: ${workItem.workItemId}`,
2347
+ `Project ID: ${workItem.projectId}`,
2348
+ "",
2349
+ "## Rules",
2350
+ "",
2351
+ "- Read AGENTS.md first when it exists.",
2352
+ "- Read the relevant root control-plane files before implementation so the work stays aligned with existing direction.",
2353
+ "- Keep changes focused on this work item.",
2354
+ "- Preserve old decisions, plans, memory, and prompts unless the work item explicitly supersedes them.",
2355
+ "- Do not commit changes.",
2356
+ "- Do not write secrets into the repository.",
2357
+ "- Do not create a repo-local .amistio folder.",
2358
+ "- Run relevant verification commands when feasible and summarize results."
2359
+ ].join("\n");
2360
+ }
2361
+ function createPlanRevisionPrompt(workItem, context) {
2362
+ const messages = context?.messages ?? [];
2363
+ return [
2364
+ "# Amistio Plan Revision",
2365
+ "",
2366
+ "You are running locally through the Amistio CLI inside the user's repository.",
2367
+ "Revise the selected generated plan using the user's conversation. Do not implement product/source code changes in this pass.",
2368
+ "",
2369
+ "## Work Item",
2370
+ "",
2371
+ `Title: ${workItem.title}`,
2372
+ `Work item ID: ${workItem.workItemId}`,
2373
+ `Project ID: ${workItem.projectId}`,
2374
+ `Document ID: ${workItem.reviewDocumentId ?? "unknown"}`,
2375
+ `Document revision: ${workItem.reviewDocumentRevision ?? "unknown"}`,
2376
+ "",
2377
+ "## Current Plan",
2378
+ "",
2379
+ context?.planDocument.content ?? "The current plan document could not be loaded. Explain the blocker in the result summary.",
2380
+ "",
2381
+ "## Conversation",
2382
+ "",
2383
+ messages.length ? messages.map((message) => `- ${message.role} / ${message.intent} / ${message.status} / rev ${message.documentRevision}: ${message.content}`).join("\n") : "No conversation messages were loaded.",
2384
+ "",
2385
+ "## Output Contract",
2386
+ "",
2387
+ "Print exactly one JSON object between the markers below with an artifacts array containing one revised plan artifact.",
2388
+ 'The artifact must use documentType "plan", keep the plan under plans/, and include the complete revised plan content.',
2389
+ "",
2390
+ generationResultStart,
2391
+ '{"artifacts":[{"documentType":"plan","title":"Revised Plan","repoPath":"plans/PLAN-revised.md","content":"# Revised Plan\\n\\n## Goal\\n..."}]}',
2392
+ generationResultEnd,
2393
+ "",
2394
+ "Do not put Markdown fences around the markers. Do not claim implementation is complete."
2395
+ ].join("\n");
2396
+ }
2397
+ function parseBrainGenerationArtifacts(output) {
2398
+ const start = output.indexOf(generationResultStart);
2399
+ const end = output.indexOf(generationResultEnd, start + generationResultStart.length);
2400
+ if (start === -1 || end === -1 || end <= start) {
2401
+ throw new Error("Local AI generation did not return an Amistio brain generation result block.");
2402
+ }
2403
+ const payload = output.slice(start + generationResultStart.length, end).trim();
2404
+ const parsed = JSON.parse(stripJsonFence(payload));
2405
+ return brainGenerationResultSchema.parse(parsed).artifacts;
2406
+ }
2407
+ function createBrainGenerationPrompt(workItem) {
2408
+ const wish = workItem.sourceWish ?? workItem.title;
2409
+ return [
2410
+ "# Amistio Brain Generation",
2411
+ "",
2412
+ "You are running locally through the Amistio CLI inside the user's repository.",
2413
+ "Generate reviewable project-brain artifacts from the submitted wish. Do not implement product/source code changes in this pass.",
2414
+ "",
2415
+ "## Submitted Wish",
2416
+ "",
2417
+ wish,
2418
+ "",
2419
+ "## Work Item",
2420
+ "",
2421
+ `Title: ${workItem.title}`,
2422
+ `Work item ID: ${workItem.workItemId}`,
2423
+ `Project ID: ${workItem.projectId}`,
2424
+ `Generated draft ID: ${workItem.generatedDraftId ?? "unknown"}`,
2425
+ "",
2426
+ "## Read First",
2427
+ "",
2428
+ "- Read AGENTS.md first when it exists.",
2429
+ "- Read relevant files under architecture/, context/, decisions/, features/, memory/, plans/, prompts/, and workflows/ before drafting.",
2430
+ "- Keep source code and secrets local. Do not include source-code payloads, access tokens, API keys, local credential paths, or provider session references in the result.",
2431
+ "",
2432
+ "## Generate Artifacts",
2433
+ "",
2434
+ "Return Markdown artifacts that should enter human review before implementation. Use only these document types and matching repo roots:",
2435
+ "",
2436
+ "- architecture -> architecture/",
2437
+ "- context -> context/",
2438
+ "- decision -> decisions/",
2439
+ "- feature -> features/",
2440
+ "- memory -> memory/",
2441
+ "- plan -> plans/",
2442
+ "- prompt -> prompts/",
2443
+ "- workflow -> workflows/",
2444
+ "",
2445
+ "Each artifact needs documentType, title, repoPath, and content. Include at least a plan and an implementation prompt when implementation work is implied. Use model-agnostic prompt wording.",
2446
+ "",
2447
+ "## Output Contract",
2448
+ "",
2449
+ "Print exactly one JSON object between the markers below. The CLI will submit only this structured result back to Amistio.",
2450
+ "",
2451
+ generationResultStart,
2452
+ '{"artifacts":[{"documentType":"plan","title":"Plan: Example","repoPath":"plans/PLAN-example.md","content":"# Plan: Example\\n\\n## Goal\\n..."}]}',
2453
+ generationResultEnd,
2454
+ "",
2455
+ "Do not put Markdown fences around the markers. Do not claim implementation is complete."
2456
+ ].join("\n");
2457
+ }
2458
+ function stripJsonFence(value) {
2459
+ const trimmed = value.trim();
2460
+ if (!trimmed.startsWith("```")) {
2461
+ return trimmed;
2462
+ }
2463
+ return trimmed.replace(/^```(?:json)?\s*/i, "").replace(/```$/i, "").trim();
2464
+ }
2465
+
2466
+ // src/runner-status.ts
2467
+ var watchStateReminderMs = 60 * 1e3;
2468
+ function formatWatchStartupContext(input) {
2469
+ return [
2470
+ `Runner ${input.runnerId} is watching project ${input.projectId}.`,
2471
+ `Repository link: ${input.repositoryLinkId}`,
2472
+ `API: ${input.apiUrl}`,
2473
+ `Polling interval: ${input.intervalSeconds}s. Press Ctrl+C to stop.`
2474
+ ];
2475
+ }
2476
+ function formatWatchIdleLine(action, intervalSeconds) {
2477
+ return `${formatProjectNextAction(action)} Checking again in ${intervalSeconds}s.`;
2478
+ }
2479
+ function shouldPrintWatchState(action, previous, nowMs, reminderMs = watchStateReminderMs) {
2480
+ const key = watchStateKey(action);
2481
+ return !previous || previous.key !== key || nowMs - previous.printedAtMs >= reminderMs;
2482
+ }
2483
+ function watchStateKey(action) {
2484
+ return [action.kind, action.message, action.workItemId, action.documentId, action.runnerId].filter(Boolean).join(":");
2485
+ }
2486
+
2487
+ // src/index.ts
2488
+ var program = new Command();
2489
+ var defaultRoot = process.env.INIT_CWD ?? process.cwd();
2490
+ var apiUrlOptionDescription = `Amistio API URL override (or ${AMISTIO_API_URL_ENV})`;
2491
+ program.name("amistio").description("Amistio project brain CLI").version("0.1.2");
2492
+ var CLI_VERSION = "0.1.2";
2493
+ program.command("init").description("Create Amistio control-plane folders for a new project").option("--root <path>", "Repository root", defaultRoot).action(async (options) => {
2494
+ const created = await initControlPlane(options.root);
2495
+ console.log(created.length ? `Created ${created.length} control-plane folders.` : "Control-plane folders already exist.");
2496
+ });
2497
+ program.command("onboard").description("Inspect and prepare an existing repository for Amistio").option("--root <path>", "Repository root", defaultRoot).action(async (options) => {
2498
+ const result = await inspectControlPlane(options.root);
2499
+ console.log(`Present: ${result.present.length ? result.present.join(", ") : "none"}`);
2500
+ console.log(`Missing: ${result.missing.length ? result.missing.join(", ") : "none"}`);
2501
+ if (result.missing.length) {
2502
+ console.log("Run `amistio init` to create missing control-plane folders without overwriting existing files.");
2503
+ }
2504
+ });
2505
+ program.command("bootstrap").description("Clone a linked repository locally, prepare the control plane, and pair it with Amistio").requiredOption("--repo-url <url>", "Linked repository clone URL").requiredOption("--target <path>", "Local checkout target path").requiredOption("--account <accountId>", "Amistio account ID").requiredOption("--project <projectId>", "Amistio project ID").requiredOption("--repository-link <repositoryLinkId>", "Existing repository link ID").option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl()).requiredOption("--pairing-code <code>", "Short-lived pairing code from the Amistio app").option("--default-branch <branch>", "Default branch", "main").action(async (options) => {
2506
+ const parsedRepoUrl = parseRepositoryCloneUrl(options.repoUrl);
2507
+ const checkout = await cloneOrValidateRepository({ repoUrl: parsedRepoUrl.cloneUrl, targetDir: options.target });
2508
+ await initControlPlane(checkout.targetDir);
2509
+ const pairing = await new ApiClient({
2510
+ apiUrl: options.apiUrl,
2511
+ accountId: options.account
2512
+ }).consumePairingSession({
2513
+ projectId: options.project,
2514
+ pairingCode: options.pairingCode,
2515
+ repositoryLinkId: options.repositoryLink,
2516
+ repoName: parsedRepoUrl.repoName,
2517
+ repoFingerprint: createRepoFingerprint(options.account, options.project, options.repositoryLink),
2518
+ defaultBranch: options.defaultBranch
2519
+ });
2520
+ const filePath = await writeProjectLink(checkout.targetDir, {
2521
+ amistioAccountId: options.account,
2522
+ amistioProjectId: options.project,
2523
+ repositoryLinkId: pairing.repositoryLink.repositoryLinkId,
2524
+ defaultBranch: options.defaultBranch,
2525
+ lastSyncedRevision: 0
2526
+ });
2527
+ await new LocalCredentialStore().set(credentialKey(options.account, options.project, pairing.repositoryLink.repositoryLinkId), pairing.token);
2528
+ console.log(checkout.status === "cloned" ? `Cloned repository to ${checkout.targetDir}.` : `Validated existing checkout at ${checkout.targetDir}.`);
2529
+ console.log(`Pairing confirmed for ${pairing.repositoryLink.repoName}.`);
2530
+ console.log(`Wrote non-secret project metadata to ${filePath}.`);
2531
+ console.log(`Next: cd ${formatShellArg(checkout.targetDir)} && amistio run${formatApiUrlFlag(options.apiUrl)} --watch`);
2532
+ });
2533
+ program.command("pair").description("Pair this repository with an Amistio web project").requiredOption("--account <accountId>", "Amistio account ID").requiredOption("--project <projectId>", "Amistio project ID").option("--repository-link <repositoryLinkId>", "Existing repository link ID").option("--default-branch <branch>", "Default branch", "main").option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl()).option("--pairing-code <code>", "Short-lived pairing code from the Amistio app").option("--token <token>", "Runner/device credential to store outside the repository").option("--root <path>", "Repository root", defaultRoot).action(async (options, command) => {
2534
+ let repositoryLinkId = options.repositoryLink ?? `repo_${randomUUID()}`;
2535
+ let credential = options.token;
2536
+ if (options.pairingCode) {
2537
+ const pairing = await new ApiClient({
2538
+ apiUrl: options.apiUrl,
2539
+ accountId: options.account,
2540
+ ...credential ? { token: credential } : {}
2541
+ }).consumePairingSession({
2542
+ projectId: options.project,
2543
+ pairingCode: options.pairingCode,
2544
+ repositoryLinkId,
2545
+ repoName: inferRepoName(options.root),
2546
+ repoFingerprint: createRepoFingerprint(options.account, options.project, repositoryLinkId),
2547
+ defaultBranch: options.defaultBranch
583
2548
  });
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 } : {})
2549
+ repositoryLinkId = pairing.repositoryLink.repositoryLinkId;
2550
+ credential = credential ?? pairing.token;
2551
+ console.log(`Pairing confirmed for ${pairing.repositoryLink.repoName}.`);
2552
+ }
2553
+ const filePath = await writeProjectLink(options.root, {
2554
+ amistioAccountId: options.account,
2555
+ amistioProjectId: options.project,
2556
+ repositoryLinkId,
2557
+ defaultBranch: options.defaultBranch,
2558
+ lastSyncedRevision: 0
2559
+ });
2560
+ if (credential) {
2561
+ await new LocalCredentialStore().set(credentialKey(options.account, options.project, repositoryLinkId), credential);
2562
+ }
2563
+ if (!options.pairingCode && apiUrlOverrideWasRequested(command)) {
2564
+ const session = await new ApiClient({
2565
+ apiUrl: options.apiUrl,
2566
+ accountId: options.account,
2567
+ ...credential ? { token: credential } : {}
2568
+ }).createPairingSession(options.project);
2569
+ console.log(`Pairing code: ${session.pairingCode}`);
2570
+ console.log(`Expires at: ${session.expiresAt}`);
2571
+ }
2572
+ console.log(`Wrote non-secret project metadata to ${filePath}.`);
2573
+ });
2574
+ var sync = program.command("sync").description("Inspect or move project brain changes between the repo and Amistio");
2575
+ sync.command("status").description("Show local sync status").option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl()).option("--root <path>", "Repository root", defaultRoot).action(async (options) => {
2576
+ const metadata = await readProjectLink(options.root);
2577
+ if (!metadata) {
2578
+ console.log("Repository is not paired. Run `amistio pair` first.");
2579
+ return;
2580
+ }
2581
+ const context = await loadPairedApiContext(options.root, options.apiUrl);
2582
+ const webDocuments = context ? await context.client.listBrainDocuments(metadata.amistioProjectId).then((result) => result.documents).catch(() => []) : [];
2583
+ const report = await collectSyncStatus(options.root, webDocuments);
2584
+ console.log(`Paired project: ${metadata.amistioProjectId}`);
2585
+ console.log(`Repository link: ${metadata.repositoryLinkId}`);
2586
+ console.log(`Last synced revision: ${metadata.lastSyncedRevision}`);
2587
+ console.log(`Sync status: ${report.status}`);
2588
+ console.log(`Clean: ${report.counts.clean}; Pending: ${report.counts.pending}; Dirty: ${report.counts.dirty}; Conflicted: ${report.counts.conflicted}`);
2589
+ for (const item of report.items.filter((entry) => entry.status !== "clean")) {
2590
+ console.log(`${item.status}: ${item.repoPath} - ${item.reason}`);
2591
+ }
2592
+ });
2593
+ sync.command("pull").description("Pull approved web changes into the repository").option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl()).option("--root <path>", "Repository root", defaultRoot).action(async (options) => {
2594
+ const context = await loadPairedApiContext(options.root, options.apiUrl);
2595
+ if (!context) {
2596
+ console.log("Repository is not paired. Run `amistio pair` first.");
2597
+ return;
2598
+ }
2599
+ const { documents } = await context.client.listBrainDocuments(context.metadata.amistioProjectId);
2600
+ const approvedDocuments = documents.filter((document) => document.syncState === "approved" || document.syncState === "synced");
2601
+ const result = await materializeBrainDocuments(options.root, approvedDocuments);
2602
+ for (const conflict of result.conflicts) {
2603
+ console.log(`conflicted: ${conflict}`);
2604
+ }
2605
+ if (result.conflicts.length) {
2606
+ process.exitCode = 1;
2607
+ return;
2608
+ }
2609
+ const latestRevision = Math.max(context.metadata.lastSyncedRevision, ...approvedDocuments.map((document) => document.revision));
2610
+ if (latestRevision !== context.metadata.lastSyncedRevision) {
2611
+ await writeProjectLink(options.root, { ...context.metadata, lastSyncedRevision: latestRevision });
2612
+ }
2613
+ console.log(result.written.length ? `Pulled ${result.written.length} approved document${result.written.length === 1 ? "" : "s"}.` : "No approved web changes were pulled.");
2614
+ });
2615
+ sync.command("push").description("Push local brain changes to Amistio for review").option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl()).option("--root <path>", "Repository root", defaultRoot).action(async (options) => {
2616
+ const context = await loadPairedApiContext(options.root, options.apiUrl);
2617
+ if (!context) {
2618
+ console.log("Repository is not paired. Run `amistio pair` first.");
2619
+ return;
2620
+ }
2621
+ const dirtyDocuments = await collectDirtyDocumentsForPush(options.root, context.metadata);
2622
+ if (!dirtyDocuments.length) {
2623
+ console.log("No local brain changes were pushed.");
2624
+ return;
2625
+ }
2626
+ const { documents } = await context.client.pushBrainDocuments(context.metadata.amistioProjectId, dirtyDocuments);
2627
+ const conflictedDocuments = documents.filter((document) => document.syncState === "conflicted" || document.status === "conflicted");
2628
+ if (conflictedDocuments.length) {
2629
+ for (const document of conflictedDocuments) {
2630
+ console.log(`conflicted: ${document.repoPath} - web and repository revisions both changed`);
2631
+ }
2632
+ process.exitCode = 1;
2633
+ return;
2634
+ }
2635
+ await materializeBrainDocuments(options.root, documents, { allowDirtyOverwrite: true });
2636
+ console.log(`Pushed ${documents.length} local document${documents.length === 1 ? "" : "s"} for web review.`);
2637
+ });
2638
+ var work = program.command("work").description("Inspect approved work items");
2639
+ work.command("list").description("List queued work without claiming it").option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl()).option("--root <path>", "Repository root", defaultRoot).action(async (options) => {
2640
+ const context = await loadPairedApiContext(options.root, options.apiUrl);
2641
+ if (!context) {
2642
+ console.log("Repository is not paired. Run `amistio pair` first.");
2643
+ return;
2644
+ }
2645
+ const [{ workItems }, { documents }, { runners }] = await Promise.all([
2646
+ context.client.listWorkItems(context.metadata.amistioProjectId),
2647
+ context.client.listBrainDocuments(context.metadata.amistioProjectId),
2648
+ context.client.listRunners(context.metadata.amistioProjectId).catch(() => ({ runners: [] }))
2649
+ ]);
2650
+ const nextAction = computeProjectNextAction({
2651
+ projectId: context.metadata.amistioProjectId,
2652
+ repositoryLinks: [createCliRepositoryLink(context.metadata, options.root)],
2653
+ documents,
2654
+ workItems,
2655
+ runnerHeartbeats: runners
2656
+ });
2657
+ console.log(`Next action: ${formatProjectNextAction(nextAction)}`);
2658
+ if (!workItems.length) {
2659
+ console.log("No work items are queued for this project.");
2660
+ return;
2661
+ }
2662
+ for (const item of workItems) {
2663
+ console.log(`${item.workItemId} [${item.status}] ${item.title}`);
2664
+ }
2665
+ });
2666
+ work.command("prompt").description("Print or write an approved work prompt without claiming a runner lease").argument("[workItemId]", "Work item ID. Defaults to the newest approved work item.").option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl()).option("--root <path>", "Repository root", defaultRoot).option("--out <path>", "Write the prompt to a file instead of stdout").action(async (workItemId, options) => {
2667
+ const context = await loadPairedApiContext(options.root, options.apiUrl);
2668
+ if (!context) {
2669
+ console.log("Repository is not paired. Run `amistio pair` first.");
2670
+ return;
2671
+ }
2672
+ const { workItems } = await context.client.listWorkItems(context.metadata.amistioProjectId);
2673
+ const workItem = selectPromptWorkItem(workItems, workItemId);
2674
+ if (!workItem) {
2675
+ console.log(workItemId ? `No work item found for ${workItemId}.` : "No approved work item is ready for prompt export.");
2676
+ return;
2677
+ }
2678
+ const prompt = createWorkExecutionPrompt(workItem);
2679
+ if (options.out) {
2680
+ await writeFile8(options.out, prompt, "utf8");
2681
+ console.log(`Wrote work prompt to ${options.out}.`);
2682
+ } else {
2683
+ console.log(prompt);
2684
+ }
2685
+ });
2686
+ program.command("tools").description("List local AI coding tools that the Amistio CLI can use").action(async () => {
2687
+ const tools = await detectLocalTools();
2688
+ for (const tool of tools) {
2689
+ const mode = tool.execution === "sdk" ? "sdk" : tool.execution === "command" ? "cli" : "--";
2690
+ console.log(`${tool.available ? "yes" : "no "} ${mode} ${tool.name} - ${tool.description}`);
2691
+ }
2692
+ console.log("custom - pass --tool-command to use any other local runner command.");
2693
+ });
2694
+ program.command("orchestrate").description("Update the Amistio control plane through a user-installed local AI tool").argument("[goal...]", "Goal or next-step instruction for the orchestration pass").option("--root <path>", "Repository root", defaultRoot).option("--tool <name>", "Local tool to use: auto, none, opencode, claude, codex, copilot, gemini, aider, cursor-agent", "auto").option("--model <model>", "Model to request when the selected local tool supports model selection").option("--tool-command <command>", "Custom local command. Use {promptFile} and {root} placeholders when supported").option("--session <policy>", "Tool session policy: auto, new, continue:<toolSessionId>, or none", "auto").option("--prompt-out <path>", "Write the generated orchestration prompt to a file before running").option("--dry-run", "Print the generated orchestration prompt without running a tool").option("--no-stream", "Capture local tool output instead of streaming it").action(async (goalParts, options) => {
2695
+ const goal = goalParts?.join(" ").trim() || "Review the current repository state and update the Amistio control plane with the next useful orchestration steps.";
2696
+ const prompt = await createOrchestrationPrompt({ rootDir: options.root, goal });
2697
+ if (options.promptOut) {
2698
+ const promptPath = await writePromptFile(options.promptOut, prompt);
2699
+ console.log(`Wrote orchestration prompt to ${promptPath}.`);
2700
+ }
2701
+ if (options.dryRun || options.tool === "none") {
2702
+ console.log(prompt);
2703
+ return;
2704
+ }
2705
+ const sessionPolicy = normalizeSessionPolicy(options.session);
2706
+ const preview = await createToolRunPreview({ rootDir: options.root, prompt, tool: options.tool, ...options.toolCommand ? { toolCommand: options.toolCommand } : {}, ...options.model ? { model: options.model } : {} });
2707
+ console.log(`Running ${preview.toolName}: ${preview.displayCommand}`);
2708
+ const result = await runLocalTool({
2709
+ rootDir: options.root,
2710
+ prompt,
2711
+ tool: options.tool,
2712
+ ...options.toolCommand ? { toolCommand: options.toolCommand } : {},
2713
+ ...options.model ? { model: options.model } : {},
2714
+ streamOutput: options.stream,
2715
+ ...sessionPolicy === "none" ? {} : { session: { toolSessionId: `local_orchestration_${randomUUID()}`, policy: sessionPolicy, decision: localSessionDecision(sessionPolicy) } }
2716
+ });
2717
+ if (!options.stream && result.stdout.trim()) {
2718
+ console.log(result.stdout.trim());
2719
+ }
2720
+ if (!options.stream && result.stderr.trim()) {
2721
+ console.error(result.stderr.trim());
2722
+ }
2723
+ if (result.exitCode !== 0) {
2724
+ process.exitCode = result.exitCode;
2725
+ }
2726
+ });
2727
+ program.command("run").description("Claim and run approved Amistio work locally").option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl()).option("--runner-id <runnerId>", "Stable runner ID", `runner_${randomUUID()}`).option("--root <path>", "Repository root", defaultRoot).option("--tool <name>", "Local tool to use: auto, none, opencode, claude, codex, copilot, gemini, aider, cursor-agent").option("--model <model>", "Model to request when the selected local tool supports model selection").option("--tool-command <command>", "Custom local command. Use {promptFile} and {root} placeholders when supported").option("--session <policy>", "Tool session policy: auto, new, continue:<toolSessionId>, or none", "auto").option("--dry-run", "Claim work and print the generated execution prompt without running a tool").option("--watch", "Keep polling for approved work until stopped").option("--background", "Start a detached background runner that watches for approved work").option("--interval-seconds <seconds>", "Polling interval for --watch", parsePositiveInteger, 10).option("--max-iterations <count>", "Stop watch mode after this many polling attempts", parsePositiveInteger).option("--no-stream", "Capture local tool output instead of streaming it").action(async (options, command) => {
2728
+ const context = await loadPairedApiContext(options.root, options.apiUrl);
2729
+ if (!context) {
2730
+ console.log("Repository is not paired. Run `amistio pair` first.");
2731
+ return;
2732
+ }
2733
+ if (!context.token) {
2734
+ console.log("No local runner credential found. Run `amistio pair --pairing-code <code>` to store this machine credential.");
2735
+ process.exitCode = 1;
2736
+ return;
2737
+ }
2738
+ if (options.background) {
2739
+ if (options.dryRun) {
2740
+ console.log("Background runners cannot be started in dry-run mode.");
2741
+ process.exitCode = 1;
2742
+ return;
2743
+ }
2744
+ const metadata = await startRunnerDaemon({
2745
+ accountId: context.metadata.amistioAccountId,
2746
+ projectId: context.metadata.amistioProjectId,
2747
+ repositoryLinkId: context.metadata.repositoryLinkId,
2748
+ runnerId: options.runnerId,
2749
+ rootDir: path9.resolve(options.root),
2750
+ apiUrl: options.apiUrl,
2751
+ args: buildBackgroundRunnerArgs(options)
611
2752
  });
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." } : {})
2753
+ console.log(`Started background runner ${metadata.runnerId} with PID ${metadata.pid}.`);
2754
+ if (metadata.logPath) {
2755
+ console.log(`Log: ${metadata.logPath}`);
2756
+ }
2757
+ return;
2758
+ }
2759
+ if (options.watch) {
2760
+ for (const line of formatWatchStartupContext({ runnerId: options.runnerId, projectId: context.metadata.amistioProjectId, repositoryLinkId: context.metadata.repositoryLinkId, apiUrl: options.apiUrl, intervalSeconds: options.intervalSeconds })) {
2761
+ console.log(line);
2762
+ }
2763
+ }
2764
+ let iterations = 0;
2765
+ let lastWatchStateLog;
2766
+ while (true) {
2767
+ iterations += 1;
2768
+ const result = await runNextWorkItem({
2769
+ apiClient: context.client,
2770
+ projectId: context.metadata.amistioProjectId,
2771
+ repositoryLinkId: context.metadata.repositoryLinkId,
2772
+ runnerId: options.runnerId,
2773
+ root: options.root,
2774
+ sessionPolicy: normalizeSessionPolicy(options.session),
2775
+ ...command.getOptionValueSource("tool") === "cli" && options.tool ? { explicitTool: options.tool } : {},
2776
+ ...command.getOptionValueSource("model") === "cli" && options.model ? { explicitModel: options.model } : {},
2777
+ ...options.toolCommand ? { toolCommand: options.toolCommand } : {},
2778
+ dryRun: Boolean(options.dryRun),
2779
+ stream: options.stream,
2780
+ commandContext: {
2781
+ accountId: context.metadata.amistioAccountId,
2782
+ apiUrl: options.apiUrl,
2783
+ backgroundArgs: buildBackgroundRunnerArgs(options),
2784
+ projectId: context.metadata.amistioProjectId,
2785
+ repositoryLinkId: context.metadata.repositoryLinkId,
2786
+ root: options.root,
2787
+ runnerId: options.runnerId
2788
+ },
2789
+ suppressIdleOutput: Boolean(options.watch)
629
2790
  });
630
- return toolSession;
2791
+ if (!options.watch || options.dryRun) {
2792
+ if (result.exitCode !== 0) {
2793
+ process.exitCode = result.exitCode;
2794
+ }
2795
+ return;
2796
+ }
2797
+ if (result.status === "idle" && result.nextAction) {
2798
+ const nowMs = Date.now();
2799
+ if (shouldPrintWatchState(result.nextAction, lastWatchStateLog, nowMs)) {
2800
+ console.log(formatWatchIdleLine(result.nextAction, options.intervalSeconds));
2801
+ lastWatchStateLog = { key: watchStateKey(result.nextAction), printedAtMs: nowMs };
2802
+ }
2803
+ }
2804
+ if (result.stopRunner) {
2805
+ return;
2806
+ }
2807
+ if (options.maxIterations !== void 0 && iterations >= options.maxIterations) {
2808
+ console.log(`Runner stopped after ${iterations} polling attempt${iterations === 1 ? "" : "s"}.`);
2809
+ return;
2810
+ }
2811
+ await delay(options.intervalSeconds * 1e3);
2812
+ }
2813
+ });
2814
+ var runner = program.command("runner").description("Manage local Amistio runner processes");
2815
+ runner.command("status").description("Show background runner status for the paired repository").option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl()).option("--root <path>", "Repository root", defaultRoot).option("--runner-id <runnerId>", "Limit status to one runner ID").action(async (options) => {
2816
+ const context = await loadPairedApiContext(options.root, options.apiUrl);
2817
+ if (!context) {
2818
+ console.log("Repository is not paired. Run `amistio pair` first.");
2819
+ return;
2820
+ }
2821
+ const records = await listRunnerDaemonMetadata({
2822
+ accountId: context.metadata.amistioAccountId,
2823
+ projectId: context.metadata.amistioProjectId,
2824
+ repositoryLinkId: context.metadata.repositoryLinkId,
2825
+ ...options.runnerId ? { runnerId: options.runnerId } : {}
2826
+ });
2827
+ const runners = await context.client.listRunners(context.metadata.amistioProjectId).then((result) => result.runners).catch(() => []);
2828
+ if (!records.length) {
2829
+ console.log("No background runner metadata found for this paired repository.");
2830
+ if (runners.length) {
2831
+ console.log(`Last runner heartbeat: ${runners[0].runnerId} ${runners[0].status} at ${runners[0].lastSeenAt}.`);
2832
+ }
2833
+ return;
2834
+ }
2835
+ for (const record of records) {
2836
+ const runtimeStatus = runnerDaemonRuntimeStatus(record);
2837
+ const heartbeat = runners.find((item) => item.runnerId === record.runnerId);
2838
+ console.log(`Runner ${record.runnerId}: ${runtimeStatus}`);
2839
+ console.log(` PID: ${record.pid}`);
2840
+ console.log(` Uptime: ${runtimeStatus === "running" ? runnerDaemonUptime(record) : "not running"}`);
2841
+ console.log(` Project: ${record.projectId}`);
2842
+ console.log(` Repository link: ${record.repositoryLinkId}`);
2843
+ console.log(` Root: ${record.rootDir}`);
2844
+ console.log(` API: ${record.apiUrl}`);
2845
+ console.log(` Host: ${record.hostname}`);
2846
+ if (record.logPath) {
2847
+ console.log(` Log: ${record.logPath}`);
2848
+ }
2849
+ if (heartbeat) {
2850
+ console.log(` Last heartbeat: ${heartbeat.status} at ${heartbeat.lastSeenAt}${heartbeat.version ? ` (${heartbeat.version})` : ""}`);
2851
+ }
2852
+ }
2853
+ });
2854
+ runner.command("stop").description("Stop a background runner for the paired repository").option("--api-url <url>", apiUrlOptionDescription, defaultApiUrl()).option("--root <path>", "Repository root", defaultRoot).option("--runner-id <runnerId>", "Runner ID to stop when multiple background runners exist").action(async (options) => {
2855
+ const context = await loadPairedApiContext(options.root, options.apiUrl);
2856
+ if (!context) {
2857
+ console.log("Repository is not paired. Run `amistio pair` first.");
2858
+ return;
2859
+ }
2860
+ const records = await listRunnerDaemonMetadata({
2861
+ accountId: context.metadata.amistioAccountId,
2862
+ projectId: context.metadata.amistioProjectId,
2863
+ repositoryLinkId: context.metadata.repositoryLinkId,
2864
+ ...options.runnerId ? { runnerId: options.runnerId } : {}
2865
+ });
2866
+ if (!records.length) {
2867
+ console.log("No background runner metadata found for this paired repository.");
2868
+ return;
2869
+ }
2870
+ if (records.length > 1 && !options.runnerId) {
2871
+ console.log(`Multiple background runners found: ${records.map((record2) => record2.runnerId).join(", ")}. Pass --runner-id to stop one.`);
2872
+ process.exitCode = 1;
2873
+ return;
2874
+ }
2875
+ const record = records[0];
2876
+ const stopResult = await stopRunnerDaemonProcess(record);
2877
+ await markRunnerDaemonStopped(record);
2878
+ await context.client.sendRunnerHeartbeat(context.metadata.amistioProjectId, record.runnerId, context.metadata.repositoryLinkId, "offline", {
2879
+ version: CLI_VERSION,
2880
+ mode: "background",
2881
+ hostname: record.hostname
2882
+ }).catch(() => void 0);
2883
+ console.log(stopResult === "stopped" ? `Stopped background runner ${record.runnerId}.` : `Marked background runner ${record.runnerId} stopped; process was not running.`);
2884
+ });
2885
+ async function runNextWorkItem({
2886
+ apiClient,
2887
+ dryRun,
2888
+ projectId,
2889
+ repositoryLinkId,
2890
+ root,
2891
+ runnerId,
2892
+ sessionPolicy,
2893
+ stream,
2894
+ explicitModel,
2895
+ explicitTool,
2896
+ toolCommand,
2897
+ commandContext,
2898
+ suppressIdleOutput
2899
+ }) {
2900
+ const toolConfig = await resolveRunnerToolConfig({
2901
+ apiClient,
2902
+ projectId,
2903
+ ...explicitModel ? { explicitModel } : {},
2904
+ ...explicitTool ? { explicitTool } : {},
2905
+ ...toolCommand ? { toolCommand } : {}
2906
+ });
2907
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, toolConfig.ready ? "online" : "blocked", runnerHeartbeatMetadata(toolConfig));
2908
+ const commandResult = await runPendingRunnerCommand(apiClient, commandContext, runnerHeartbeatMetadata(toolConfig));
2909
+ if (commandResult.handled) {
2910
+ if (commandResult.message) {
2911
+ console.log(commandResult.message);
2912
+ }
2913
+ return { status: commandResult.succeeded ? "completed" : "failed", exitCode: commandResult.succeeded ? 0 : 1, ...commandResult.stopRunner ? { stopRunner: true } : {} };
2914
+ }
2915
+ if (!toolConfig.ready) {
2916
+ console.log(toolConfig.message);
2917
+ return { status: "blocked", exitCode: 1 };
2918
+ }
2919
+ const result = await apiClient.claimWork(projectId, runnerId, repositoryLinkId);
2920
+ if (!result.workItem) {
2921
+ const nextAction = await loadProjectNextAction(apiClient, projectId, repositoryLinkId, root);
2922
+ const message = formatProjectNextAction(nextAction);
2923
+ if (!suppressIdleOutput) {
2924
+ console.log(message);
2925
+ }
2926
+ return { status: "idle", exitCode: 0, nextAction, message };
2927
+ }
2928
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "running", runnerHeartbeatMetadata(toolConfig));
2929
+ const prompt = await createRunnerWorkPrompt(apiClient, projectId, result.workItem);
2930
+ if (dryRun || toolConfig.tool === "none") {
2931
+ console.log(prompt);
2932
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", runnerHeartbeatMetadata(toolConfig));
2933
+ return { status: "preview", exitCode: 0 };
2934
+ }
2935
+ const preview = await createToolRunPreview({ rootDir: root, prompt, tool: toolConfig.tool, ...toolCommand ? { toolCommand } : {}, ...toolConfig.model ? { model: toolConfig.model } : {} });
2936
+ const sessionContext = await prepareToolSession({
2937
+ apiClient,
2938
+ projectId,
2939
+ repositoryLinkId,
2940
+ runnerId,
2941
+ sessionPolicy: result.workItem.sessionPolicy ?? sessionPolicy,
2942
+ toolName: preview.toolName,
2943
+ ...toolConfig.model ? { model: toolConfig.model } : {},
2944
+ supportsSessionReuse: preview.supportsSessionReuse,
2945
+ resumabilityScope: preview.resumabilityScope,
2946
+ workItem: result.workItem
2947
+ });
2948
+ console.log(`Claimed ${result.workItem.workItemId}. Running ${preview.toolName}: ${preview.displayCommand}`);
2949
+ const startedAt = Date.now();
2950
+ const providerSessionStore = new LocalToolSessionStore();
2951
+ const providerSessionId = sessionContext.toolSession ? await providerSessionStore.getProviderSessionId(sessionContext.toolSession.toolSessionId, preview.toolName) : void 0;
2952
+ const toolResult = await runLocalTool({
2953
+ rootDir: root,
2954
+ prompt,
2955
+ tool: toolConfig.tool,
2956
+ ...toolCommand ? { toolCommand } : {},
2957
+ ...toolConfig.model ? { model: toolConfig.model } : {},
2958
+ streamOutput: stream,
2959
+ ...sessionContext.toolSession ? {
2960
+ session: {
2961
+ toolSessionId: sessionContext.toolSession.toolSessionId,
2962
+ policy: sessionContext.policy,
2963
+ decision: sessionContext.decision,
2964
+ ...providerSessionId ? { providerSessionId } : {}
2965
+ }
2966
+ } : {}
2967
+ }).catch(async (error) => {
2968
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "blocked", runnerHeartbeatMetadata(toolConfig));
2969
+ await markToolSessionBlocked(apiClient, projectId, sessionContext.toolSession, errorMessage2(error));
2970
+ throw error;
2971
+ });
2972
+ if (sessionContext.toolSession && toolResult.providerSessionId) {
2973
+ await providerSessionStore.setProviderSessionId(sessionContext.toolSession.toolSessionId, preview.toolName, toolResult.providerSessionId);
2974
+ }
2975
+ if (!stream && toolResult.stdout.trim()) {
2976
+ console.log(toolResult.stdout.trim());
2977
+ }
2978
+ if (!stream && toolResult.stderr.trim()) {
2979
+ console.error(toolResult.stderr.trim());
2980
+ }
2981
+ if (result.workItem.workKind === "brainGeneration" || result.workItem.workKind === "planRevision") {
2982
+ return finalizeBrainGenerationWork({
2983
+ apiClient,
2984
+ durationMs: Date.now() - startedAt,
2985
+ projectId,
2986
+ repositoryLinkId,
2987
+ runnerId,
2988
+ sessionContext,
2989
+ toolConfig,
2990
+ toolName: preview.toolName,
2991
+ toolResult,
2992
+ workItem: result.workItem
2993
+ });
2994
+ }
2995
+ const finalStatus = toolResult.exitCode === 0 ? "completed" : "failed";
2996
+ const durationMs = Date.now() - startedAt;
2997
+ const failureExcerpt = toolResult.exitCode === 0 ? void 0 : truncateLogExcerpt(toolResult.stderr || toolResult.stdout);
2998
+ const updatedToolSession = await finalizeToolSession({
2999
+ apiClient,
3000
+ projectId,
3001
+ status: finalStatus,
3002
+ runnerId,
3003
+ workItemId: result.workItem.workItemId,
3004
+ stdout: toolResult.stdout,
3005
+ ...sessionContext.toolSession ? { session: sessionContext.toolSession } : {},
3006
+ ...toolResult.messageCount !== void 0 ? { messageCount: toolResult.messageCount } : {},
3007
+ ...toolResult.tokensIn !== void 0 ? { tokensIn: toolResult.tokensIn } : {},
3008
+ ...toolResult.tokensOut !== void 0 ? { tokensOut: toolResult.tokensOut } : {},
3009
+ ...toolResult.costUsd !== void 0 ? { costUsd: toolResult.costUsd } : {}
3010
+ });
3011
+ const statusResult = await apiClient.updateWorkStatus(
3012
+ projectId,
3013
+ result.workItem.workItemId,
3014
+ finalStatus,
3015
+ `run_${result.workItem.workItemId}_${randomUUID()}`,
3016
+ runnerId,
3017
+ {
3018
+ tool: preview.toolName,
3019
+ ...toolResult.model ? { model: toolResult.model } : {},
3020
+ durationMs,
3021
+ message: `${preview.toolName} exited with code ${toolResult.exitCode}.`,
3022
+ sessionPolicy: sessionContext.policy,
3023
+ sessionDecision: sessionContext.decision,
3024
+ sessionDecisionReason: sessionContext.reason,
3025
+ ...updatedToolSession ? { toolSessionId: updatedToolSession.toolSessionId } : {},
3026
+ ...updatedToolSession?.sessionGroupKey ? { sessionGroupKey: updatedToolSession.sessionGroupKey } : {},
3027
+ ...toolResult.tokensIn !== void 0 ? { tokensIn: toolResult.tokensIn } : {},
3028
+ ...toolResult.tokensOut !== void 0 ? { tokensOut: toolResult.tokensOut } : {},
3029
+ ...toolResult.costUsd !== void 0 ? { costUsd: toolResult.costUsd } : {},
3030
+ ...failureExcerpt ? { error: failureExcerpt } : {}
3031
+ }
3032
+ );
3033
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", runnerHeartbeatMetadata(toolConfig));
3034
+ const durationSeconds = Math.round(durationMs / 1e3);
3035
+ console.log(`Marked ${statusResult.workItem.workItemId} ${statusResult.workItem.status} after ${durationSeconds}s.`);
3036
+ return { status: finalStatus, exitCode: toolResult.exitCode };
631
3037
  }
632
- async function markToolSessionBlocked(apiClient, projectId, session, reason) {
633
- if (!session) {
634
- return;
3038
+ async function runPendingRunnerCommand(apiClient, context, heartbeatMetadata) {
3039
+ const { commands } = await apiClient.listRunnerCommands(context.projectId, context.runnerId, context.repositoryLinkId).catch(() => ({ commands: [] }));
3040
+ const command = commands.filter((item) => item.status === "pending" || item.status === "acknowledged" || item.status === "running").sort((first, second) => Date.parse(first.createdAt) - Date.parse(second.createdAt))[0];
3041
+ if (!command) {
3042
+ return { handled: false, succeeded: true };
3043
+ }
3044
+ await updateRunnerCommandStatus(apiClient, context, command, "acknowledged", "Command acknowledged by local runner.");
3045
+ await updateRunnerCommandStatus(apiClient, context, command, "running", `Running ${runnerCommandLabel(command.commandKind)} command.`);
3046
+ const result = await executeRunnerCommand(command, context);
3047
+ await updateRunnerCommandStatus(apiClient, context, command, result.succeeded ? "completed" : "failed", result.message, result.error);
3048
+ if (command.commandKind === "remove" && result.succeeded) {
3049
+ await new LocalCredentialStore().delete(credentialKey(context.accountId, context.projectId, context.repositoryLinkId));
3050
+ }
3051
+ if (result.stopRunner && command.commandKind === "remove") {
3052
+ await apiClient.sendRunnerHeartbeat(context.projectId, context.runnerId, context.repositoryLinkId, "offline", heartbeatMetadata).catch(() => void 0);
3053
+ }
3054
+ return { handled: true, succeeded: result.succeeded, message: result.message, ...result.stopRunner ? { stopRunner: true } : {} };
3055
+ }
3056
+ async function updateRunnerCommandStatus(apiClient, context, command, status, message, error) {
3057
+ const result = await apiClient.updateRunnerCommand(context.projectId, command.commandId, {
3058
+ runnerId: context.runnerId,
3059
+ repositoryLinkId: context.repositoryLinkId,
3060
+ status,
3061
+ idempotencyKey: `runner_command_${command.commandId}_${status}_${randomUUID()}`,
3062
+ message,
3063
+ ...error ? { error } : {}
3064
+ });
3065
+ return result.command;
3066
+ }
3067
+ async function executeRunnerCommand(command, context) {
3068
+ if (command.commandKind === "remove") {
3069
+ return { succeeded: true, stopRunner: true, message: "Runner credential revoked. This local runner will stop after removing its stored credential." };
3070
+ }
3071
+ if (command.commandKind === "restart") {
3072
+ return restartCurrentRunner(context);
3073
+ }
3074
+ return runOfficialCliUpdate();
3075
+ }
3076
+ async function restartCurrentRunner(context) {
3077
+ if (currentRunnerMode() !== "background") {
3078
+ return { succeeded: false, message: "Foreground runners cannot be restarted remotely. Stop and start the local command manually." };
3079
+ }
3080
+ const [metadata] = await listRunnerDaemonMetadata({ accountId: context.accountId, projectId: context.projectId, repositoryLinkId: context.repositoryLinkId, runnerId: context.runnerId });
3081
+ if (!metadata) {
3082
+ return { succeeded: false, message: "Background runner metadata was not found, so restart needs manual action." };
3083
+ }
3084
+ try {
3085
+ const replacement = await restartRunnerDaemonProcess(metadata, context.backgroundArgs);
3086
+ return { succeeded: true, stopRunner: true, message: `Replacement background runner started with PID ${replacement.pid}.` };
3087
+ } catch (error) {
3088
+ return { succeeded: false, message: "Background restart failed.", error: errorMessage2(error) };
3089
+ }
3090
+ }
3091
+ async function runOfficialCliUpdate() {
3092
+ const result = await runOfficialUpdateProcess("npm", ["install", "-g", "@amistio/cli"], 12e4);
3093
+ if (result.exitCode === 0) {
3094
+ return { succeeded: true, message: "Official Amistio CLI update command completed." };
3095
+ }
3096
+ return { succeeded: false, message: "Official Amistio CLI update command failed.", error: result.output || `npm exited with code ${result.exitCode}.` };
3097
+ }
3098
+ function runOfficialUpdateProcess(command, args, timeoutMs) {
3099
+ return new Promise((resolve) => {
3100
+ const child = spawn3(command, args, { stdio: ["ignore", "pipe", "pipe"] });
3101
+ let output = "";
3102
+ const timer = setTimeout(() => {
3103
+ output += "Timed out while running official CLI update.\n";
3104
+ child.kill("SIGTERM");
3105
+ }, timeoutMs);
3106
+ child.stdout?.on("data", (chunk) => {
3107
+ output += chunk.toString("utf8");
3108
+ });
3109
+ child.stderr?.on("data", (chunk) => {
3110
+ output += chunk.toString("utf8");
3111
+ });
3112
+ child.on("error", (error) => {
3113
+ clearTimeout(timer);
3114
+ resolve({ exitCode: 1, output: error.message });
3115
+ });
3116
+ child.on("close", (code) => {
3117
+ clearTimeout(timer);
3118
+ resolve({ exitCode: code ?? 1, output: truncateLogExcerpt(output) });
3119
+ });
3120
+ });
3121
+ }
3122
+ function runnerCommandLabel(commandKind) {
3123
+ if (commandKind === "update") return "update";
3124
+ if (commandKind === "restart") return "restart";
3125
+ return "remove";
3126
+ }
3127
+ async function finalizeBrainGenerationWork({
3128
+ apiClient,
3129
+ durationMs,
3130
+ projectId,
3131
+ repositoryLinkId,
3132
+ runnerId,
3133
+ sessionContext,
3134
+ toolConfig,
3135
+ toolName,
3136
+ toolResult,
3137
+ workItem
3138
+ }) {
3139
+ let artifacts;
3140
+ let generationError;
3141
+ if (toolResult.exitCode === 0) {
3142
+ try {
3143
+ artifacts = parseBrainGenerationArtifacts(`${toolResult.stdout}
3144
+ ${toolResult.stderr}`);
3145
+ } catch (error) {
3146
+ generationError = errorMessage2(error);
635
3147
  }
636
- await apiClient.updateToolSession(projectId, session.toolSessionId, {
637
- status: "blocked",
638
- summary: reason,
639
- closedReason: reason
3148
+ } else {
3149
+ generationError = truncateLogExcerpt(toolResult.stderr || toolResult.stdout) || `${toolName} exited with code ${toolResult.exitCode}.`;
3150
+ }
3151
+ const finalStatus = artifacts ? "completed" : "failed";
3152
+ const updatedToolSession = await finalizeToolSession({
3153
+ apiClient,
3154
+ projectId,
3155
+ status: finalStatus,
3156
+ runnerId,
3157
+ workItemId: workItem.workItemId,
3158
+ stdout: toolResult.stdout,
3159
+ ...sessionContext.toolSession ? { session: sessionContext.toolSession } : {},
3160
+ ...toolResult.messageCount !== void 0 ? { messageCount: toolResult.messageCount } : {},
3161
+ ...toolResult.tokensIn !== void 0 ? { tokensIn: toolResult.tokensIn } : {},
3162
+ ...toolResult.tokensOut !== void 0 ? { tokensOut: toolResult.tokensOut } : {},
3163
+ ...toolResult.costUsd !== void 0 ? { costUsd: toolResult.costUsd } : {}
3164
+ });
3165
+ const sessionTelemetry = {
3166
+ sessionPolicy: sessionContext.policy,
3167
+ sessionDecision: sessionContext.decision,
3168
+ sessionDecisionReason: sessionContext.reason,
3169
+ ...updatedToolSession ? { toolSessionId: updatedToolSession.toolSessionId } : {},
3170
+ ...updatedToolSession?.sessionGroupKey ? { sessionGroupKey: updatedToolSession.sessionGroupKey } : {}
3171
+ };
3172
+ if (artifacts) {
3173
+ const completionMessage = workItem.workKind === "planRevision" ? `${toolName} returned a revised plan for review.` : `${toolName} generated ${artifacts.length} brain artifact${artifacts.length === 1 ? "" : "s"}.`;
3174
+ const result = await apiClient.submitBrainGenerationResult(projectId, workItem.workItemId, {
3175
+ status: "completed",
3176
+ runnerId,
3177
+ idempotencyKey: `generation_${workItem.workItemId}_${randomUUID()}`,
3178
+ artifacts,
3179
+ tool: toolName,
3180
+ durationMs,
3181
+ ...sessionTelemetry,
3182
+ message: completionMessage
640
3183
  });
3184
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", runnerHeartbeatMetadata(toolConfig));
3185
+ console.log(workItem.workKind === "planRevision" ? "Revised plan returned for review." : `Generated ${result.documents.length} brain artifact${result.documents.length === 1 ? "" : "s"} for review.`);
3186
+ return { status: "completed", exitCode: 0 };
3187
+ }
3188
+ await apiClient.submitBrainGenerationResult(projectId, workItem.workItemId, {
3189
+ status: "failed",
3190
+ runnerId,
3191
+ idempotencyKey: `generation_${workItem.workItemId}_${randomUUID()}`,
3192
+ tool: toolName,
3193
+ durationMs,
3194
+ ...sessionTelemetry,
3195
+ message: `${toolName} did not produce valid brain artifacts.`,
3196
+ ...generationError ? { error: generationError } : {}
3197
+ });
3198
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", runnerHeartbeatMetadata(toolConfig));
3199
+ console.error(generationError ?? "Local runner generation failed.");
3200
+ return { status: "failed", exitCode: toolResult.exitCode || 1 };
3201
+ }
3202
+ async function createRunnerWorkPrompt(apiClient, projectId, workItem) {
3203
+ if (workItem.workKind !== "planRevision" || !workItem.reviewDocumentId) {
3204
+ return createWorkExecutionPrompt(workItem);
3205
+ }
3206
+ const [{ documents }, { messages }] = await Promise.all([
3207
+ apiClient.listBrainDocuments(projectId),
3208
+ apiClient.listPlanReviewMessages(projectId, workItem.reviewDocumentId)
3209
+ ]);
3210
+ const planDocument = documents.find((document) => document.documentId === workItem.reviewDocumentId || document.id === workItem.reviewDocumentId);
3211
+ return createWorkExecutionPrompt(workItem, {
3212
+ ...planDocument ? { planRevision: { planDocument, messages } } : {}
3213
+ });
3214
+ }
3215
+ async function loadPairedApiContext(root, apiUrl) {
3216
+ const metadata = await readProjectLink(root);
3217
+ if (!metadata) {
3218
+ return void 0;
3219
+ }
3220
+ const token = await new LocalCredentialStore().get(
3221
+ credentialKey(metadata.amistioAccountId, metadata.amistioProjectId, metadata.repositoryLinkId)
3222
+ );
3223
+ return {
3224
+ metadata,
3225
+ ...token ? { token } : {},
3226
+ client: new ApiClient({
3227
+ apiUrl,
3228
+ accountId: metadata.amistioAccountId,
3229
+ ...token ? { token } : {}
3230
+ })
3231
+ };
3232
+ }
3233
+ async function loadProjectNextAction(apiClient, projectId, repositoryLinkId, root) {
3234
+ const [{ workItems }, { documents }, { runners }] = await Promise.all([
3235
+ apiClient.listWorkItems(projectId),
3236
+ apiClient.listBrainDocuments(projectId),
3237
+ apiClient.listRunners(projectId).catch(() => ({ runners: [] }))
3238
+ ]);
3239
+ return computeProjectNextAction({
3240
+ projectId,
3241
+ repositoryLinks: [createCliRepositoryLink({ amistioAccountId: "local_runner", amistioProjectId: projectId, repositoryLinkId, defaultBranch: "main", lastSyncedRevision: 0 }, root)],
3242
+ documents,
3243
+ workItems,
3244
+ runnerHeartbeats: runners
3245
+ });
3246
+ }
3247
+ function createCliRepositoryLink(metadata, root) {
3248
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3249
+ return {
3250
+ id: metadata.repositoryLinkId,
3251
+ type: "repositoryLink",
3252
+ schemaVersion: 1,
3253
+ accountId: metadata.amistioAccountId,
3254
+ projectId: metadata.amistioProjectId,
3255
+ repositoryLinkId: metadata.repositoryLinkId,
3256
+ repoName: inferRepoName(root),
3257
+ repoFingerprint: createRepoFingerprint(metadata.amistioAccountId, metadata.amistioProjectId, metadata.repositoryLinkId),
3258
+ defaultBranch: metadata.defaultBranch,
3259
+ status: "active",
3260
+ createdAt: now,
3261
+ updatedAt: now
3262
+ };
3263
+ }
3264
+ function selectPromptWorkItem(workItems, workItemId) {
3265
+ if (workItemId) {
3266
+ return workItems.find((item) => item.workItemId === workItemId || item.id === workItemId);
3267
+ }
3268
+ return workItems.find((item) => isPromptReadyStatus(item.status));
3269
+ }
3270
+ function isPromptReadyStatus(status) {
3271
+ return status === "approved" || status === "running" || status === "blocked" || status === "changesRequested";
3272
+ }
3273
+ async function prepareToolSession({
3274
+ apiClient,
3275
+ projectId,
3276
+ repositoryLinkId,
3277
+ runnerId,
3278
+ sessionPolicy,
3279
+ supportsSessionReuse,
3280
+ resumabilityScope,
3281
+ toolName,
3282
+ model,
3283
+ workItem
3284
+ }) {
3285
+ const { toolSessions } = await apiClient.listToolSessions(projectId);
3286
+ const selection = selectToolSession({
3287
+ policy: sessionPolicy,
3288
+ workItem,
3289
+ sessions: toolSessions,
3290
+ toolName,
3291
+ runnerId,
3292
+ repositoryLinkId,
3293
+ supportsSessionReuse
3294
+ });
3295
+ if (selection.decision === "skipped") {
3296
+ return selection;
3297
+ }
3298
+ if (selection.toolSession) {
3299
+ const { toolSession: toolSession2 } = await apiClient.updateToolSession(projectId, selection.toolSession.toolSessionId, {
3300
+ status: "active",
3301
+ runnerId,
3302
+ lastWorkItemId: workItem.workItemId,
3303
+ reusePolicy: sessionPolicy
3304
+ });
3305
+ return { ...selection, toolSession: toolSession2 };
3306
+ }
3307
+ const toolSessionId = `tool_session_${randomUUID()}`;
3308
+ const { toolSession } = await apiClient.createToolSession(projectId, {
3309
+ toolSessionId,
3310
+ repositoryLinkId,
3311
+ tool: toolName,
3312
+ provider: toolName,
3313
+ ...model ? { model } : {},
3314
+ resumabilityScope: supportsSessionReuse ? resumabilityScope : "none",
3315
+ title: workItem.title,
3316
+ summary: selection.reason,
3317
+ status: "active",
3318
+ runnerId,
3319
+ lastWorkItemId: workItem.workItemId,
3320
+ messageCount: 0,
3321
+ reusePolicy: sessionPolicy,
3322
+ ...workItem.sessionGroupKey ? { sessionGroupKey: workItem.sessionGroupKey } : {}
3323
+ });
3324
+ return { ...selection, toolSession };
3325
+ }
3326
+ async function finalizeToolSession({
3327
+ apiClient,
3328
+ costUsd,
3329
+ messageCount,
3330
+ projectId,
3331
+ runnerId,
3332
+ session,
3333
+ status,
3334
+ stdout,
3335
+ tokensIn,
3336
+ tokensOut,
3337
+ workItemId
3338
+ }) {
3339
+ if (!session) {
3340
+ return void 0;
3341
+ }
3342
+ const summary = summarizeToolOutput(stdout) ?? session.summary;
3343
+ const { toolSession } = await apiClient.updateToolSession(projectId, session.toolSessionId, {
3344
+ status: status === "completed" ? "open" : "blocked",
3345
+ runnerId,
3346
+ lastWorkItemId: workItemId,
3347
+ messageCount: (session.messageCount ?? 0) + (messageCount ?? 1),
3348
+ ...summary ? { summary } : {},
3349
+ ...tokensIn !== void 0 ? { estimatedInputTokens: (session.estimatedInputTokens ?? 0) + tokensIn } : {},
3350
+ ...tokensOut !== void 0 ? { estimatedOutputTokens: (session.estimatedOutputTokens ?? 0) + tokensOut } : {},
3351
+ ...costUsd !== void 0 ? { costUsd: (session.costUsd ?? 0) + costUsd } : {},
3352
+ ...status === "failed" ? { closedReason: "Last run failed or returned a non-zero exit code." } : {}
3353
+ });
3354
+ return toolSession;
3355
+ }
3356
+ async function markToolSessionBlocked(apiClient, projectId, session, reason) {
3357
+ if (!session) {
3358
+ return;
3359
+ }
3360
+ await apiClient.updateToolSession(projectId, session.toolSessionId, {
3361
+ status: "blocked",
3362
+ summary: reason,
3363
+ closedReason: reason
3364
+ });
641
3365
  }
642
3366
  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";
3367
+ if (policy === "new") {
3368
+ return "forcedNew";
3369
+ }
3370
+ if (typeof policy === "string" && policy.startsWith("continue:")) {
3371
+ return "forcedContinue";
3372
+ }
3373
+ return "created";
650
3374
  }
651
3375
  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;
3376
+ const trimmed = value.trim();
3377
+ if (!trimmed) {
3378
+ return void 0;
3379
+ }
3380
+ return trimmed.length > 300 ? `${trimmed.slice(0, 300)}...` : trimmed;
657
3381
  }
658
- function errorMessage(error) {
659
- return error instanceof Error ? error.message : String(error);
3382
+ function errorMessage2(error) {
3383
+ return error instanceof Error ? error.message : String(error);
660
3384
  }
661
3385
  function truncateLogExcerpt(value) {
662
- const trimmed = value.trim();
663
- return trimmed.length > 1200 ? `${trimmed.slice(0, 1200)}...` : trimmed;
3386
+ const trimmed = value.trim();
3387
+ return trimmed.length > 1200 ? `${trimmed.slice(0, 1200)}...` : trimmed;
664
3388
  }
665
3389
  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;
3390
+ const parsed = Number(value);
3391
+ if (!Number.isInteger(parsed) || parsed <= 0) {
3392
+ throw new Error(`Expected a positive integer, received ${value}.`);
3393
+ }
3394
+ return parsed;
671
3395
  }
672
3396
  function inferRepoName(root) {
673
- return path.basename(path.resolve(root)) || "repository";
3397
+ return path9.basename(path9.resolve(root)) || "repository";
674
3398
  }
675
3399
  function createRepoFingerprint(accountId, projectId, repositoryLinkId) {
676
- return createHash("sha256").update(`${accountId}:${projectId}:${repositoryLinkId}`).digest("hex");
3400
+ return createHash3("sha256").update(`${accountId}:${projectId}:${repositoryLinkId}`).digest("hex");
677
3401
  }
678
3402
  function defaultApiUrl() {
679
- const envApiUrl = process.env[AMISTIO_API_URL_ENV]?.trim();
680
- return envApiUrl || officialAmistioApiUrl;
3403
+ const envApiUrl = process.env[AMISTIO_API_URL_ENV]?.trim();
3404
+ return envApiUrl || officialAmistioApiUrl;
681
3405
  }
682
3406
  function apiUrlOverrideWasRequested(command) {
683
- return command.getOptionValueSource("apiUrl") === "cli" || Boolean(process.env[AMISTIO_API_URL_ENV]?.trim());
3407
+ return command.getOptionValueSource("apiUrl") === "cli" || Boolean(process.env[AMISTIO_API_URL_ENV]?.trim());
684
3408
  }
685
3409
  function formatApiUrlFlag(apiUrl) {
686
- return isOfficialAmistioApiUrl(apiUrl) ? "" : ` --api-url ${formatShellArg(apiUrl)}`;
3410
+ return isOfficialAmistioApiUrl(apiUrl) ? "" : ` --api-url ${formatShellArg(apiUrl)}`;
687
3411
  }
688
3412
  function formatShellArg(value) {
689
- return /^[A-Za-z0-9_./:@-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
3413
+ return /^[A-Za-z0-9_./:@-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
3414
+ }
3415
+ async function resolveRunnerToolConfig({ apiClient, explicitModel, explicitTool, projectId, toolCommand }) {
3416
+ const capabilities = toRunnerToolCapabilities(await detectLocalTools());
3417
+ if (toolCommand) {
3418
+ return {
3419
+ ready: true,
3420
+ tool: explicitTool ?? "auto",
3421
+ capabilities,
3422
+ source: "cli",
3423
+ status: "custom",
3424
+ effectiveTool: "custom",
3425
+ ...explicitTool && explicitTool !== "none" && explicitTool !== "auto" && isLocalToolName(explicitTool) ? { requestedTool: explicitTool } : explicitTool === "auto" ? { requestedTool: "auto" } : {},
3426
+ ...explicitModel ? { model: explicitModel } : {},
3427
+ message: "Using local custom tool command."
3428
+ };
3429
+ }
3430
+ if (explicitTool === "none") {
3431
+ if (explicitModel) {
3432
+ return unavailableToolConfig({ capabilities, source: "cli", status: "modelUnsupported", message: "--model cannot be used with --tool none.", tool: "none", model: explicitModel });
3433
+ }
3434
+ return { ready: true, tool: "none", capabilities, source: "cli", status: "none", message: "No local tool selected." };
3435
+ }
3436
+ if (explicitTool && explicitTool !== "auto" && !isLocalToolName(explicitTool)) {
3437
+ return unavailableToolConfig({ capabilities, source: "cli", status: "unavailable", message: `Unsupported local tool: ${explicitTool}.`, tool: explicitTool, ...explicitModel ? { model: explicitModel } : {} });
3438
+ }
3439
+ const remotePreference = explicitTool || explicitModel ? void 0 : await apiClient.getRunnerPreferences(projectId).then((response) => response.effective).catch(() => void 0);
3440
+ const requestedTool = explicitTool ?? remotePreference?.tool ?? "auto";
3441
+ const model = explicitModel ?? remotePreference?.model;
3442
+ const source = explicitTool || explicitModel ? "cli" : remotePreference?.source ?? "default";
3443
+ return resolveRequestedTool({ capabilities, requestedTool, source, ...model ? { model } : {} });
3444
+ }
3445
+ function resolveRequestedTool({ capabilities, model, requestedTool, source }) {
3446
+ if (requestedTool === "auto") {
3447
+ const candidate = capabilities.find((capability2) => capability2.available && (!model || capability2.supportsModelSelection));
3448
+ if (!candidate) {
3449
+ return unavailableToolConfig({
3450
+ capabilities,
3451
+ source,
3452
+ status: model ? "modelUnsupported" : "unavailable",
3453
+ requestedTool,
3454
+ tool: "auto",
3455
+ ...model ? { model } : {},
3456
+ message: model ? "No installed local tool can honor the selected model." : "No supported local AI tool is installed."
3457
+ });
3458
+ }
3459
+ return { ready: true, tool: "auto", capabilities, source, status: "resolved", requestedTool, effectiveTool: candidate.name, ...model ? { model } : {} };
3460
+ }
3461
+ const capability = capabilities.find((candidate) => candidate.name === requestedTool);
3462
+ if (!capability?.available) {
3463
+ return unavailableToolConfig({ capabilities, source, status: "unavailable", requestedTool, tool: requestedTool, ...model ? { model } : {}, message: `${requestedTool} is selected but is not available on this runner.` });
3464
+ }
3465
+ if (model && !capability.supportsModelSelection) {
3466
+ return unavailableToolConfig({ capabilities, source, status: "modelUnsupported", requestedTool, effectiveTool: requestedTool, tool: requestedTool, model, message: `${requestedTool} is available but does not support Amistio model selection yet.` });
3467
+ }
3468
+ return { ready: true, tool: requestedTool, capabilities, source, status: "resolved", requestedTool, effectiveTool: requestedTool, ...model ? { model } : {} };
3469
+ }
3470
+ function unavailableToolConfig(input) {
3471
+ return {
3472
+ ready: false,
3473
+ tool: input.tool,
3474
+ capabilities: input.capabilities,
3475
+ source: input.source,
3476
+ status: input.status,
3477
+ message: input.message,
3478
+ ...input.requestedTool ? { requestedTool: input.requestedTool } : {},
3479
+ ...input.effectiveTool ? { effectiveTool: input.effectiveTool } : {},
3480
+ ...input.model ? { model: input.model } : {}
3481
+ };
3482
+ }
3483
+ function toRunnerToolCapabilities(tools) {
3484
+ return tools.map((tool) => ({
3485
+ name: tool.name,
3486
+ description: tool.description,
3487
+ available: tool.available,
3488
+ sdkAvailable: tool.sdkAvailable,
3489
+ commandAvailable: tool.commandAvailable,
3490
+ execution: tool.execution,
3491
+ supportsSessionReuse: tool.supportsSessionReuse,
3492
+ resumabilityScope: tool.resumabilityScope,
3493
+ supportsModelSelection: tool.supportsModelSelection
3494
+ }));
3495
+ }
3496
+ function buildBackgroundRunnerArgs(options) {
3497
+ const args = [
3498
+ "run",
3499
+ "--watch",
3500
+ "--api-url",
3501
+ options.apiUrl,
3502
+ "--runner-id",
3503
+ options.runnerId,
3504
+ "--root",
3505
+ path9.resolve(options.root),
3506
+ "--session",
3507
+ options.session,
3508
+ "--interval-seconds",
3509
+ String(options.intervalSeconds)
3510
+ ];
3511
+ if (options.tool) {
3512
+ args.push("--tool", options.tool);
3513
+ }
3514
+ if (options.toolCommand) {
3515
+ args.push("--tool-command", options.toolCommand);
3516
+ }
3517
+ if (options.model) {
3518
+ args.push("--model", options.model);
3519
+ }
3520
+ if (options.maxIterations !== void 0) {
3521
+ args.push("--max-iterations", String(options.maxIterations));
3522
+ }
3523
+ if (!options.stream) {
3524
+ args.push("--no-stream");
3525
+ }
3526
+ return args;
3527
+ }
3528
+ function runnerHeartbeatMetadata(toolConfig, mode = currentRunnerMode()) {
3529
+ return {
3530
+ version: CLI_VERSION,
3531
+ mode,
3532
+ hostname: os5.hostname(),
3533
+ ...toolConfig?.capabilities ? { capabilities: toolConfig.capabilities } : {},
3534
+ ...toolConfig?.requestedTool ? { requestedTool: toolConfig.requestedTool } : {},
3535
+ ...toolConfig?.effectiveTool ? { effectiveTool: toolConfig.effectiveTool } : {},
3536
+ ...toolConfig?.model ? { effectiveModel: toolConfig.model } : {},
3537
+ ...toolConfig?.source ? { preferenceSource: toolConfig.source } : {},
3538
+ ...toolConfig?.status ? { preferenceStatus: toolConfig.status } : {},
3539
+ ...toolConfig?.message ? { preferenceMessage: toolConfig.message } : {}
3540
+ };
690
3541
  }
691
3542
  async function delay(milliseconds) {
692
- await new Promise((resolve) => setTimeout(resolve, milliseconds));
3543
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
693
3544
  }
694
3545
  program.parseAsync().catch((error) => {
695
- console.error(error instanceof Error ? error.message : String(error));
696
- process.exitCode = 1;
3546
+ console.error(error instanceof Error ? error.message : String(error));
3547
+ process.exitCode = 1;
697
3548
  });
698
- //# sourceMappingURL=index.js.map
3549
+ //# sourceMappingURL=index.js.map