@awak-app/simy-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/src/runner.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFile, spawn } from "node:child_process";
2
- import { EventEmitter } from "node:events";
2
+ import { EventEmitter, once } from "node:events";
3
3
  import { access } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { promisify } from "node:util";
@@ -18,32 +18,52 @@ import {
18
18
  redactExecutionLogs,
19
19
  redactExecutionText,
20
20
  } from "./orchestrator/execution-io.js";
21
+ import { summarizeCodingLoopEvent } from "./orchestrator/presentation.js";
22
+ import { createProviderStreamDecoder } from "./provider-stream.js";
23
+ import { resolveBackendExecutable } from "./backend-executable.js";
24
+ import {
25
+ attachmentDescriptorForLedger,
26
+ cleanupRunAttachments,
27
+ markAttachmentsCleaned,
28
+ markAttachmentsCleanupFailed,
29
+ markAttachmentsDelivered,
30
+ } from "./local-attachments.js";
31
+ import { LocalRunRegistry } from "./run-registry.js";
32
+ import { webApiHeaders, webApiUrl } from "./web-api.js";
33
+ import { normalizeGitHubRemote } from "./workspace-context.js";
21
34
 
22
35
  const execFileAsync = promisify(execFile);
36
+ const MAX_LOCAL_LOG_LINES = 1_000;
23
37
  const TERMINAL_STATES = new Set([
24
38
  "merge_ready",
25
39
  "pr_ready_for_review",
26
40
  "failed",
27
41
  "blocked",
28
42
  "waiting_human",
43
+ "stopped",
29
44
  ]);
45
+ const RESUMABLE_STATES = new Set([
46
+ "waiting_human",
47
+ "pr_ready_for_review",
48
+ "blocked",
49
+ "failed",
50
+ "stopped",
51
+ ]);
52
+ const INTERRUPTED_STATES = new Set([
53
+ "queued",
54
+ "risk_classifying",
55
+ "chartering",
56
+ "dispatching",
57
+ "coding",
58
+ "collecting_evidence",
59
+ "auditing",
60
+ "independent_auditing",
61
+ "checking_pr",
62
+ "re_instructing",
63
+ ]);
64
+ const RESTORABLE_STATES = new Set([...RESUMABLE_STATES, ...INTERRUPTED_STATES]);
30
65
 
31
- export class LocalRunRegistry {
32
- #runs = new Map();
33
-
34
- create(run) {
35
- if (this.#runs.has(run.id)) throw new Error(`Run ${run.id} already exists.`);
36
- this.#runs.set(run.id, run);
37
- }
38
-
39
- get(runId) {
40
- return this.#runs.get(runId) ?? null;
41
- }
42
-
43
- has(runId) {
44
- return this.#runs.has(runId);
45
- }
46
- }
66
+ export { LocalRunRegistry };
47
67
 
48
68
  export function createRun({ runId, request, session, apiOrigin }) {
49
69
  const emitter = new EventEmitter();
@@ -57,6 +77,12 @@ export function createRun({ runId, request, session, apiOrigin }) {
57
77
  startedAt: null,
58
78
  completedAt: null,
59
79
  child: null,
80
+ operation: null,
81
+ controlState: "queued",
82
+ stopRequested: false,
83
+ logs: [],
84
+ pendingGuidance: [],
85
+ resumeCount: 0,
60
86
  emitter,
61
87
  pendingLedgerUpdate: null,
62
88
  ledgerUpdateRunning: false,
@@ -71,13 +97,312 @@ export function createRun({ runId, request, session, apiOrigin }) {
71
97
  };
72
98
  }
73
99
 
100
+ export function restoreRun({ snapshot, session, apiOrigin, localPath = null }) {
101
+ if (!snapshot || typeof snapshot !== "object" || !snapshot.charter) {
102
+ throw new Error("Cannot restore an invalid coding loop snapshot.");
103
+ }
104
+ const charter = snapshot.charter;
105
+ const run = createRun({
106
+ runId: String(snapshot.id || ""),
107
+ session,
108
+ apiOrigin,
109
+ request: {
110
+ backend: charter.backend === "claude" ? "claude" : "codex",
111
+ audit_backend:
112
+ charter.audit_backend === "claude" || charter.audit_backend === "codex"
113
+ ? charter.audit_backend
114
+ : null,
115
+ requirement: String(charter.requirement || ""),
116
+ repository: String(charter.repository || ""),
117
+ local_path: localPath,
118
+ base_branch: String(charter.base_branch || "dev"),
119
+ max_attempts: charter.max_attempts,
120
+ ui_evidence_root: String(charter.ui_evidence_root || ""),
121
+ acceptance_criteria: Array.isArray(charter.acceptance_criteria)
122
+ ? charter.acceptance_criteria
123
+ : [],
124
+ expected_tests: Array.isArray(charter.expected_tests) ? charter.expected_tests : [],
125
+ expected_evidence: Array.isArray(charter.expected_evidence)
126
+ ? charter.expected_evidence
127
+ : [],
128
+ required_checks: Array.isArray(charter.required_checks) ? charter.required_checks : [],
129
+ require_human_approval: charter.require_human_approval !== false,
130
+ must_not: Array.isArray(charter.must_not) ? charter.must_not : [],
131
+ proposal_id: typeof charter.proposal_id === "string" ? charter.proposal_id : null,
132
+ attachments: [],
133
+ },
134
+ });
135
+ const restoredSnapshot = structuredClone(snapshot);
136
+ const lifecycleState = restoredSnapshot.metadata?.pr_lifecycle_state;
137
+ const persistedState =
138
+ typeof lifecycleState === "string" && RESTORABLE_STATES.has(lifecycleState)
139
+ ? lifecycleState
140
+ : restoredSnapshot.state;
141
+ if (!RESTORABLE_STATES.has(persistedState)) {
142
+ throw new Error(`Coding loop ${run.id} is not in a restorable state.`);
143
+ }
144
+ const restoredFromState = INTERRUPTED_STATES.has(persistedState) ? persistedState : null;
145
+ const restoredState = restoredFromState ? "waiting_human" : persistedState;
146
+ if (restoredFromState) {
147
+ restoredSnapshot.events = Array.isArray(restoredSnapshot.events)
148
+ ? restoredSnapshot.events
149
+ : [];
150
+ restoredSnapshot.events.push({
151
+ state: "waiting_human",
152
+ message: "Local execution was interrupted and is ready to continue.",
153
+ detail: {
154
+ code: "local_execution_interrupted",
155
+ interrupted_state: restoredFromState,
156
+ },
157
+ occurred_at: new Date().toISOString(),
158
+ });
159
+ }
160
+
161
+ run.snapshot = restoredSnapshot;
162
+ run.snapshot.state = restoredState;
163
+ run.status = restoredState;
164
+ run.lastOutput = restoredFromState
165
+ ? `Local CLI restarted during ${restoredFromState}; provide guidance to continue this run.`
166
+ : String(restoredSnapshot.metadata?.last_agent_output || "");
167
+ run.startedAt = restoredSnapshot.created_at || null;
168
+ run.completedAt = restoredSnapshot.updated_at || null;
169
+ run.controlState = restoredState === "stopped" ? "stopped" : "waiting_human";
170
+ run.restoredFromState = restoredFromState;
171
+ return run;
172
+ }
173
+
174
+ export async function publishRestoredLocalCodingRun(run) {
175
+ if (!run?.restoredFromState) return run?.snapshot || null;
176
+ await updateRun(run, "waiting_human");
177
+ return run.snapshot;
178
+ }
179
+
74
180
  export async function startLocalCodingRun(
75
181
  run,
76
- { executeAttempt, executeIndependentAudit, collectEvidence } = {},
182
+ dependencies = {},
77
183
  ) {
78
- if (run.child || TERMINAL_STATES.has(run.status) || run.startedAt) return;
184
+ if (run.operation || run.child || TERMINAL_STATES.has(run.status) || run.startedAt) return;
79
185
  run.startedAt = new Date().toISOString();
186
+ run.controlState = "running";
187
+ return executeLocalCodingRun(run, dependencies);
188
+ }
189
+
190
+ export async function continueLocalCodingRun(run, guidance, dependencies = {}) {
191
+ const message = String(guidance || "").trim();
192
+ if (!message) throw new Error("Human guidance is required.");
193
+ if (run.operation || run.child) throw new Error("The selected coding run is still active.");
194
+ if (!RESUMABLE_STATES.has(run.status)) {
195
+ throw new Error("Human guidance is available only when the selected run is waiting.");
196
+ }
197
+
198
+ const nextAttempt = run.snapshot.attempts.length + 1;
199
+ if (nextAttempt > 5) throw new Error("The coding run reached the five-attempt safety limit.");
200
+ run.snapshot.charter.max_attempts = Math.max(run.snapshot.charter.max_attempts, nextAttempt);
201
+ run.completedAt = null;
202
+ run.stopRequested = false;
203
+ run.controlState = "running";
204
+ run.resumeCount += 1;
205
+ emitControl(run, "human_input", `Human guidance accepted: ${message}`);
206
+ return executeLocalCodingRun(run, dependencies, { humanGuidance: message, resume: true });
207
+ }
208
+
209
+ export async function continueLocalCodingRunAfterRepositoryApproval(
210
+ run,
211
+ { repository, localPath } = {},
212
+ dependencies = {},
213
+ ) {
214
+ const approvedRepository = String(repository || run.request.repository || "").trim();
215
+ const approvedPath = String(localPath || "").trim();
216
+ if (!approvedRepository || !approvedPath) {
217
+ throw new Error("An approved local repository and path are required.");
218
+ }
219
+ if (run.operation || run.child) throw new Error("The selected coding run is still active.");
220
+ if (run.status !== "waiting_human") {
221
+ throw new Error("Repository approval is available only while the run is waiting.");
222
+ }
223
+ if (run.snapshot.attempts.length > 0) {
224
+ throw new Error("Repository approval cannot restart a run after a coding attempt.");
225
+ }
226
+
227
+ run.request.local_path = approvedPath;
228
+ run.snapshot.final_audit = null;
229
+ run.snapshot.events.push({
230
+ state: "queued",
231
+ message: "Local repository authorization accepted.",
232
+ detail: {
233
+ code: "local_repository_authorized",
234
+ repository: approvedRepository,
235
+ },
236
+ occurred_at: new Date().toISOString(),
237
+ });
238
+ run.snapshot.state = "queued";
239
+ run.status = "queued";
240
+ run.lastOutput = `Authorized local repository: ${approvedRepository}.`;
241
+ run.completedAt = null;
242
+ run.stopRequested = false;
243
+ run.controlState = "running";
244
+ emitControl(
245
+ run,
246
+ "repository_authorized",
247
+ `Authorized local repository selected: ${approvedRepository}.`,
248
+ );
249
+ return executeLocalCodingRun(run, dependencies, {
250
+ humanGuidance: `Repository scan approved: ${approvedRepository}.`,
251
+ resume: false,
252
+ });
253
+ }
254
+
255
+ export function isLocalRepositoryApprovalPending(run) {
256
+ if (run?.status !== "waiting_human" || run.snapshot?.attempts?.length > 0) return false;
257
+ const events = Array.isArray(run.snapshot?.events) ? run.snapshot.events : [];
258
+ const markerIndex = events.findLastIndex(
259
+ (event) => event?.detail?.code === "local_repository_not_found",
260
+ );
261
+ if (markerIndex < 0) return false;
262
+ return events
263
+ .slice(markerIndex + 1)
264
+ .every((event) => event?.state === "waiting_human" || event?.state === "pr_ready_for_review");
265
+ }
266
+
267
+ export function queueLocalCodingGuidance(run, guidance) {
268
+ const message = String(guidance || "").trim();
269
+ if (!message) throw new Error("Human guidance is required.");
270
+ run.pendingGuidance.push(message);
271
+ emitControl(
272
+ run,
273
+ "guidance_queued",
274
+ "Human guidance queued for the next executor handoff.",
275
+ );
276
+ }
277
+
278
+ export async function applyLocalHumanDecision(
279
+ run,
280
+ { message, designApproval = false, requiredChecks = [], acceptanceCriteria = [] } = {},
281
+ dependencies = {},
282
+ ) {
283
+ const guidance = String(message || "").trim();
284
+ if (!guidance) throw new Error("A human decision note is required.");
285
+ const charter = run.snapshot.charter;
286
+ if (acceptanceCriteria.length > 0) {
287
+ charter.acceptance_criteria = acceptanceCriteria.map(String).map((item) => item.trim()).filter(Boolean);
288
+ charter.acceptance_criteria_source = "explicit";
289
+ }
290
+ if (requiredChecks.length > 0) {
291
+ charter.required_checks = requiredChecks.map(String).map((item) => item.trim()).filter(Boolean);
292
+ }
293
+ if (designApproval) {
294
+ charter.design_review.summary = guidance;
295
+ charter.design_review.approved_by = run.session?.device_id || "local-cli-human";
296
+ charter.design_review.evidence_url = `local-cli://approval/${encodeURIComponent(run.id)}/${Date.now()}`;
297
+ charter.design_review.status = "approved";
298
+ }
299
+ return continueLocalCodingRun(run, guidance, dependencies);
300
+ }
301
+
302
+ export function pauseLocalCodingRun(run) {
303
+ if (!run.child) throw new Error("The selected run has no active executor process.");
304
+ if (run.controlState === "paused") throw new Error("The executor process is already paused.");
305
+ if (process.platform === "win32") {
306
+ throw new Error("Process pause is not supported on Windows; stop the run instead.");
307
+ }
308
+ if (!run.child.kill("SIGSTOP")) throw new Error("The executor process could not be paused.");
309
+ run.controlState = "paused";
310
+ emitControl(run, "paused", "Executor paused by the local human operator.");
311
+ }
312
+
313
+ export function resumeLocalCodingRun(run) {
314
+ if (!run.child) throw new Error("The selected run has no paused executor process.");
315
+ if (run.controlState !== "paused") throw new Error("The executor process is not paused.");
316
+ if (process.platform === "win32") {
317
+ throw new Error("Process resume is not supported on Windows.");
318
+ }
319
+ if (!run.child.kill("SIGCONT")) throw new Error("The executor process could not be resumed.");
320
+ run.controlState = "running";
321
+ emitControl(run, "resumed", "Executor resumed by the local human operator.");
322
+ }
323
+
324
+ export async function stopLocalCodingRun(run) {
325
+ if (run.status === "stopped") return run.snapshot;
326
+ if (run.stopRequested) {
327
+ await waitForLocalStop(run);
328
+ return run.snapshot;
329
+ }
330
+ run.stopRequested = true;
331
+ run.controlState = "stopping";
332
+ emitControl(run, "stopping", "Stop requested by the local human operator.");
333
+
334
+ const child = run.child;
335
+ if (!child) {
336
+ await finalizeLocalStop(run);
337
+ return run.snapshot;
338
+ }
339
+
340
+ let outcome = await signalAndWaitForClose(child, "SIGINT", 1_500);
341
+ if (outcome === "timeout" && run.child === child) {
342
+ outcome = await signalAndWaitForClose(child, "SIGTERM", 1_500);
343
+ }
344
+ if (outcome === "timeout" && run.child === child) {
345
+ await signalAndWaitForClose(child, "SIGKILL", 750);
346
+ }
347
+ await waitForLocalStop(run);
348
+ await finalizeLocalStop(run);
349
+ return run.snapshot;
350
+ }
351
+
352
+ async function signalAndWaitForClose(child, signal, timeoutMs) {
353
+ const close = once(child, "close").then(() => "closed");
354
+ child.kill(signal);
355
+ return Promise.race([
356
+ close,
357
+ new Promise((resolve) => setTimeout(() => resolve("timeout"), timeoutMs)),
358
+ ]);
359
+ }
360
+
361
+ async function waitForLocalStop(run) {
362
+ if (!run.operation) return;
363
+ await Promise.race([
364
+ run.operation.catch(() => null),
365
+ new Promise((resolve) => setTimeout(resolve, 1_500)),
366
+ ]);
367
+ }
368
+
369
+ async function finalizeLocalStop(run) {
370
+ if (run.status === "stopped") return;
371
+ if (run.snapshot.events.at(-1)?.state !== "stopped") {
372
+ run.snapshot.events.push({
373
+ state: "stopped",
374
+ message: "Coding loop stopped by the local human operator.",
375
+ detail: { source: "local_cli" },
376
+ occurred_at: new Date().toISOString(),
377
+ });
378
+ }
379
+ await updateRun(run, "stopped");
380
+ }
80
381
 
382
+ async function executeLocalCodingRun(
383
+ run,
384
+ { executeAttempt, executeIndependentAudit, collectEvidence } = {},
385
+ { humanGuidance = "", resume = false } = {},
386
+ ) {
387
+ const operation = runLocalCodingRun(run, {
388
+ executeAttempt,
389
+ executeIndependentAudit,
390
+ collectEvidence,
391
+ humanGuidance,
392
+ resume,
393
+ });
394
+ run.operation = operation;
395
+ try {
396
+ return await operation;
397
+ } finally {
398
+ if (run.operation === operation) run.operation = null;
399
+ }
400
+ }
401
+
402
+ async function runLocalCodingRun(
403
+ run,
404
+ { executeAttempt, executeIndependentAudit, collectEvidence, humanGuidance, resume },
405
+ ) {
81
406
  let repositoryPath;
82
407
  try {
83
408
  repositoryPath = await resolveRepositoryPath(run.request);
@@ -91,10 +416,13 @@ export async function startLocalCodingRun(
91
416
  detail: { code: "local_repository_not_found" },
92
417
  occurred_at: new Date().toISOString(),
93
418
  });
419
+ await cleanupLocalAttachments(run);
94
420
  await updateRun(run, "waiting_human");
95
421
  return;
96
422
  }
97
423
 
424
+ markAttachmentsDelivered(run.request.attachments);
425
+
98
426
  const executor =
99
427
  executeAttempt ||
100
428
  ((context) =>
@@ -127,6 +455,10 @@ export async function startLocalCodingRun(
127
455
  executeAttempt: executor,
128
456
  executeIndependentAudit: independentAuditor,
129
457
  collectEvidence: evidenceCollector,
458
+ humanGuidance,
459
+ resume,
460
+ shouldStop: () => run.stopRequested,
461
+ consumeHumanGuidance: () => run.pendingGuidance.splice(0).join("\n\n"),
130
462
  onUpdate: async (snapshot) => {
131
463
  run.snapshot = snapshot;
132
464
  await updateRun(run, snapshot.state);
@@ -142,6 +474,31 @@ export async function startLocalCodingRun(
142
474
  occurred_at: new Date().toISOString(),
143
475
  });
144
476
  await updateRun(run, "failed");
477
+ } finally {
478
+ const cleanupError = await cleanupLocalAttachments(run);
479
+ if (cleanupError) {
480
+ const message = `Local attachment cleanup failed: ${cleanupError}`;
481
+ emitOutput(run, message);
482
+ run.snapshot.events.push({
483
+ state: "failed",
484
+ message: "Local attachment cleanup failed.",
485
+ detail: { error: cleanupError },
486
+ occurred_at: new Date().toISOString(),
487
+ });
488
+ run.snapshot.state = "failed";
489
+ }
490
+ await updateRun(run, run.snapshot.state);
491
+ }
492
+ }
493
+
494
+ async function cleanupLocalAttachments(run) {
495
+ try {
496
+ await cleanupRunAttachments(run.request.attachments);
497
+ markAttachmentsCleaned(run.request.attachments);
498
+ return null;
499
+ } catch (error) {
500
+ markAttachmentsCleanupFailed(run.request.attachments, error);
501
+ return error instanceof Error ? error.message : "attachment cleanup failed";
145
502
  }
146
503
  }
147
504
 
@@ -174,11 +531,14 @@ async function executeProcessAttempt({
174
531
  repositoryPath,
175
532
  run,
176
533
  marker = "SIMY_RESULT_JSON:",
534
+ attemptNumber = null,
177
535
  }) {
178
- const command = resolveBackendCommand({ backend, instruction, repositoryPath });
536
+ const command = await resolveBackendCommand({ backend, instruction, repositoryPath });
179
537
  const startedAt = new Date().toISOString();
180
538
  const stdout = [];
181
539
  const stderr = [];
540
+ const tokenUsageRecords = [];
541
+ const phase = marker === "SIMY_AUDIT_JSON:" ? "reviewer" : "executor";
182
542
 
183
543
  return new Promise((resolve) => {
184
544
  let settled = false;
@@ -189,17 +549,36 @@ async function executeProcessAttempt({
189
549
  });
190
550
  run.child = child;
191
551
 
192
- const collect = (target, chunk) => {
552
+ const stdoutDecoder = createProviderStreamDecoder({
553
+ backend,
554
+ stream: "stdout",
555
+ onLine: (line) => emitOutput(run, line, { backend }),
556
+ onUsage: (usage) =>
557
+ tokenUsageRecords.push({
558
+ ...usage,
559
+ phase,
560
+ ...(Number.isInteger(attemptNumber) ? { attempt_number: attemptNumber } : {}),
561
+ backend,
562
+ }),
563
+ });
564
+ const stderrDecoder = createProviderStreamDecoder({
565
+ backend,
566
+ stream: "stderr",
567
+ onLine: (line) => emitOutput(run, line, { backend }),
568
+ });
569
+ const collect = (target, decoder, chunk) => {
193
570
  const text = chunk.toString("utf8");
194
571
  target.push(text);
195
- emitOutput(run, text);
572
+ decoder.push(text);
196
573
  };
197
- child.stdout?.on("data", (chunk) => collect(stdout, chunk));
198
- child.stderr?.on("data", (chunk) => collect(stderr, chunk));
574
+ child.stdout?.on("data", (chunk) => collect(stdout, stdoutDecoder, chunk));
575
+ child.stderr?.on("data", (chunk) => collect(stderr, stderrDecoder, chunk));
199
576
 
200
577
  const finish = ({ exitCode = null, error = null } = {}) => {
201
578
  if (settled) return;
202
579
  settled = true;
580
+ stdoutDecoder.flush();
581
+ stderrDecoder.flush();
203
582
  run.child = null;
204
583
  const rawStdout = stdout.join("");
205
584
  const assistantText = extractAssistantText(rawStdout);
@@ -212,6 +591,8 @@ async function executeProcessAttempt({
212
591
  exitCode,
213
592
  error,
214
593
  result: Object.keys(result).length > 0 ? result : parse(rawStdout),
594
+ tokenUsage:
595
+ tokenUsageRecords.length > 0 ? { records: tokenUsageRecords } : {},
215
596
  logs: [...stdout, ...stderr]
216
597
  .join("")
217
598
  .split(/\r?\n/)
@@ -227,13 +608,14 @@ async function executeProcessAttempt({
227
608
  });
228
609
  }
229
610
 
230
- function resolveBackendCommand({ backend, instruction, repositoryPath }) {
611
+ async function resolveBackendCommand({ backend, instruction, repositoryPath }) {
231
612
  if (backend === "claude") {
232
613
  const override = process.env.SIMY_CLAUDE_COMMAND;
233
614
  if (override) return shellCommand(override, repositoryPath, instruction);
615
+ const binary = (await resolveBackendExecutable("claude")) || "claude";
234
616
  return {
235
- bin: "claude",
236
- args: ["-p", instruction, "--output-format", "stream-json", "--verbose"],
617
+ bin: binary,
618
+ args: claudeBackendArgs(instruction),
237
619
  env: {},
238
620
  spawn,
239
621
  };
@@ -241,14 +623,29 @@ function resolveBackendCommand({ backend, instruction, repositoryPath }) {
241
623
 
242
624
  const override = process.env.SIMY_CODEX_COMMAND;
243
625
  if (override) return shellCommand(override, repositoryPath, instruction);
626
+ const binary = (await resolveBackendExecutable("codex")) || "codex";
244
627
  return {
245
- bin: "codex",
628
+ bin: binary,
246
629
  args: ["exec", "--json", instruction],
247
630
  env: {},
248
631
  spawn,
249
632
  };
250
633
  }
251
634
 
635
+ export function claudeBackendArgs(instruction) {
636
+ return [
637
+ "-p",
638
+ instruction,
639
+ "--output-format",
640
+ "stream-json",
641
+ "--verbose",
642
+ // The Coding Loop is already scoped to a verified checkout and explicitly
643
+ // approved by the user. Print mode cannot display permission prompts, so
644
+ // edits would otherwise be silently unavailable to the executor.
645
+ "--dangerously-skip-permissions",
646
+ ];
647
+ }
648
+
252
649
  function shellCommand(command, cwd, instruction) {
253
650
  return {
254
651
  bin: process.platform === "win32" ? "cmd.exe" : "sh",
@@ -259,12 +656,15 @@ function shellCommand(command, cwd, instruction) {
259
656
  };
260
657
  }
261
658
 
262
- async function resolveRepositoryPath(request) {
659
+ export async function resolveRepositoryPath(request) {
263
660
  const repository = String(request.repository || "").replace(/^https?:\/\/github\.com\//, "").replace(/\.git$/, "");
264
661
  const repoName = repository.split("/").filter(Boolean).at(-1);
265
662
  if (!repoName) throw new Error("The coding loop did not specify a valid GitHub repository.");
266
663
 
267
- const roots = [request.local_path, process.env.SIMY_REPO_ROOT, process.cwd()].filter(Boolean);
664
+ // An operator-provided root scopes this CLI process to the intended workspace.
665
+ // Persisted inventory paths can outlive a checkout, so only prefer them when no
666
+ // explicit process scope is configured.
667
+ const roots = [process.env.SIMY_REPO_ROOT, request.local_path, process.cwd()].filter(Boolean);
268
668
  const candidates = new Set();
269
669
  for (const root of roots) {
270
670
  const resolved = path.resolve(String(root));
@@ -280,29 +680,19 @@ async function resolveRepositoryPath(request) {
280
680
 
281
681
  throw new Error(
282
682
  `Repository ${repository} was not found below ${process.env.SIMY_REPO_ROOT || process.cwd()}. ` +
283
- "Start simy from the repository or its workspace root, or set SIMY_REPO_ROOT.",
683
+ "Open SIMY CLI and press s to authorize a local repository scan, or set SIMY_REPO_ROOT.",
284
684
  );
285
685
  }
286
686
 
287
687
  async function gitRemote(cwd) {
288
688
  try {
289
689
  const { stdout } = await execFileAsync("git", ["remote", "get-url", "origin"], { cwd });
290
- return normalizeGitHubRemote(stdout);
690
+ return normalizeGitHubRemote(stdout)?.toLowerCase() ?? null;
291
691
  } catch {
292
692
  return null;
293
693
  }
294
694
  }
295
695
 
296
- function normalizeGitHubRemote(value) {
297
- return String(value || "")
298
- .trim()
299
- .replace(/^git@github\.com:/, "")
300
- .replace(/^ssh:\/\/git@github\.com\//, "")
301
- .replace(/^https?:\/\/github\.com\//, "")
302
- .replace(/\.git$/, "")
303
- .toLowerCase();
304
- }
305
-
306
696
  async function pathExists(value) {
307
697
  try {
308
698
  await access(value);
@@ -332,25 +722,55 @@ function extractAssistantText(raw) {
332
722
  return messages.join("\n");
333
723
  }
334
724
 
335
- function emitOutput(run, text) {
725
+ function emitOutput(run, text, { backend = run.request.backend } = {}) {
336
726
  for (const line of String(text).split(/\r?\n/)) {
337
727
  if (!line.trim()) continue;
728
+ const occurredAt = new Date().toISOString();
338
729
  run.lastOutput = line;
730
+ run.logs.push({
731
+ text: line,
732
+ backend,
733
+ occurred_at: occurredAt,
734
+ });
735
+ if (run.logs.length > MAX_LOCAL_LOG_LINES) {
736
+ run.logs.splice(0, run.logs.length - MAX_LOCAL_LOG_LINES);
737
+ }
339
738
  run.emitter.emit("event", {
340
739
  type: "output",
341
740
  run_id: run.id,
342
- backend: run.request.backend,
741
+ backend,
343
742
  text: line,
344
- occurred_at: new Date().toISOString(),
743
+ occurred_at: occurredAt,
345
744
  });
346
745
  }
347
746
  }
348
747
 
748
+ function emitControl(run, state, message) {
749
+ run.emitter.emit("event", {
750
+ type: "control",
751
+ run_id: run.id,
752
+ state,
753
+ message,
754
+ occurred_at: new Date().toISOString(),
755
+ });
756
+ }
757
+
349
758
  async function updateRun(run, state) {
350
759
  run.status = state;
351
760
  run.snapshot.state = state;
352
761
  run.snapshot.updated_at = new Date().toISOString();
353
762
  run.completedAt = TERMINAL_STATES.has(state) ? run.snapshot.updated_at : null;
763
+ if (state === "waiting_human" || state === "pr_ready_for_review") {
764
+ run.controlState = "waiting_human";
765
+ } else if (state === "stopped") {
766
+ run.controlState = "stopped";
767
+ } else if (state === "merge_ready") {
768
+ run.controlState = "complete";
769
+ } else if (TERMINAL_STATES.has(state)) {
770
+ run.controlState = "complete";
771
+ } else if (run.controlState !== "paused" && run.controlState !== "stopping") {
772
+ run.controlState = "running";
773
+ }
354
774
 
355
775
  run.emitter.emit("event", { type: "run", run: toLedgerSnapshot(run.snapshot) });
356
776
  const latestEvent = run.snapshot.events.at(-1);
@@ -395,15 +815,18 @@ async function drainLedgerUpdates(run) {
395
815
  }
396
816
 
397
817
  async function persistRunStatus(run, update) {
398
- const url = new URL(`/api/local-cli/runs/${encodeURIComponent(run.id)}/status`, run.apiOrigin);
818
+ const url = webApiUrl(`runs/${encodeURIComponent(run.id)}/status`, {
819
+ webOrigin: run.apiOrigin,
820
+ apiBaseUrl: run.session.api_base_url,
821
+ });
399
822
  try {
400
823
  const response = await fetch(url, {
401
824
  method: "POST",
402
825
  signal: AbortSignal.timeout(5000),
403
- headers: {
404
- Authorization: `Bearer ${run.session.token}`,
405
- "Content-Type": "application/json",
406
- },
826
+ headers: webApiHeaders(
827
+ { token: run.session.token },
828
+ { "Content-Type": "application/json" },
829
+ ),
407
830
  body: JSON.stringify({
408
831
  state: update.state,
409
832
  last_agent_output: redactExecutionText(run.lastOutput, { maxChars: 2_000 }).text || null,
@@ -420,15 +843,32 @@ async function persistRunStatus(run, update) {
420
843
  }
421
844
 
422
845
  export function toLedgerSnapshot(snapshot) {
846
+ const attachments = snapshot.charter.attachments || [];
423
847
  return {
424
848
  ...snapshot,
849
+ charter: {
850
+ ...snapshot.charter,
851
+ attachments: (snapshot.charter.attachments || []).map(attachmentDescriptorForLedger),
852
+ },
425
853
  state: toCompatibleCodingLoopState(snapshot.state),
426
- attempts: snapshot.attempts.map(redactedAttemptForLedger),
427
- events: snapshot.events.map((item) => ({
428
- ...item,
429
- state: toCompatibleCodingLoopState(item.state),
430
- detail: redactedEventDetail(item.detail, item.state),
431
- })),
854
+ attempts: snapshot.attempts.map((attempt) => redactedAttemptForLedger(attempt, attachments)),
855
+ events: snapshot.events.map((item) => {
856
+ const presentation = summarizeCodingLoopEvent(snapshot, item);
857
+ return {
858
+ ...item,
859
+ state: toCompatibleCodingLoopState(item.state),
860
+ detail: redactedEventDetail(
861
+ {
862
+ ...item.detail,
863
+ step_summary: presentation.summary,
864
+ step_result: presentation.result,
865
+ next_action: presentation.next,
866
+ },
867
+ item.state,
868
+ attachments,
869
+ ),
870
+ };
871
+ }),
432
872
  metadata: {
433
873
  ...(snapshot.metadata || {}),
434
874
  pr_lifecycle_state: snapshot.state,
@@ -440,15 +880,19 @@ export function toLedgerSnapshot(snapshot) {
440
880
  };
441
881
  }
442
882
 
443
- function redactedAttemptForLedger(attempt) {
883
+ function redactedAttemptForLedger(attempt, attachments) {
444
884
  const instruction = redactExecutionText(attempt.instruction);
445
885
  const executorLogs = redactExecutionLogs(attempt.executor_logs);
446
- const logText = executorLogs.lines.join("\n");
886
+ const redactedInstruction = redactAttachmentPaths(instruction.text, attachments);
887
+ const redactedExecutorLogs = executorLogs.lines.map((line) =>
888
+ redactAttachmentPaths(line, attachments),
889
+ );
890
+ const logText = redactedExecutorLogs.join("\n");
447
891
  return {
448
892
  ...attempt,
449
- instruction: instruction.text,
450
- instruction_sha256: executionTextSha256(instruction.text),
451
- executor_logs: executorLogs.lines,
893
+ instruction: redactedInstruction,
894
+ instruction_sha256: executionTextSha256(redactedInstruction),
895
+ executor_logs: redactedExecutorLogs,
452
896
  executor_logs_sha256: executionTextSha256(logText),
453
897
  io_redaction: {
454
898
  policy_version: EXECUTION_IO_POLICY_VERSION,
@@ -462,15 +906,20 @@ function redactedAttemptForLedger(attempt) {
462
906
  };
463
907
  }
464
908
 
465
- function redactedEventDetail(value, lifecycleState) {
909
+ function redactedEventDetail(value, lifecycleState, attachments) {
466
910
  const detail = value && typeof value === "object" ? value : {};
467
911
  const result = {};
468
912
  for (const [key, item] of Object.entries(detail)) {
469
913
  if (["instruction", "reinstruction", "prompt", "last_agent_output"].includes(key)) {
470
- result[key] = redactExecutionText(item, { maxChars: 24_000 }).text;
914
+ result[key] = redactAttachmentPaths(
915
+ redactExecutionText(item, { maxChars: 24_000 }).text,
916
+ attachments,
917
+ );
471
918
  } else if (["executor_logs", "stdout", "stderr"].includes(key)) {
472
919
  const logs = Array.isArray(item) ? item : [item];
473
- result[key] = redactExecutionLogs(logs).lines;
920
+ result[key] = redactExecutionLogs(logs).lines.map((line) =>
921
+ redactAttachmentPaths(line, attachments),
922
+ );
474
923
  } else {
475
924
  result[key] = item;
476
925
  }
@@ -479,6 +928,16 @@ function redactedEventDetail(value, lifecycleState) {
479
928
  return result;
480
929
  }
481
930
 
931
+ function redactAttachmentPaths(value, attachments) {
932
+ let text = String(value || "");
933
+ for (const attachment of attachments) {
934
+ if (typeof attachment?.local_path === "string" && attachment.local_path) {
935
+ text = text.replaceAll(attachment.local_path, `[LOCAL_ATTACHMENT:${attachment.name}]`);
936
+ }
937
+ }
938
+ return text;
939
+ }
940
+
482
941
  function observedEvidenceForLedger(value) {
483
942
  if (!value || typeof value !== "object") return null;
484
943
  const local = value.local && typeof value.local === "object" ? value.local : {};
@@ -530,6 +989,8 @@ export function toCompatibleCodingLoopState(state) {
530
989
  return "waiting_human";
531
990
  case "merge_ready":
532
991
  return "done";
992
+ case "stopped":
993
+ return "failed";
533
994
  default:
534
995
  return state;
535
996
  }