@amistio/cli 0.1.1 → 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.
package/dist/index.js CHANGED
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { createHash as createHash2, randomUUID } from "node:crypto";
5
- import { writeFile as writeFile7 } from "node:fs/promises";
6
- import path8 from "node:path";
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";
7
9
  import { Command } from "commander";
8
10
 
9
11
  // ../shared/src/schemas.ts
@@ -24,6 +26,9 @@ var itemTypeSchema = z.enum([
24
26
  "workItem",
25
27
  "runnerHeartbeat",
26
28
  "runnerExecutionLog",
29
+ "runnerCredential",
30
+ "runnerCommand",
31
+ "planReviewMessage",
27
32
  "toolSession",
28
33
  "activityEvent",
29
34
  "pairingSession"
@@ -60,7 +65,7 @@ var workStatusSchema = z.enum([
60
65
  ]);
61
66
  var sourceSchema = z.enum(["web", "repo", "generated", "runner"]);
62
67
  var executionModeSchema = z.enum(["localRunner", "cloudSandbox"]);
63
- var workKindSchema = z.enum(["brainGeneration", "implementation"]);
68
+ var workKindSchema = z.enum(["brainGeneration", "implementation", "planRevision"]);
64
69
  var generatedDraftStatusSchema = z.enum(["queued", "generating", "reviewing", "approved", "rejected", "changesRequested", "failed"]);
65
70
  var generatedBrainArtifactSchema = z.object({
66
71
  documentType: documentTypeSchema,
@@ -76,6 +81,27 @@ var sessionPolicySchema = z.union([z.enum(["auto", "new", "none"]), z.string().r
76
81
  var sessionDecisionSchema = z.enum(["created", "continued", "forcedNew", "forcedContinue", "notSupported", "skipped"]);
77
82
  var toolSessionStatusSchema = z.enum(["open", "active", "closed", "archived", "blocked", "unavailable"]);
78
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
+ });
79
105
  var repositoryLinkSourceSchema = z.enum(["web", "cli"]);
80
106
  var repositoryCloneStatusSchema = z.enum(["notCloned", "cloned", "validated", "failed"]);
81
107
  var baseItemSchema = z.object({
@@ -182,6 +208,10 @@ var workItemSchema = baseItemSchema.extend({
182
208
  approvedBy: z.string().min(1).optional(),
183
209
  sourceWish: z.string().min(1).optional(),
184
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(),
185
215
  claimedByRunnerId: z.string().optional(),
186
216
  leaseExpiresAt: isoDateTimeSchema.optional(),
187
217
  attempt: z.number().int().nonnegative().default(0),
@@ -201,8 +231,25 @@ var runnerHeartbeatItemSchema = baseItemSchema.extend({
201
231
  repositoryLinkId: z.string().min(1),
202
232
  status: z.enum(["online", "offline", "running", "blocked"]),
203
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(),
204
244
  lastSeenAt: isoDateTimeSchema
205
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
+ });
206
253
  var runnerExecutionLogItemSchema = baseItemSchema.extend({
207
254
  type: z.literal("runnerExecutionLog"),
208
255
  projectId: z.string().min(1),
@@ -232,6 +279,57 @@ var runnerExecutionLogItemSchema = baseItemSchema.extend({
232
279
  message: z.string().optional(),
233
280
  error: z.string().optional()
234
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
+ });
235
333
  var toolSessionItemSchema = baseItemSchema.extend({
236
334
  type: z.literal("toolSession"),
237
335
  projectId: z.string().min(1),
@@ -275,7 +373,11 @@ var projectItemUnionSchema = z.discriminatedUnion("type", [
275
373
  syncConflictItemSchema,
276
374
  workItemSchema,
277
375
  runnerHeartbeatItemSchema,
376
+ runnerSettingsItemSchema,
278
377
  runnerExecutionLogItemSchema,
378
+ runnerCredentialItemSchema,
379
+ runnerCommandItemSchema,
380
+ planReviewMessageItemSchema,
279
381
  toolSessionItemSchema
280
382
  ]);
281
383
 
@@ -461,6 +563,211 @@ function decideSyncState(state) {
461
563
  return "clean";
462
564
  }
463
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
+
464
771
  // src/bootstrap.ts
465
772
  import { execFile } from "node:child_process";
466
773
  import { mkdir, readdir, stat } from "node:fs/promises";
@@ -544,6 +851,16 @@ var LocalCredentialStore = class {
544
851
  const data = await this.read();
545
852
  return data.credentials[key];
546
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
+ }
547
864
  async read() {
548
865
  try {
549
866
  return JSON.parse(await readFile(this.filePath, "utf8"));
@@ -699,6 +1016,14 @@ var ApiClient = class {
699
1016
  { method: "GET" }
700
1017
  );
701
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
+ }
702
1027
  async pushBrainDocuments(projectId, documents) {
703
1028
  return this.request(
704
1029
  `/projects/${projectId}/brain-documents`,
@@ -709,13 +1034,58 @@ var ApiClient = class {
709
1034
  }
710
1035
  );
711
1036
  }
712
- async sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, status, version) {
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 ?? {};
713
1083
  return this.request(
714
1084
  `/projects/${projectId}/runners`,
715
1085
  z3.object({ runner: runnerHeartbeatItemSchema }),
716
1086
  {
717
1087
  method: "POST",
718
- body: JSON.stringify({ runnerId, repositoryLinkId, status, ...version ? { version } : {}, lastSeenAt: (/* @__PURE__ */ new Date()).toISOString() })
1088
+ body: JSON.stringify({ runnerId, repositoryLinkId, status, ...heartbeatMetadata, lastSeenAt: (/* @__PURE__ */ new Date()).toISOString() })
719
1089
  }
720
1090
  );
721
1091
  }
@@ -808,8 +1178,8 @@ var toolSessionMutationSchema = z3.object({
808
1178
  });
809
1179
  function resolveApiUrl(apiUrl, urlPath) {
810
1180
  const base = apiUrl.endsWith("/") ? apiUrl.slice(0, -1) : apiUrl;
811
- const path9 = urlPath.startsWith("/") ? urlPath : `/${urlPath}`;
812
- return new URL(`${base}${path9}`);
1181
+ const path10 = urlPath.startsWith("/") ? urlPath : `/${urlPath}`;
1182
+ return new URL(`${base}${path10}`);
813
1183
  }
814
1184
 
815
1185
  // src/orchestrator.ts
@@ -878,7 +1248,7 @@ import { spawn } from "node:child_process";
878
1248
  import { mkdtemp, rm, writeFile as writeFile4 } from "node:fs/promises";
879
1249
  import os2 from "node:os";
880
1250
  import path5 from "node:path";
881
- var localToolNames = ["opencode", "claude", "codex", "copilot", "gemini", "aider", "cursor-agent"];
1251
+ var localToolNames = runnerToolNames;
882
1252
  var localToolAdapters = [
883
1253
  {
884
1254
  name: "opencode",
@@ -931,6 +1301,7 @@ var localToolAdapters = [
931
1301
  description: "GitHub Copilot SDK adapter using @github/copilot-sdk.",
932
1302
  sdkPackageName: "@github/copilot-sdk",
933
1303
  sdkDisplayCommand: "@github/copilot-sdk CopilotClient.createSession().sendAndWait()",
1304
+ supportsModelSelection: true,
934
1305
  supportsSessionReuse: false,
935
1306
  resumabilityScope: "none",
936
1307
  runWithSdk: runCopilotSdk
@@ -988,7 +1359,8 @@ async function detectLocalTools() {
988
1359
  commandAvailable,
989
1360
  execution: sdkAvailable ? "sdk" : commandAvailable ? "command" : "unavailable",
990
1361
  supportsSessionReuse: Boolean(adapter.supportsSessionReuse),
991
- resumabilityScope: adapter.resumabilityScope ?? "none"
1362
+ resumabilityScope: adapter.resumabilityScope ?? "none",
1363
+ supportsModelSelection: Boolean(adapter.supportsModelSelection)
992
1364
  };
993
1365
  })
994
1366
  );
@@ -1002,13 +1374,14 @@ async function runLocalTool(options) {
1002
1374
  rootDir: options.rootDir,
1003
1375
  prompt: options.prompt,
1004
1376
  promptFilePath,
1005
- tool: options.tool ?? "auto"
1377
+ tool: options.tool ?? "auto",
1378
+ ...options.model ? { model: options.model } : {}
1006
1379
  };
1007
1380
  if (options.toolCommand) {
1008
1381
  runnerOptions.toolCommand = options.toolCommand;
1009
1382
  }
1010
- const runner = await createToolRunner(runnerOptions);
1011
- const result = await executeToolRunner(runner, {
1383
+ const runner2 = await createToolRunner(runnerOptions);
1384
+ const result = await executeToolRunner(runner2, {
1012
1385
  rootDir: options.rootDir,
1013
1386
  prompt: options.prompt,
1014
1387
  promptFilePath,
@@ -1016,10 +1389,11 @@ async function runLocalTool(options) {
1016
1389
  ...options.session ? { session: options.session } : {}
1017
1390
  });
1018
1391
  return {
1019
- toolName: runner.toolName,
1020
- displayCommand: runner.kind === "sdk" ? runner.displayCommand : runner.invocation.displayCommand,
1021
- supportsSessionReuse: runner.kind === "sdk" ? Boolean(runner.adapter.supportsSessionReuse) : false,
1022
- resumabilityScope: runner.kind === "sdk" ? runner.adapter.resumabilityScope ?? "none" : "none",
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 } : {},
1023
1397
  ...result
1024
1398
  };
1025
1399
  } finally {
@@ -1032,17 +1406,19 @@ async function createToolRunPreview(options) {
1032
1406
  rootDir: options.rootDir,
1033
1407
  prompt: options.prompt,
1034
1408
  promptFilePath,
1035
- tool: options.tool ?? "auto"
1409
+ tool: options.tool ?? "auto",
1410
+ ...options.model ? { model: options.model } : {}
1036
1411
  };
1037
1412
  if (options.toolCommand) {
1038
1413
  runnerOptions.toolCommand = options.toolCommand;
1039
1414
  }
1040
- const runner = await createToolRunner(runnerOptions);
1415
+ const runner2 = await createToolRunner(runnerOptions);
1041
1416
  return {
1042
- toolName: runner.toolName,
1043
- displayCommand: runner.kind === "sdk" ? runner.displayCommand : runner.invocation.displayCommand,
1044
- supportsSessionReuse: runner.kind === "sdk" ? Boolean(runner.adapter.supportsSessionReuse) : false,
1045
- resumabilityScope: runner.kind === "sdk" ? runner.adapter.resumabilityScope ?? "none" : "none"
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 } : {}
1046
1422
  };
1047
1423
  }
1048
1424
  function createCustomToolInvocation(commandTemplate, input) {
@@ -1067,7 +1443,10 @@ async function createToolRunner(options) {
1067
1443
  if (tool === "none") {
1068
1444
  throw new Error("No local tool selected. Use --tool auto, a supported tool name, or --tool-command.");
1069
1445
  }
1070
- const adapter = tool === "auto" ? await selectFirstAvailableAdapter() : await selectRequestedAdapter(tool);
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
+ }
1071
1450
  if (adapter.runWithSdk && await isSdkAvailable(adapter)) {
1072
1451
  return {
1073
1452
  toolName: adapter.name,
@@ -1085,17 +1464,17 @@ async function createToolRunner(options) {
1085
1464
  }
1086
1465
  throw new Error(`The ${adapter.name} SDK or executable was not found. Install the SDK/runtime or pass --tool-command.`);
1087
1466
  }
1088
- async function executeToolRunner(runner, input) {
1089
- if (runner.kind === "command") {
1090
- return executeToolInvocation(runner.invocation, input.rootDir, input.streamOutput);
1467
+ async function executeToolRunner(runner2, input) {
1468
+ if (runner2.kind === "command") {
1469
+ return executeToolInvocation(runner2.invocation, input.rootDir, input.streamOutput);
1091
1470
  }
1092
1471
  try {
1093
- return await runner.adapter.runWithSdk(input);
1472
+ return await runner2.adapter.runWithSdk(input);
1094
1473
  } catch (error) {
1095
- if (runner.adapter.buildInvocation && runner.adapter.executable && await commandExists(runner.adapter.executable)) {
1096
- const fallback = runner.adapter.buildInvocation(input);
1474
+ if (runner2.adapter.buildInvocation && runner2.adapter.executable && await commandExists(runner2.adapter.executable)) {
1475
+ const fallback = runner2.adapter.buildInvocation(input);
1097
1476
  const result = await executeToolInvocation(fallback, input.rootDir, input.streamOutput);
1098
- const sdkFailure = `SDK execution for ${runner.adapter.name} failed, fell back to ${fallback.displayCommand}: ${errorMessage(error)}`;
1477
+ const sdkFailure = `SDK execution for ${runner2.adapter.name} failed, fell back to ${fallback.displayCommand}: ${errorMessage(error)}`;
1099
1478
  return {
1100
1479
  ...result,
1101
1480
  stderr: result.stderr ? `${sdkFailure}
@@ -1111,8 +1490,11 @@ function normalizeToolRequest(value) {
1111
1490
  }
1112
1491
  throw new Error(`Unsupported local tool: ${value}. Supported tools: auto, none, ${localToolNames.join(", ")}.`);
1113
1492
  }
1114
- async function selectFirstAvailableAdapter() {
1493
+ async function selectFirstAvailableAdapter(requiresModelSelection = false) {
1115
1494
  for (const adapter of localToolAdapters) {
1495
+ if (requiresModelSelection && !adapter.supportsModelSelection) {
1496
+ continue;
1497
+ }
1116
1498
  const sdkAvailable = await isSdkAvailable(adapter);
1117
1499
  const commandAvailable = adapter.executable ? await commandExists(adapter.executable) : false;
1118
1500
  if (sdkAvailable || commandAvailable) {
@@ -1120,7 +1502,7 @@ async function selectFirstAvailableAdapter() {
1120
1502
  }
1121
1503
  }
1122
1504
  throw new Error(
1123
- `No supported local AI tool was found. Install one of ${localToolNames.join(", ")} or pass --tool-command "your-tool --prompt-file {promptFile}".`
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}".`
1124
1506
  );
1125
1507
  }
1126
1508
  async function selectRequestedAdapter(tool) {
@@ -1292,7 +1674,7 @@ async function runCopilotSdk(input) {
1292
1674
  await client.start();
1293
1675
  const session = await client.createSession({
1294
1676
  clientName: "amistio-cli",
1295
- model: process.env.AMISTIO_COPILOT_MODEL ?? "gpt-5",
1677
+ model: input.model ?? process.env.AMISTIO_COPILOT_MODEL ?? "gpt-5",
1296
1678
  workingDirectory: input.rootDir,
1297
1679
  enableConfigDiscovery: true,
1298
1680
  streaming: input.streamOutput,
@@ -1334,6 +1716,187 @@ function shellQuote(value) {
1334
1716
  return `'${value.replaceAll("'", "'\\''")}'`;
1335
1717
  }
1336
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
+
1337
1900
  // src/session-policy.ts
1338
1901
  var maxIdleMs = 24 * 60 * 60 * 1e3;
1339
1902
  var maxTotalMs = 7 * 24 * 60 * 60 * 1e3;
@@ -1455,8 +2018,8 @@ function tokens(value) {
1455
2018
  }
1456
2019
 
1457
2020
  // src/sync.ts
1458
- import { mkdir as mkdir5, readdir as readdir2, readFile as readFile3, stat as stat3, writeFile as writeFile5 } from "node:fs/promises";
1459
- import path6 from "node:path";
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";
1460
2023
  var syncRoots = ["architecture", "context", "decisions", "features", "memory", "plans", "prompts", "workflows"];
1461
2024
  async function collectSyncStatus(rootDir, webDocuments = []) {
1462
2025
  const localDocuments = await readLocalSyncedDocuments(rootDir);
@@ -1533,7 +2096,7 @@ async function readLocalSyncedDocuments(rootDir) {
1533
2096
  const markdownFiles = await findMarkdownFiles(rootDir);
1534
2097
  const documents = [];
1535
2098
  for (const fullPath of markdownFiles) {
1536
- const raw = await readFile3(fullPath, "utf8");
2099
+ const raw = await readFile4(fullPath, "utf8");
1537
2100
  const parsed = parseSyncedMarkdown(raw);
1538
2101
  if (!parsed) {
1539
2102
  continue;
@@ -1574,8 +2137,8 @@ async function materializeBrainDocuments(rootDir, documents, options = {}) {
1574
2137
  result.skipped.push(document.repoPath);
1575
2138
  continue;
1576
2139
  }
1577
- await mkdir5(path6.dirname(fullPath), { recursive: true });
1578
- await writeFile5(fullPath, createSyncedDocumentMarkdown(document), "utf8");
2140
+ await mkdir6(path7.dirname(fullPath), { recursive: true });
2141
+ await writeFile6(fullPath, createSyncedDocumentMarkdown(document), "utf8");
1579
2142
  result.written.push(document.repoPath);
1580
2143
  }
1581
2144
  return result;
@@ -1636,7 +2199,7 @@ function parseSyncedMarkdown(content) {
1636
2199
  }
1637
2200
  async function readExistingSyncedDocument(fullPath) {
1638
2201
  try {
1639
- const raw = await readFile3(fullPath, "utf8");
2202
+ const raw = await readFile4(fullPath, "utf8");
1640
2203
  const parsed = parseSyncedMarkdown(raw);
1641
2204
  if (!parsed) {
1642
2205
  return { exists: true };
@@ -1661,7 +2224,7 @@ async function readExistingSyncedDocument(fullPath) {
1661
2224
  async function findMarkdownFiles(rootDir) {
1662
2225
  const files = [];
1663
2226
  for (const syncRoot of syncRoots) {
1664
- const fullRoot = path6.join(rootDir, syncRoot);
2227
+ const fullRoot = path7.join(rootDir, syncRoot);
1665
2228
  if (!await exists2(fullRoot)) {
1666
2229
  continue;
1667
2230
  }
@@ -1670,8 +2233,8 @@ async function findMarkdownFiles(rootDir) {
1670
2233
  return files;
1671
2234
  }
1672
2235
  async function walkMarkdownFiles(directory, files) {
1673
- for (const entry of await readdir2(directory, { withFileTypes: true })) {
1674
- const fullPath = path6.join(directory, entry.name);
2236
+ for (const entry of await readdir3(directory, { withFileTypes: true })) {
2237
+ const fullPath = path7.join(directory, entry.name);
1675
2238
  if (entry.isDirectory()) {
1676
2239
  await walkMarkdownFiles(fullPath, files);
1677
2240
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
@@ -1680,30 +2243,30 @@ async function walkMarkdownFiles(directory, files) {
1680
2243
  }
1681
2244
  }
1682
2245
  function safeRepoPath(rootDir, repoPath) {
1683
- if (path6.isAbsolute(repoPath)) {
2246
+ if (path7.isAbsolute(repoPath)) {
1684
2247
  throw new Error(`Refusing to use absolute repo path: ${repoPath}`);
1685
2248
  }
1686
- const normalized = path6.normalize(repoPath);
1687
- if (normalized === ".." || normalized.startsWith(`..${path6.sep}`)) {
2249
+ const normalized = path7.normalize(repoPath);
2250
+ if (normalized === ".." || normalized.startsWith(`..${path7.sep}`)) {
1688
2251
  throw new Error(`Refusing to use path outside the repository: ${repoPath}`);
1689
2252
  }
1690
- const root = path6.resolve(rootDir);
1691
- const fullPath = path6.resolve(root, normalized);
1692
- if (!fullPath.startsWith(`${root}${path6.sep}`)) {
2253
+ const root = path7.resolve(rootDir);
2254
+ const fullPath = path7.resolve(root, normalized);
2255
+ if (!fullPath.startsWith(`${root}${path7.sep}`)) {
1693
2256
  throw new Error(`Refusing to use path outside the repository: ${repoPath}`);
1694
2257
  }
1695
2258
  return fullPath;
1696
2259
  }
1697
2260
  function isControlPlanePath(repoPath) {
1698
- const normalized = path6.normalize(repoPath);
1699
- return syncRoots.some((syncRoot) => normalized === syncRoot || normalized.startsWith(`${syncRoot}${path6.sep}`));
2261
+ const normalized = path7.normalize(repoPath);
2262
+ return syncRoots.some((syncRoot) => normalized === syncRoot || normalized.startsWith(`${syncRoot}${path7.sep}`));
1700
2263
  }
1701
2264
  function toRepoPath(rootDir, fullPath) {
1702
- return path6.relative(rootDir, fullPath).split(path6.sep).join("/");
2265
+ return path7.relative(rootDir, fullPath).split(path7.sep).join("/");
1703
2266
  }
1704
2267
  function inferTitle(content, repoPath) {
1705
2268
  const heading = content.split("\n").find((line) => line.startsWith("# "))?.replace(/^#\s+/, "").trim();
1706
- return heading || path6.basename(repoPath, path6.extname(repoPath));
2269
+ return heading || path7.basename(repoPath, path7.extname(repoPath));
1707
2270
  }
1708
2271
  function parseFrontmatterFromSyncedDocument(frontmatter) {
1709
2272
  return {
@@ -1724,9 +2287,9 @@ async function exists2(filePath) {
1724
2287
  }
1725
2288
 
1726
2289
  // src/tool-session-store.ts
1727
- import { mkdir as mkdir6, readFile as readFile4, writeFile as writeFile6 } from "node:fs/promises";
1728
- import os3 from "node:os";
1729
- import path7 from "node:path";
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";
1730
2293
  var LocalToolSessionStore = class {
1731
2294
  constructor(filePath = defaultSessionStorePath()) {
1732
2295
  this.filePath = filePath;
@@ -1740,12 +2303,12 @@ var LocalToolSessionStore = class {
1740
2303
  async setProviderSessionId(toolSessionId, toolName, providerSessionId) {
1741
2304
  const data = await this.read();
1742
2305
  data[toolSessionId] = { toolName, providerSessionId, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
1743
- await mkdir6(path7.dirname(this.filePath), { recursive: true });
1744
- await writeFile6(this.filePath, JSON.stringify(data, null, 2), "utf8");
2306
+ await mkdir7(path8.dirname(this.filePath), { recursive: true });
2307
+ await writeFile7(this.filePath, JSON.stringify(data, null, 2), "utf8");
1745
2308
  }
1746
2309
  async read() {
1747
2310
  try {
1748
- return JSON.parse(await readFile4(this.filePath, "utf8"));
2311
+ return JSON.parse(await readFile5(this.filePath, "utf8"));
1749
2312
  } catch {
1750
2313
  return {};
1751
2314
  }
@@ -1753,21 +2316,24 @@ var LocalToolSessionStore = class {
1753
2316
  };
1754
2317
  function defaultSessionStorePath() {
1755
2318
  if (process.platform === "darwin") {
1756
- return path7.join(os3.homedir(), "Library", "Application Support", "Amistio", "tool-sessions.json");
2319
+ return path8.join(os4.homedir(), "Library", "Application Support", "Amistio", "tool-sessions.json");
1757
2320
  }
1758
2321
  if (process.platform === "win32") {
1759
- return path7.join(process.env.APPDATA ?? os3.homedir(), "Amistio", "tool-sessions.json");
2322
+ return path8.join(process.env.APPDATA ?? os4.homedir(), "Amistio", "tool-sessions.json");
1760
2323
  }
1761
- return path7.join(process.env.XDG_STATE_HOME ?? path7.join(os3.homedir(), ".local", "state"), "amistio", "tool-sessions.json");
2324
+ return path8.join(process.env.XDG_STATE_HOME ?? path8.join(os4.homedir(), ".local", "state"), "amistio", "tool-sessions.json");
1762
2325
  }
1763
2326
 
1764
2327
  // src/work-runner.ts
1765
2328
  var generationResultStart = "AMISTIO_BRAIN_GENERATION_RESULT_START";
1766
2329
  var generationResultEnd = "AMISTIO_BRAIN_GENERATION_RESULT_END";
1767
- function createWorkExecutionPrompt(workItem) {
2330
+ function createWorkExecutionPrompt(workItem, context) {
1768
2331
  if (workItem.workKind === "brainGeneration") {
1769
2332
  return createBrainGenerationPrompt(workItem);
1770
2333
  }
2334
+ if (workItem.workKind === "planRevision") {
2335
+ return createPlanRevisionPrompt(workItem, context?.planRevision);
2336
+ }
1771
2337
  return [
1772
2338
  "# Amistio Work Execution",
1773
2339
  "",
@@ -1792,6 +2358,42 @@ function createWorkExecutionPrompt(workItem) {
1792
2358
  "- Run relevant verification commands when feasible and summarize results."
1793
2359
  ].join("\n");
1794
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
+ }
1795
2397
  function parseBrainGenerationArtifacts(output) {
1796
2398
  const start = output.indexOf(generationResultStart);
1797
2399
  const end = output.indexOf(generationResultEnd, start + generationResultStart.length);
@@ -1861,12 +2463,33 @@ function stripJsonFence(value) {
1861
2463
  return trimmed.replace(/^```(?:json)?\s*/i, "").replace(/```$/i, "").trim();
1862
2464
  }
1863
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
+
1864
2487
  // src/index.ts
1865
2488
  var program = new Command();
1866
2489
  var defaultRoot = process.env.INIT_CWD ?? process.cwd();
1867
2490
  var apiUrlOptionDescription = `Amistio API URL override (or ${AMISTIO_API_URL_ENV})`;
1868
- program.name("amistio").description("Amistio project brain CLI").version("0.1.1");
1869
- var CLI_VERSION = "0.1.1";
2491
+ program.name("amistio").description("Amistio project brain CLI").version("0.1.2");
2492
+ var CLI_VERSION = "0.1.2";
1870
2493
  program.command("init").description("Create Amistio control-plane folders for a new project").option("--root <path>", "Repository root", defaultRoot).action(async (options) => {
1871
2494
  const created = await initControlPlane(options.root);
1872
2495
  console.log(created.length ? `Created ${created.length} control-plane folders.` : "Control-plane folders already exist.");
@@ -2019,7 +2642,19 @@ work.command("list").description("List queued work without claiming it").option(
2019
2642
  console.log("Repository is not paired. Run `amistio pair` first.");
2020
2643
  return;
2021
2644
  }
2022
- const { workItems } = await context.client.listWorkItems(context.metadata.amistioProjectId);
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)}`);
2023
2658
  if (!workItems.length) {
2024
2659
  console.log("No work items are queued for this project.");
2025
2660
  return;
@@ -2042,7 +2677,7 @@ work.command("prompt").description("Print or write an approved work prompt witho
2042
2677
  }
2043
2678
  const prompt = createWorkExecutionPrompt(workItem);
2044
2679
  if (options.out) {
2045
- await writeFile7(options.out, prompt, "utf8");
2680
+ await writeFile8(options.out, prompt, "utf8");
2046
2681
  console.log(`Wrote work prompt to ${options.out}.`);
2047
2682
  } else {
2048
2683
  console.log(prompt);
@@ -2056,7 +2691,7 @@ program.command("tools").description("List local AI coding tools that the Amisti
2056
2691
  }
2057
2692
  console.log("custom - pass --tool-command to use any other local runner command.");
2058
2693
  });
2059
- 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("--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) => {
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) => {
2060
2695
  const goal = goalParts?.join(" ").trim() || "Review the current repository state and update the Amistio control plane with the next useful orchestration steps.";
2061
2696
  const prompt = await createOrchestrationPrompt({ rootDir: options.root, goal });
2062
2697
  if (options.promptOut) {
@@ -2068,13 +2703,14 @@ program.command("orchestrate").description("Update the Amistio control plane thr
2068
2703
  return;
2069
2704
  }
2070
2705
  const sessionPolicy = normalizeSessionPolicy(options.session);
2071
- const preview = await createToolRunPreview({ rootDir: options.root, prompt, tool: options.tool, ...options.toolCommand ? { toolCommand: options.toolCommand } : {} });
2706
+ const preview = await createToolRunPreview({ rootDir: options.root, prompt, tool: options.tool, ...options.toolCommand ? { toolCommand: options.toolCommand } : {}, ...options.model ? { model: options.model } : {} });
2072
2707
  console.log(`Running ${preview.toolName}: ${preview.displayCommand}`);
2073
2708
  const result = await runLocalTool({
2074
2709
  rootDir: options.root,
2075
2710
  prompt,
2076
2711
  tool: options.tool,
2077
2712
  ...options.toolCommand ? { toolCommand: options.toolCommand } : {},
2713
+ ...options.model ? { model: options.model } : {},
2078
2714
  streamOutput: options.stream,
2079
2715
  ...sessionPolicy === "none" ? {} : { session: { toolSessionId: `local_orchestration_${randomUUID()}`, policy: sessionPolicy, decision: localSessionDecision(sessionPolicy) } }
2080
2716
  });
@@ -2088,7 +2724,7 @@ program.command("orchestrate").description("Update the Amistio control plane thr
2088
2724
  process.exitCode = result.exitCode;
2089
2725
  }
2090
2726
  });
2091
- 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", "auto").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("--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) => {
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) => {
2092
2728
  const context = await loadPairedApiContext(options.root, options.apiUrl);
2093
2729
  if (!context) {
2094
2730
  console.log("Repository is not paired. Run `amistio pair` first.");
@@ -2099,10 +2735,34 @@ program.command("run").description("Claim and run approved Amistio work locally"
2099
2735
  process.exitCode = 1;
2100
2736
  return;
2101
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)
2752
+ });
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
+ }
2102
2759
  if (options.watch) {
2103
- console.log(`Runner ${options.runnerId} is watching ${context.metadata.amistioProjectId} every ${options.intervalSeconds}s. Press Ctrl+C to stop.`);
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
+ }
2104
2763
  }
2105
2764
  let iterations = 0;
2765
+ let lastWatchStateLog;
2106
2766
  while (true) {
2107
2767
  iterations += 1;
2108
2768
  const result = await runNextWorkItem({
@@ -2112,10 +2772,21 @@ program.command("run").description("Claim and run approved Amistio work locally"
2112
2772
  runnerId: options.runnerId,
2113
2773
  root: options.root,
2114
2774
  sessionPolicy: normalizeSessionPolicy(options.session),
2115
- tool: options.tool,
2775
+ ...command.getOptionValueSource("tool") === "cli" && options.tool ? { explicitTool: options.tool } : {},
2776
+ ...command.getOptionValueSource("model") === "cli" && options.model ? { explicitModel: options.model } : {},
2116
2777
  ...options.toolCommand ? { toolCommand: options.toolCommand } : {},
2117
2778
  dryRun: Boolean(options.dryRun),
2118
- stream: options.stream
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)
2119
2790
  });
2120
2791
  if (!options.watch || options.dryRun) {
2121
2792
  if (result.exitCode !== 0) {
@@ -2123,16 +2794,94 @@ program.command("run").description("Claim and run approved Amistio work locally"
2123
2794
  }
2124
2795
  return;
2125
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
+ }
2126
2807
  if (options.maxIterations !== void 0 && iterations >= options.maxIterations) {
2127
2808
  console.log(`Runner stopped after ${iterations} polling attempt${iterations === 1 ? "" : "s"}.`);
2128
2809
  return;
2129
2810
  }
2130
- if (result.status === "idle") {
2131
- console.log(`No approved work item is available. Checking again in ${options.intervalSeconds}s.`);
2132
- }
2133
2811
  await delay(options.intervalSeconds * 1e3);
2134
2812
  }
2135
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
+ });
2136
2885
  async function runNextWorkItem({
2137
2886
  apiClient,
2138
2887
  dryRun,
@@ -2142,23 +2891,48 @@ async function runNextWorkItem({
2142
2891
  runnerId,
2143
2892
  sessionPolicy,
2144
2893
  stream,
2145
- tool,
2146
- toolCommand
2894
+ explicitModel,
2895
+ explicitTool,
2896
+ toolCommand,
2897
+ commandContext,
2898
+ suppressIdleOutput
2147
2899
  }) {
2148
- await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", CLI_VERSION);
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
+ }
2149
2919
  const result = await apiClient.claimWork(projectId, runnerId, repositoryLinkId);
2150
2920
  if (!result.workItem) {
2151
- console.log("No approved work item is available.");
2152
- return { status: "idle", exitCode: 0 };
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 };
2153
2927
  }
2154
- await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "running", CLI_VERSION);
2155
- const prompt = createWorkExecutionPrompt(result.workItem);
2156
- if (dryRun || tool === "none") {
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") {
2157
2931
  console.log(prompt);
2158
- await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", CLI_VERSION);
2932
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", runnerHeartbeatMetadata(toolConfig));
2159
2933
  return { status: "preview", exitCode: 0 };
2160
2934
  }
2161
- const preview = await createToolRunPreview({ rootDir: root, prompt, tool, ...toolCommand ? { toolCommand } : {} });
2935
+ const preview = await createToolRunPreview({ rootDir: root, prompt, tool: toolConfig.tool, ...toolCommand ? { toolCommand } : {}, ...toolConfig.model ? { model: toolConfig.model } : {} });
2162
2936
  const sessionContext = await prepareToolSession({
2163
2937
  apiClient,
2164
2938
  projectId,
@@ -2166,6 +2940,7 @@ async function runNextWorkItem({
2166
2940
  runnerId,
2167
2941
  sessionPolicy: result.workItem.sessionPolicy ?? sessionPolicy,
2168
2942
  toolName: preview.toolName,
2943
+ ...toolConfig.model ? { model: toolConfig.model } : {},
2169
2944
  supportsSessionReuse: preview.supportsSessionReuse,
2170
2945
  resumabilityScope: preview.resumabilityScope,
2171
2946
  workItem: result.workItem
@@ -2177,8 +2952,9 @@ async function runNextWorkItem({
2177
2952
  const toolResult = await runLocalTool({
2178
2953
  rootDir: root,
2179
2954
  prompt,
2180
- tool,
2955
+ tool: toolConfig.tool,
2181
2956
  ...toolCommand ? { toolCommand } : {},
2957
+ ...toolConfig.model ? { model: toolConfig.model } : {},
2182
2958
  streamOutput: stream,
2183
2959
  ...sessionContext.toolSession ? {
2184
2960
  session: {
@@ -2189,7 +2965,7 @@ async function runNextWorkItem({
2189
2965
  }
2190
2966
  } : {}
2191
2967
  }).catch(async (error) => {
2192
- await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "blocked", CLI_VERSION);
2968
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "blocked", runnerHeartbeatMetadata(toolConfig));
2193
2969
  await markToolSessionBlocked(apiClient, projectId, sessionContext.toolSession, errorMessage2(error));
2194
2970
  throw error;
2195
2971
  });
@@ -2202,7 +2978,7 @@ async function runNextWorkItem({
2202
2978
  if (!stream && toolResult.stderr.trim()) {
2203
2979
  console.error(toolResult.stderr.trim());
2204
2980
  }
2205
- if (result.workItem.workKind === "brainGeneration") {
2981
+ if (result.workItem.workKind === "brainGeneration" || result.workItem.workKind === "planRevision") {
2206
2982
  return finalizeBrainGenerationWork({
2207
2983
  apiClient,
2208
2984
  durationMs: Date.now() - startedAt,
@@ -2210,6 +2986,7 @@ async function runNextWorkItem({
2210
2986
  repositoryLinkId,
2211
2987
  runnerId,
2212
2988
  sessionContext,
2989
+ toolConfig,
2213
2990
  toolName: preview.toolName,
2214
2991
  toolResult,
2215
2992
  workItem: result.workItem
@@ -2239,6 +3016,7 @@ async function runNextWorkItem({
2239
3016
  runnerId,
2240
3017
  {
2241
3018
  tool: preview.toolName,
3019
+ ...toolResult.model ? { model: toolResult.model } : {},
2242
3020
  durationMs,
2243
3021
  message: `${preview.toolName} exited with code ${toolResult.exitCode}.`,
2244
3022
  sessionPolicy: sessionContext.policy,
@@ -2252,11 +3030,100 @@ async function runNextWorkItem({
2252
3030
  ...failureExcerpt ? { error: failureExcerpt } : {}
2253
3031
  }
2254
3032
  );
2255
- await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", CLI_VERSION);
3033
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", runnerHeartbeatMetadata(toolConfig));
2256
3034
  const durationSeconds = Math.round(durationMs / 1e3);
2257
3035
  console.log(`Marked ${statusResult.workItem.workItemId} ${statusResult.workItem.status} after ${durationSeconds}s.`);
2258
3036
  return { status: finalStatus, exitCode: toolResult.exitCode };
2259
3037
  }
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
+ }
2260
3127
  async function finalizeBrainGenerationWork({
2261
3128
  apiClient,
2262
3129
  durationMs,
@@ -2264,6 +3131,7 @@ async function finalizeBrainGenerationWork({
2264
3131
  repositoryLinkId,
2265
3132
  runnerId,
2266
3133
  sessionContext,
3134
+ toolConfig,
2267
3135
  toolName,
2268
3136
  toolResult,
2269
3137
  workItem
@@ -2302,6 +3170,7 @@ ${toolResult.stderr}`);
2302
3170
  ...updatedToolSession?.sessionGroupKey ? { sessionGroupKey: updatedToolSession.sessionGroupKey } : {}
2303
3171
  };
2304
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"}.`;
2305
3174
  const result = await apiClient.submitBrainGenerationResult(projectId, workItem.workItemId, {
2306
3175
  status: "completed",
2307
3176
  runnerId,
@@ -2310,10 +3179,10 @@ ${toolResult.stderr}`);
2310
3179
  tool: toolName,
2311
3180
  durationMs,
2312
3181
  ...sessionTelemetry,
2313
- message: `${toolName} generated ${artifacts.length} brain artifact${artifacts.length === 1 ? "" : "s"}.`
3182
+ message: completionMessage
2314
3183
  });
2315
- await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", CLI_VERSION);
2316
- console.log(`Generated ${result.documents.length} brain artifact${result.documents.length === 1 ? "" : "s"} for review.`);
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.`);
2317
3186
  return { status: "completed", exitCode: 0 };
2318
3187
  }
2319
3188
  await apiClient.submitBrainGenerationResult(projectId, workItem.workItemId, {
@@ -2326,10 +3195,23 @@ ${toolResult.stderr}`);
2326
3195
  message: `${toolName} did not produce valid brain artifacts.`,
2327
3196
  ...generationError ? { error: generationError } : {}
2328
3197
  });
2329
- await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", CLI_VERSION);
3198
+ await apiClient.sendRunnerHeartbeat(projectId, runnerId, repositoryLinkId, "online", runnerHeartbeatMetadata(toolConfig));
2330
3199
  console.error(generationError ?? "Local runner generation failed.");
2331
3200
  return { status: "failed", exitCode: toolResult.exitCode || 1 };
2332
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
+ }
2333
3215
  async function loadPairedApiContext(root, apiUrl) {
2334
3216
  const metadata = await readProjectLink(root);
2335
3217
  if (!metadata) {
@@ -2348,6 +3230,37 @@ async function loadPairedApiContext(root, apiUrl) {
2348
3230
  })
2349
3231
  };
2350
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
+ }
2351
3264
  function selectPromptWorkItem(workItems, workItemId) {
2352
3265
  if (workItemId) {
2353
3266
  return workItems.find((item) => item.workItemId === workItemId || item.id === workItemId);
@@ -2366,6 +3279,7 @@ async function prepareToolSession({
2366
3279
  supportsSessionReuse,
2367
3280
  resumabilityScope,
2368
3281
  toolName,
3282
+ model,
2369
3283
  workItem
2370
3284
  }) {
2371
3285
  const { toolSessions } = await apiClient.listToolSessions(projectId);
@@ -2396,6 +3310,7 @@ async function prepareToolSession({
2396
3310
  repositoryLinkId,
2397
3311
  tool: toolName,
2398
3312
  provider: toolName,
3313
+ ...model ? { model } : {},
2399
3314
  resumabilityScope: supportsSessionReuse ? resumabilityScope : "none",
2400
3315
  title: workItem.title,
2401
3316
  summary: selection.reason,
@@ -2479,10 +3394,10 @@ function parsePositiveInteger(value) {
2479
3394
  return parsed;
2480
3395
  }
2481
3396
  function inferRepoName(root) {
2482
- return path8.basename(path8.resolve(root)) || "repository";
3397
+ return path9.basename(path9.resolve(root)) || "repository";
2483
3398
  }
2484
3399
  function createRepoFingerprint(accountId, projectId, repositoryLinkId) {
2485
- return createHash2("sha256").update(`${accountId}:${projectId}:${repositoryLinkId}`).digest("hex");
3400
+ return createHash3("sha256").update(`${accountId}:${projectId}:${repositoryLinkId}`).digest("hex");
2486
3401
  }
2487
3402
  function defaultApiUrl() {
2488
3403
  const envApiUrl = process.env[AMISTIO_API_URL_ENV]?.trim();
@@ -2497,6 +3412,133 @@ function formatApiUrlFlag(apiUrl) {
2497
3412
  function formatShellArg(value) {
2498
3413
  return /^[A-Za-z0-9_./:@-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
2499
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
+ };
3541
+ }
2500
3542
  async function delay(milliseconds) {
2501
3543
  await new Promise((resolve) => setTimeout(resolve, milliseconds));
2502
3544
  }