@awak-app/simy-cli 0.1.1 → 0.1.3

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,55 @@ import {
18
18
  redactExecutionLogs,
19
19
  redactExecutionText,
20
20
  } from "./orchestrator/execution-io.js";
21
+ import { summarizeCodingLoopEvent } from "./orchestrator/presentation.js";
22
+ import {
23
+ createProviderStreamDecoder,
24
+ isNonFatalProviderDiagnostic,
25
+ } from "./provider-stream.js";
26
+ import { resolveBackendExecutable } from "./backend-executable.js";
27
+ import {
28
+ attachmentDescriptorForLedger,
29
+ cleanupRunAttachments,
30
+ markAttachmentsCleaned,
31
+ markAttachmentsCleanupFailed,
32
+ markAttachmentsDelivered,
33
+ } from "./local-attachments.js";
34
+ import { LocalRunRegistry } from "./run-registry.js";
35
+ import { webApiHeaders, webApiUrl } from "./web-api.js";
36
+ import { normalizeGitHubRemote } from "./workspace-context.js";
21
37
 
22
38
  const execFileAsync = promisify(execFile);
39
+ const MAX_LOCAL_LOG_LINES = 1_000;
23
40
  const TERMINAL_STATES = new Set([
24
41
  "merge_ready",
25
42
  "pr_ready_for_review",
26
43
  "failed",
27
44
  "blocked",
28
45
  "waiting_human",
46
+ "stopped",
29
47
  ]);
48
+ const RESUMABLE_STATES = new Set([
49
+ "waiting_human",
50
+ "pr_ready_for_review",
51
+ "blocked",
52
+ "failed",
53
+ "stopped",
54
+ ]);
55
+ const INTERRUPTED_STATES = new Set([
56
+ "queued",
57
+ "risk_classifying",
58
+ "chartering",
59
+ "dispatching",
60
+ "coding",
61
+ "collecting_evidence",
62
+ "auditing",
63
+ "independent_auditing",
64
+ "checking_pr",
65
+ "re_instructing",
66
+ ]);
67
+ const RESTORABLE_STATES = new Set([...RESUMABLE_STATES, ...INTERRUPTED_STATES]);
30
68
 
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
- }
69
+ export { LocalRunRegistry };
47
70
 
48
71
  export function createRun({ runId, request, session, apiOrigin }) {
49
72
  const emitter = new EventEmitter();
@@ -57,6 +80,12 @@ export function createRun({ runId, request, session, apiOrigin }) {
57
80
  startedAt: null,
58
81
  completedAt: null,
59
82
  child: null,
83
+ operation: null,
84
+ controlState: "queued",
85
+ stopRequested: false,
86
+ logs: [],
87
+ pendingGuidance: [],
88
+ resumeCount: 0,
60
89
  emitter,
61
90
  pendingLedgerUpdate: null,
62
91
  ledgerUpdateRunning: false,
@@ -71,13 +100,342 @@ export function createRun({ runId, request, session, apiOrigin }) {
71
100
  };
72
101
  }
73
102
 
103
+ export function restoreRun({ snapshot, session, apiOrigin, localPath = null }) {
104
+ if (!snapshot || typeof snapshot !== "object" || !snapshot.charter) {
105
+ throw new Error("Cannot restore an invalid coding loop snapshot.");
106
+ }
107
+ const charter = snapshot.charter;
108
+ const run = createRun({
109
+ runId: String(snapshot.id || ""),
110
+ session,
111
+ apiOrigin,
112
+ request: {
113
+ backend: charter.backend === "claude" ? "claude" : "codex",
114
+ audit_backend:
115
+ charter.audit_backend === "claude" || charter.audit_backend === "codex"
116
+ ? charter.audit_backend
117
+ : null,
118
+ requirement: String(charter.requirement || ""),
119
+ repository: String(charter.repository || ""),
120
+ local_path: localPath,
121
+ base_branch: String(charter.base_branch || "dev"),
122
+ max_attempts: charter.max_attempts,
123
+ ui_evidence_root: String(charter.ui_evidence_root || ""),
124
+ acceptance_criteria: Array.isArray(charter.acceptance_criteria)
125
+ ? charter.acceptance_criteria
126
+ : [],
127
+ expected_tests: Array.isArray(charter.expected_tests) ? charter.expected_tests : [],
128
+ expected_evidence: Array.isArray(charter.expected_evidence)
129
+ ? charter.expected_evidence
130
+ : [],
131
+ required_checks: Array.isArray(charter.required_checks) ? charter.required_checks : [],
132
+ require_human_approval: charter.require_human_approval !== false,
133
+ must_not: Array.isArray(charter.must_not) ? charter.must_not : [],
134
+ proposal_id: typeof charter.proposal_id === "string" ? charter.proposal_id : null,
135
+ attachments: [],
136
+ },
137
+ });
138
+ const restoredSnapshot = structuredClone(snapshot);
139
+ const lifecycleState = restoredSnapshot.metadata?.pr_lifecycle_state;
140
+ const persistedState =
141
+ typeof lifecycleState === "string" && RESTORABLE_STATES.has(lifecycleState)
142
+ ? lifecycleState
143
+ : restoredSnapshot.state;
144
+ if (!RESTORABLE_STATES.has(persistedState)) {
145
+ throw new Error(`Coding loop ${run.id} is not in a restorable state.`);
146
+ }
147
+ const restoredFromState = INTERRUPTED_STATES.has(persistedState) ? persistedState : null;
148
+ const restoredState = restoredFromState ? "waiting_human" : persistedState;
149
+ if (restoredFromState) {
150
+ restoredSnapshot.events = Array.isArray(restoredSnapshot.events)
151
+ ? restoredSnapshot.events
152
+ : [];
153
+ restoredSnapshot.events.push({
154
+ state: "waiting_human",
155
+ message: "Local execution was interrupted and is ready to continue.",
156
+ detail: {
157
+ code: "local_execution_interrupted",
158
+ interrupted_state: restoredFromState,
159
+ },
160
+ occurred_at: new Date().toISOString(),
161
+ });
162
+ }
163
+
164
+ run.snapshot = restoredSnapshot;
165
+ run.snapshot.state = restoredState;
166
+ run.status = restoredState;
167
+ run.lastOutput = restoredFromState
168
+ ? `Local CLI restarted during ${restoredFromState}; provide guidance to continue this run.`
169
+ : String(restoredSnapshot.metadata?.last_agent_output || "");
170
+ run.startedAt = restoredSnapshot.created_at || null;
171
+ run.completedAt = restoredSnapshot.updated_at || null;
172
+ run.controlState = restoredState === "stopped" ? "stopped" : "waiting_human";
173
+ run.restoredFromState = restoredFromState;
174
+ return run;
175
+ }
176
+
177
+ export async function publishRestoredLocalCodingRun(run) {
178
+ if (!run?.restoredFromState) return run?.snapshot || null;
179
+ await updateRun(run, "waiting_human");
180
+ return run.snapshot;
181
+ }
182
+
74
183
  export async function startLocalCodingRun(
75
184
  run,
76
- { executeAttempt, executeIndependentAudit, collectEvidence } = {},
185
+ dependencies = {},
77
186
  ) {
78
- if (run.child || TERMINAL_STATES.has(run.status) || run.startedAt) return;
187
+ if (run.operation || run.child || TERMINAL_STATES.has(run.status) || run.startedAt) return;
79
188
  run.startedAt = new Date().toISOString();
189
+ run.controlState = "running";
190
+ return executeLocalCodingRun(run, dependencies);
191
+ }
192
+
193
+ export async function continueLocalCodingRun(run, guidance, dependencies = {}) {
194
+ const message = String(guidance || "").trim();
195
+ if (!message) throw new Error("Human guidance is required.");
196
+ if (run.operation || run.child) throw new Error("The selected coding run is still active.");
197
+ if (!RESUMABLE_STATES.has(run.status)) {
198
+ throw new Error("Human guidance is available only when the selected run is waiting.");
199
+ }
80
200
 
201
+ const nextAttempt = run.snapshot.attempts.length + 1;
202
+ if (nextAttempt > 5) throw new Error("The coding run reached the five-attempt safety limit.");
203
+ run.snapshot.charter.max_attempts = Math.max(run.snapshot.charter.max_attempts, nextAttempt);
204
+ run.completedAt = null;
205
+ run.stopRequested = false;
206
+ run.controlState = "running";
207
+ run.resumeCount += 1;
208
+ emitControl(run, "human_input", `Human guidance accepted: ${message}`);
209
+ return executeLocalCodingRun(run, dependencies, { humanGuidance: message, resume: true });
210
+ }
211
+
212
+ export async function continueLocalCodingRunAfterRepositoryApproval(
213
+ run,
214
+ { repository, localPath } = {},
215
+ dependencies = {},
216
+ ) {
217
+ const approvedRepository = String(repository || run.request.repository || "").trim();
218
+ const approvedPath = String(localPath || "").trim();
219
+ if (!approvedRepository || !approvedPath) {
220
+ throw new Error("An approved local repository and path are required.");
221
+ }
222
+ if (run.operation || run.child) throw new Error("The selected coding run is still active.");
223
+ if (run.status !== "waiting_human") {
224
+ throw new Error("Repository approval is available only while the run is waiting.");
225
+ }
226
+ if (run.snapshot.attempts.length > 0) {
227
+ throw new Error("Repository approval cannot restart a run after a coding attempt.");
228
+ }
229
+
230
+ run.request.local_path = approvedPath;
231
+ run.snapshot.final_audit = null;
232
+ run.snapshot.events.push({
233
+ state: "queued",
234
+ message: "Local repository authorization accepted.",
235
+ detail: {
236
+ code: "local_repository_authorized",
237
+ repository: approvedRepository,
238
+ },
239
+ occurred_at: new Date().toISOString(),
240
+ });
241
+ run.snapshot.state = "queued";
242
+ run.status = "queued";
243
+ run.lastOutput = `Authorized local repository: ${approvedRepository}.`;
244
+ run.completedAt = null;
245
+ run.stopRequested = false;
246
+ run.controlState = "running";
247
+ emitControl(
248
+ run,
249
+ "repository_authorized",
250
+ `Authorized local repository selected: ${approvedRepository}.`,
251
+ );
252
+ return executeLocalCodingRun(run, dependencies, {
253
+ humanGuidance: `Repository scan approved: ${approvedRepository}.`,
254
+ resume: false,
255
+ });
256
+ }
257
+
258
+ export function isLocalRepositoryApprovalPending(run) {
259
+ if (run?.status !== "waiting_human" || run.snapshot?.attempts?.length > 0) return false;
260
+ const events = Array.isArray(run.snapshot?.events) ? run.snapshot.events : [];
261
+ const markerIndex = events.findLastIndex(
262
+ (event) =>
263
+ event?.detail?.code === "local_repository_not_authorized" ||
264
+ event?.detail?.code === "local_repository_not_found",
265
+ );
266
+ if (markerIndex < 0) return false;
267
+ return events
268
+ .slice(markerIndex + 1)
269
+ .every((event) => event?.state === "waiting_human" || event?.state === "pr_ready_for_review");
270
+ }
271
+
272
+ export function queueLocalCodingGuidance(run, guidance) {
273
+ const message = String(guidance || "").trim();
274
+ if (!message) throw new Error("Human guidance is required.");
275
+ run.pendingGuidance.push(message);
276
+ emitControl(
277
+ run,
278
+ "guidance_queued",
279
+ "Human guidance queued for the next executor handoff.",
280
+ );
281
+ }
282
+
283
+ export async function applyLocalHumanDecision(
284
+ run,
285
+ { message, designApproval = false, requiredChecks = [], acceptanceCriteria = [] } = {},
286
+ dependencies = {},
287
+ ) {
288
+ const guidance = String(message || "").trim();
289
+ if (!guidance) throw new Error("A human decision note is required.");
290
+ const charter = run.snapshot.charter;
291
+ if (acceptanceCriteria.length > 0) {
292
+ charter.acceptance_criteria = acceptanceCriteria.map(String).map((item) => item.trim()).filter(Boolean);
293
+ charter.acceptance_criteria_source = "explicit";
294
+ }
295
+ if (requiredChecks.length > 0) {
296
+ charter.required_checks = requiredChecks.map(String).map((item) => item.trim()).filter(Boolean);
297
+ }
298
+ if (designApproval) {
299
+ charter.design_review.summary = guidance;
300
+ charter.design_review.approved_by = run.session?.device_id || "local-cli-human";
301
+ charter.design_review.evidence_url = `local-cli://approval/${encodeURIComponent(run.id)}/${Date.now()}`;
302
+ charter.design_review.status = "approved";
303
+ }
304
+ return continueLocalCodingRun(run, guidance, dependencies);
305
+ }
306
+
307
+ export function pauseLocalCodingRun(run) {
308
+ if (!run.child) throw new Error("The selected run has no active executor process.");
309
+ if (run.controlState === "paused") throw new Error("The executor process is already paused.");
310
+ if (process.platform === "win32") {
311
+ throw new Error("Process pause is not supported on Windows; stop the run instead.");
312
+ }
313
+ if (!signalExecutorProcess(run.child, "SIGSTOP")) {
314
+ throw new Error("The executor process could not be paused.");
315
+ }
316
+ run.controlState = "paused";
317
+ emitControl(run, "paused", "Executor paused by the local human operator.");
318
+ }
319
+
320
+ export function resumeLocalCodingRun(run) {
321
+ if (!run.child) throw new Error("The selected run has no paused executor process.");
322
+ if (run.controlState !== "paused") throw new Error("The executor process is not paused.");
323
+ if (process.platform === "win32") {
324
+ throw new Error("Process resume is not supported on Windows.");
325
+ }
326
+ if (!signalExecutorProcess(run.child, "SIGCONT")) {
327
+ throw new Error("The executor process could not be resumed.");
328
+ }
329
+ run.controlState = "running";
330
+ emitControl(run, "resumed", "Executor resumed by the local human operator.");
331
+ }
332
+
333
+ export async function stopLocalCodingRun(run) {
334
+ if (run.status === "stopped") return run.snapshot;
335
+ if (run.stopRequested) {
336
+ const stopped = await waitForLocalStop(run);
337
+ if (!stopped) throw new Error("The local executor did not stop within the safety timeout.");
338
+ await finalizeLocalStop(run);
339
+ return run.snapshot;
340
+ }
341
+ run.stopRequested = true;
342
+ run.controlState = "stopping";
343
+ emitControl(run, "stopping", "Stop requested by the local human operator.");
344
+
345
+ const child = run.child;
346
+ if (!child) {
347
+ const stopped = await waitForLocalStop(run);
348
+ if (!stopped) throw new Error("The local executor did not stop within the safety timeout.");
349
+ await finalizeLocalStop(run);
350
+ return run.snapshot;
351
+ }
352
+
353
+ let outcome = await signalAndWaitForClose(child, "SIGINT", 1_500);
354
+ if (outcome === "timeout" && run.child === child) {
355
+ outcome = await signalAndWaitForClose(child, "SIGTERM", 1_500);
356
+ }
357
+ if (outcome === "timeout" && run.child === child) {
358
+ await signalAndWaitForClose(child, "SIGKILL", 750);
359
+ }
360
+ const stopped = await waitForLocalStop(run);
361
+ if (!stopped) throw new Error("The local executor did not stop within the safety timeout.");
362
+ await finalizeLocalStop(run);
363
+ return run.snapshot;
364
+ }
365
+
366
+ async function signalAndWaitForClose(child, signal, timeoutMs) {
367
+ const close = once(child, "close").then(() => "closed");
368
+ signalExecutorProcess(child, signal);
369
+ return Promise.race([
370
+ close,
371
+ new Promise((resolve) => setTimeout(() => resolve("timeout"), timeoutMs)),
372
+ ]);
373
+ }
374
+
375
+ async function waitForLocalStop(run) {
376
+ if (!run.operation) return true;
377
+ return Promise.race([
378
+ run.operation.then(
379
+ () => true,
380
+ () => true,
381
+ ),
382
+ new Promise((resolve) => setTimeout(() => resolve(false), 10_000)),
383
+ ]);
384
+ }
385
+
386
+ export function signalExecutorProcess(
387
+ child,
388
+ signal,
389
+ { killProcess = process.kill, platform = process.platform } = {},
390
+ ) {
391
+ if (platform !== "win32" && Number.isInteger(child?.pid)) {
392
+ try {
393
+ killProcess(-child.pid, signal);
394
+ return true;
395
+ } catch (error) {
396
+ if (error?.code !== "ESRCH") return child.kill?.(signal) === true;
397
+ }
398
+ }
399
+ return child?.kill?.(signal) === true;
400
+ }
401
+
402
+ async function finalizeLocalStop(run) {
403
+ if (run.status === "stopped") return;
404
+ if (run.snapshot.events.at(-1)?.state !== "stopped") {
405
+ run.snapshot.events.push({
406
+ state: "stopped",
407
+ message: "Coding loop stopped by the local human operator.",
408
+ detail: { source: "local_cli" },
409
+ occurred_at: new Date().toISOString(),
410
+ });
411
+ }
412
+ await updateRun(run, "stopped");
413
+ }
414
+
415
+ async function executeLocalCodingRun(
416
+ run,
417
+ { executeAttempt, executeIndependentAudit, collectEvidence } = {},
418
+ { humanGuidance = "", resume = false } = {},
419
+ ) {
420
+ const operation = runLocalCodingRun(run, {
421
+ executeAttempt,
422
+ executeIndependentAudit,
423
+ collectEvidence,
424
+ humanGuidance,
425
+ resume,
426
+ });
427
+ run.operation = operation;
428
+ try {
429
+ return await operation;
430
+ } finally {
431
+ if (run.operation === operation) run.operation = null;
432
+ }
433
+ }
434
+
435
+ async function runLocalCodingRun(
436
+ run,
437
+ { executeAttempt, executeIndependentAudit, collectEvidence, humanGuidance, resume },
438
+ ) {
81
439
  let repositoryPath;
82
440
  try {
83
441
  repositoryPath = await resolveRepositoryPath(run.request);
@@ -88,13 +446,16 @@ export async function startLocalCodingRun(
88
446
  run.snapshot.events.push({
89
447
  state: "waiting_human",
90
448
  message: "Local repository requires configuration.",
91
- detail: { code: "local_repository_not_found" },
449
+ detail: { code: "local_repository_not_authorized" },
92
450
  occurred_at: new Date().toISOString(),
93
451
  });
452
+ await cleanupLocalAttachments(run);
94
453
  await updateRun(run, "waiting_human");
95
454
  return;
96
455
  }
97
456
 
457
+ markAttachmentsDelivered(run.request.attachments);
458
+
98
459
  const executor =
99
460
  executeAttempt ||
100
461
  ((context) =>
@@ -127,6 +488,10 @@ export async function startLocalCodingRun(
127
488
  executeAttempt: executor,
128
489
  executeIndependentAudit: independentAuditor,
129
490
  collectEvidence: evidenceCollector,
491
+ humanGuidance,
492
+ resume,
493
+ shouldStop: () => run.stopRequested,
494
+ consumeHumanGuidance: () => run.pendingGuidance.splice(0).join("\n\n"),
130
495
  onUpdate: async (snapshot) => {
131
496
  run.snapshot = snapshot;
132
497
  await updateRun(run, snapshot.state);
@@ -142,6 +507,31 @@ export async function startLocalCodingRun(
142
507
  occurred_at: new Date().toISOString(),
143
508
  });
144
509
  await updateRun(run, "failed");
510
+ } finally {
511
+ const cleanupError = await cleanupLocalAttachments(run);
512
+ if (cleanupError) {
513
+ const message = `Local attachment cleanup failed: ${cleanupError}`;
514
+ emitOutput(run, message);
515
+ run.snapshot.events.push({
516
+ state: "failed",
517
+ message: "Local attachment cleanup failed.",
518
+ detail: { error: cleanupError },
519
+ occurred_at: new Date().toISOString(),
520
+ });
521
+ run.snapshot.state = "failed";
522
+ }
523
+ await updateRun(run, run.snapshot.state);
524
+ }
525
+ }
526
+
527
+ async function cleanupLocalAttachments(run) {
528
+ try {
529
+ await cleanupRunAttachments(run.request.attachments);
530
+ markAttachmentsCleaned(run.request.attachments);
531
+ return null;
532
+ } catch (error) {
533
+ markAttachmentsCleanupFailed(run.request.attachments, error);
534
+ return error instanceof Error ? error.message : "attachment cleanup failed";
145
535
  }
146
536
  }
147
537
 
@@ -174,11 +564,14 @@ async function executeProcessAttempt({
174
564
  repositoryPath,
175
565
  run,
176
566
  marker = "SIMY_RESULT_JSON:",
567
+ attemptNumber = null,
177
568
  }) {
178
- const command = resolveBackendCommand({ backend, instruction, repositoryPath });
569
+ const command = await resolveBackendCommand({ backend, instruction, repositoryPath });
179
570
  const startedAt = new Date().toISOString();
180
571
  const stdout = [];
181
572
  const stderr = [];
573
+ const tokenUsageRecords = [];
574
+ const phase = marker === "SIMY_AUDIT_JSON:" ? "reviewer" : "executor";
182
575
 
183
576
  return new Promise((resolve) => {
184
577
  let settled = false;
@@ -186,20 +579,44 @@ async function executeProcessAttempt({
186
579
  cwd: repositoryPath,
187
580
  env: { ...process.env, ...command.env },
188
581
  stdio: ["ignore", "pipe", "pipe"],
582
+ detached: process.platform !== "win32",
189
583
  });
190
584
  run.child = child;
191
585
 
192
- const collect = (target, chunk) => {
586
+ const stdoutDecoder = createProviderStreamDecoder({
587
+ backend,
588
+ stream: "stdout",
589
+ onLine: (line) => emitOutput(run, line, { backend }),
590
+ onUsage: (usage) =>
591
+ tokenUsageRecords.push({
592
+ ...usage,
593
+ phase,
594
+ ...(Number.isInteger(attemptNumber) ? { attempt_number: attemptNumber } : {}),
595
+ backend,
596
+ }),
597
+ });
598
+ const stderrDecoder = createProviderStreamDecoder({
599
+ backend,
600
+ stream: "stderr",
601
+ onLine: (line) =>
602
+ emitOutput(run, line, {
603
+ backend,
604
+ promote: !isNonFatalProviderDiagnostic(line),
605
+ }),
606
+ });
607
+ const collect = (target, decoder, chunk) => {
193
608
  const text = chunk.toString("utf8");
194
609
  target.push(text);
195
- emitOutput(run, text);
610
+ decoder.push(text);
196
611
  };
197
- child.stdout?.on("data", (chunk) => collect(stdout, chunk));
198
- child.stderr?.on("data", (chunk) => collect(stderr, chunk));
612
+ child.stdout?.on("data", (chunk) => collect(stdout, stdoutDecoder, chunk));
613
+ child.stderr?.on("data", (chunk) => collect(stderr, stderrDecoder, chunk));
199
614
 
200
615
  const finish = ({ exitCode = null, error = null } = {}) => {
201
616
  if (settled) return;
202
617
  settled = true;
618
+ stdoutDecoder.flush();
619
+ stderrDecoder.flush();
203
620
  run.child = null;
204
621
  const rawStdout = stdout.join("");
205
622
  const assistantText = extractAssistantText(rawStdout);
@@ -212,6 +629,8 @@ async function executeProcessAttempt({
212
629
  exitCode,
213
630
  error,
214
631
  result: Object.keys(result).length > 0 ? result : parse(rawStdout),
632
+ tokenUsage:
633
+ tokenUsageRecords.length > 0 ? { records: tokenUsageRecords } : {},
215
634
  logs: [...stdout, ...stderr]
216
635
  .join("")
217
636
  .split(/\r?\n/)
@@ -227,13 +646,14 @@ async function executeProcessAttempt({
227
646
  });
228
647
  }
229
648
 
230
- function resolveBackendCommand({ backend, instruction, repositoryPath }) {
649
+ async function resolveBackendCommand({ backend, instruction, repositoryPath }) {
231
650
  if (backend === "claude") {
232
651
  const override = process.env.SIMY_CLAUDE_COMMAND;
233
652
  if (override) return shellCommand(override, repositoryPath, instruction);
653
+ const binary = (await resolveBackendExecutable("claude")) || "claude";
234
654
  return {
235
- bin: "claude",
236
- args: ["-p", instruction, "--output-format", "stream-json", "--verbose"],
655
+ bin: binary,
656
+ args: claudeBackendArgs(instruction),
237
657
  env: {},
238
658
  spawn,
239
659
  };
@@ -241,14 +661,29 @@ function resolveBackendCommand({ backend, instruction, repositoryPath }) {
241
661
 
242
662
  const override = process.env.SIMY_CODEX_COMMAND;
243
663
  if (override) return shellCommand(override, repositoryPath, instruction);
664
+ const binary = (await resolveBackendExecutable("codex")) || "codex";
244
665
  return {
245
- bin: "codex",
666
+ bin: binary,
246
667
  args: ["exec", "--json", instruction],
247
668
  env: {},
248
669
  spawn,
249
670
  };
250
671
  }
251
672
 
673
+ export function claudeBackendArgs(instruction) {
674
+ return [
675
+ "-p",
676
+ instruction,
677
+ "--output-format",
678
+ "stream-json",
679
+ "--verbose",
680
+ // The Coding Loop is already scoped to a verified checkout and explicitly
681
+ // approved by the user. Print mode cannot display permission prompts, so
682
+ // edits would otherwise be silently unavailable to the executor.
683
+ "--dangerously-skip-permissions",
684
+ ];
685
+ }
686
+
252
687
  function shellCommand(command, cwd, instruction) {
253
688
  return {
254
689
  bin: process.platform === "win32" ? "cmd.exe" : "sh",
@@ -259,12 +694,15 @@ function shellCommand(command, cwd, instruction) {
259
694
  };
260
695
  }
261
696
 
262
- async function resolveRepositoryPath(request) {
697
+ export async function resolveRepositoryPath(request) {
263
698
  const repository = String(request.repository || "").replace(/^https?:\/\/github\.com\//, "").replace(/\.git$/, "");
264
699
  const repoName = repository.split("/").filter(Boolean).at(-1);
265
700
  if (!repoName) throw new Error("The coding loop did not specify a valid GitHub repository.");
266
701
 
267
- const roots = [request.local_path, process.env.SIMY_REPO_ROOT, process.cwd()].filter(Boolean);
702
+ // An operator-provided root scopes this CLI process to the intended workspace.
703
+ // Persisted inventory paths can outlive a checkout, so only prefer them when no
704
+ // explicit process scope is configured.
705
+ const roots = [process.env.SIMY_REPO_ROOT, request.local_path, process.cwd()].filter(Boolean);
268
706
  const candidates = new Set();
269
707
  for (const root of roots) {
270
708
  const resolved = path.resolve(String(root));
@@ -280,29 +718,19 @@ async function resolveRepositoryPath(request) {
280
718
 
281
719
  throw new Error(
282
720
  `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.",
721
+ "Open SIMY CLI and press s to authorize a local repository scan, or set SIMY_REPO_ROOT.",
284
722
  );
285
723
  }
286
724
 
287
725
  async function gitRemote(cwd) {
288
726
  try {
289
727
  const { stdout } = await execFileAsync("git", ["remote", "get-url", "origin"], { cwd });
290
- return normalizeGitHubRemote(stdout);
728
+ return normalizeGitHubRemote(stdout)?.toLowerCase() ?? null;
291
729
  } catch {
292
730
  return null;
293
731
  }
294
732
  }
295
733
 
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
734
  async function pathExists(value) {
307
735
  try {
308
736
  await access(value);
@@ -332,25 +760,55 @@ function extractAssistantText(raw) {
332
760
  return messages.join("\n");
333
761
  }
334
762
 
335
- function emitOutput(run, text) {
763
+ function emitOutput(run, text, { backend = run.request.backend, promote = true } = {}) {
336
764
  for (const line of String(text).split(/\r?\n/)) {
337
765
  if (!line.trim()) continue;
338
- run.lastOutput = line;
766
+ const occurredAt = new Date().toISOString();
767
+ if (promote) run.lastOutput = line;
768
+ run.logs.push({
769
+ text: line,
770
+ backend,
771
+ occurred_at: occurredAt,
772
+ });
773
+ if (run.logs.length > MAX_LOCAL_LOG_LINES) {
774
+ run.logs.splice(0, run.logs.length - MAX_LOCAL_LOG_LINES);
775
+ }
339
776
  run.emitter.emit("event", {
340
777
  type: "output",
341
778
  run_id: run.id,
342
- backend: run.request.backend,
779
+ backend,
343
780
  text: line,
344
- occurred_at: new Date().toISOString(),
781
+ occurred_at: occurredAt,
345
782
  });
346
783
  }
347
784
  }
348
785
 
786
+ function emitControl(run, state, message) {
787
+ run.emitter.emit("event", {
788
+ type: "control",
789
+ run_id: run.id,
790
+ state,
791
+ message,
792
+ occurred_at: new Date().toISOString(),
793
+ });
794
+ }
795
+
349
796
  async function updateRun(run, state) {
350
797
  run.status = state;
351
798
  run.snapshot.state = state;
352
799
  run.snapshot.updated_at = new Date().toISOString();
353
800
  run.completedAt = TERMINAL_STATES.has(state) ? run.snapshot.updated_at : null;
801
+ if (state === "waiting_human" || state === "pr_ready_for_review") {
802
+ run.controlState = "waiting_human";
803
+ } else if (state === "stopped") {
804
+ run.controlState = "stopped";
805
+ } else if (state === "merge_ready") {
806
+ run.controlState = "complete";
807
+ } else if (TERMINAL_STATES.has(state)) {
808
+ run.controlState = "complete";
809
+ } else if (run.controlState !== "paused" && run.controlState !== "stopping") {
810
+ run.controlState = "running";
811
+ }
354
812
 
355
813
  run.emitter.emit("event", { type: "run", run: toLedgerSnapshot(run.snapshot) });
356
814
  const latestEvent = run.snapshot.events.at(-1);
@@ -395,15 +853,18 @@ async function drainLedgerUpdates(run) {
395
853
  }
396
854
 
397
855
  async function persistRunStatus(run, update) {
398
- const url = new URL(`/api/local-cli/runs/${encodeURIComponent(run.id)}/status`, run.apiOrigin);
856
+ const url = webApiUrl(`runs/${encodeURIComponent(run.id)}/status`, {
857
+ webOrigin: run.apiOrigin,
858
+ apiBaseUrl: run.session.api_base_url,
859
+ });
399
860
  try {
400
861
  const response = await fetch(url, {
401
862
  method: "POST",
402
863
  signal: AbortSignal.timeout(5000),
403
- headers: {
404
- Authorization: `Bearer ${run.session.token}`,
405
- "Content-Type": "application/json",
406
- },
864
+ headers: webApiHeaders(
865
+ { token: run.session.token },
866
+ { "Content-Type": "application/json" },
867
+ ),
407
868
  body: JSON.stringify({
408
869
  state: update.state,
409
870
  last_agent_output: redactExecutionText(run.lastOutput, { maxChars: 2_000 }).text || null,
@@ -420,15 +881,32 @@ async function persistRunStatus(run, update) {
420
881
  }
421
882
 
422
883
  export function toLedgerSnapshot(snapshot) {
884
+ const attachments = snapshot.charter.attachments || [];
423
885
  return {
424
886
  ...snapshot,
887
+ charter: {
888
+ ...snapshot.charter,
889
+ attachments: (snapshot.charter.attachments || []).map(attachmentDescriptorForLedger),
890
+ },
425
891
  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
- })),
892
+ attempts: snapshot.attempts.map((attempt) => redactedAttemptForLedger(attempt, attachments)),
893
+ events: snapshot.events.map((item) => {
894
+ const presentation = summarizeCodingLoopEvent(snapshot, item);
895
+ return {
896
+ ...item,
897
+ state: toCompatibleCodingLoopState(item.state),
898
+ detail: redactedEventDetail(
899
+ {
900
+ ...item.detail,
901
+ step_summary: presentation.summary,
902
+ step_result: presentation.result,
903
+ next_action: presentation.next,
904
+ },
905
+ item.state,
906
+ attachments,
907
+ ),
908
+ };
909
+ }),
432
910
  metadata: {
433
911
  ...(snapshot.metadata || {}),
434
912
  pr_lifecycle_state: snapshot.state,
@@ -440,15 +918,19 @@ export function toLedgerSnapshot(snapshot) {
440
918
  };
441
919
  }
442
920
 
443
- function redactedAttemptForLedger(attempt) {
921
+ function redactedAttemptForLedger(attempt, attachments) {
444
922
  const instruction = redactExecutionText(attempt.instruction);
445
923
  const executorLogs = redactExecutionLogs(attempt.executor_logs);
446
- const logText = executorLogs.lines.join("\n");
924
+ const redactedInstruction = redactAttachmentPaths(instruction.text, attachments);
925
+ const redactedExecutorLogs = executorLogs.lines.map((line) =>
926
+ redactAttachmentPaths(line, attachments),
927
+ );
928
+ const logText = redactedExecutorLogs.join("\n");
447
929
  return {
448
930
  ...attempt,
449
- instruction: instruction.text,
450
- instruction_sha256: executionTextSha256(instruction.text),
451
- executor_logs: executorLogs.lines,
931
+ instruction: redactedInstruction,
932
+ instruction_sha256: executionTextSha256(redactedInstruction),
933
+ executor_logs: redactedExecutorLogs,
452
934
  executor_logs_sha256: executionTextSha256(logText),
453
935
  io_redaction: {
454
936
  policy_version: EXECUTION_IO_POLICY_VERSION,
@@ -462,15 +944,20 @@ function redactedAttemptForLedger(attempt) {
462
944
  };
463
945
  }
464
946
 
465
- function redactedEventDetail(value, lifecycleState) {
947
+ function redactedEventDetail(value, lifecycleState, attachments) {
466
948
  const detail = value && typeof value === "object" ? value : {};
467
949
  const result = {};
468
950
  for (const [key, item] of Object.entries(detail)) {
469
951
  if (["instruction", "reinstruction", "prompt", "last_agent_output"].includes(key)) {
470
- result[key] = redactExecutionText(item, { maxChars: 24_000 }).text;
952
+ result[key] = redactAttachmentPaths(
953
+ redactExecutionText(item, { maxChars: 24_000 }).text,
954
+ attachments,
955
+ );
471
956
  } else if (["executor_logs", "stdout", "stderr"].includes(key)) {
472
957
  const logs = Array.isArray(item) ? item : [item];
473
- result[key] = redactExecutionLogs(logs).lines;
958
+ result[key] = redactExecutionLogs(logs).lines.map((line) =>
959
+ redactAttachmentPaths(line, attachments),
960
+ );
474
961
  } else {
475
962
  result[key] = item;
476
963
  }
@@ -479,6 +966,16 @@ function redactedEventDetail(value, lifecycleState) {
479
966
  return result;
480
967
  }
481
968
 
969
+ function redactAttachmentPaths(value, attachments) {
970
+ let text = String(value || "");
971
+ for (const attachment of attachments) {
972
+ if (typeof attachment?.local_path === "string" && attachment.local_path) {
973
+ text = text.replaceAll(attachment.local_path, `[LOCAL_ATTACHMENT:${attachment.name}]`);
974
+ }
975
+ }
976
+ return text;
977
+ }
978
+
482
979
  function observedEvidenceForLedger(value) {
483
980
  if (!value || typeof value !== "object") return null;
484
981
  const local = value.local && typeof value.local === "object" ? value.local : {};
@@ -530,6 +1027,8 @@ export function toCompatibleCodingLoopState(state) {
530
1027
  return "waiting_human";
531
1028
  case "merge_ready":
532
1029
  return "done";
1030
+ case "stopped":
1031
+ return "failed";
533
1032
  default:
534
1033
  return state;
535
1034
  }