@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/agent.js CHANGED
@@ -1,11 +1,23 @@
1
1
  import { createServer } from "node:http";
2
2
  import { randomBytes } from "node:crypto";
3
+ import { dirname, resolve } from "node:path";
3
4
 
4
5
  import {
6
+ applyLocalHumanDecision,
7
+ continueLocalCodingRun,
8
+ continueLocalCodingRunAfterRepositoryApproval,
5
9
  createRun,
6
10
  LocalRunRegistry,
11
+ isLocalRepositoryApprovalPending,
12
+ pauseLocalCodingRun,
13
+ queueLocalCodingGuidance,
7
14
  recheckLocalCodingRun,
15
+ publishRestoredLocalCodingRun,
16
+ resolveRepositoryPath,
17
+ restoreRun,
18
+ resumeLocalCodingRun,
8
19
  startLocalCodingRun,
20
+ stopLocalCodingRun,
9
21
  toCompatibleCodingLoopState,
10
22
  toLedgerSnapshot,
11
23
  } from "./runner.js";
@@ -17,17 +29,202 @@ import {
17
29
  writeSession,
18
30
  } from "./session-store.js";
19
31
  import { isRequestOriginAllowed, resolveWebOrigin } from "./web-origin.js";
32
+ import {
33
+ cleanupExpiredRuns,
34
+ cleanupRunAttachments,
35
+ stageRunAttachments,
36
+ referenceLocalAttachmentPaths,
37
+ } from "./local-attachments.js";
38
+ import { discoverWorkspace } from "./workspace-context.js";
39
+ import { resolveBackendExecutable } from "./backend-executable.js";
40
+ import {
41
+ resolveWebApiBaseUrl,
42
+ sessionRequiresWebAuthorization,
43
+ webApiErrorMessage,
44
+ webApiHeaders,
45
+ webApiUrl,
46
+ } from "./web-api.js";
47
+ import {
48
+ defaultRepositoryScanRoot,
49
+ findRepository,
50
+ mergeRepositoryInventory,
51
+ readRepositoryInventory,
52
+ scanGitRepositories,
53
+ writeRepositoryInventory,
54
+ } from "./repository-inventory.js";
55
+
56
+ const DEVICE_HEARTBEAT_INTERVAL_MS = 20_000;
20
57
 
21
58
  export async function startAgent({
22
59
  requestedPort = 0,
23
60
  daemon = false,
24
61
  webOrigin = null,
25
62
  sessionRoot,
63
+ dependencies = {},
64
+ quiet = false,
26
65
  } = {}) {
27
66
  const apiOrigin = resolveWebOrigin(webOrigin);
28
67
  const registry = new LocalRunRegistry();
29
68
  const authNonce = randomBytes(16).toString("base64url");
30
69
  let session = await readSession(apiOrigin, sessionRoot);
70
+ if (session && sessionRequiresWebAuthorization(session, apiOrigin)) session = null;
71
+ await cleanupExpiredRuns();
72
+ const workspace = dependencies.discoverWorkspace
73
+ ? await dependencies.discoverWorkspace()
74
+ : await discoverWorkspace(dependencies.cwd ?? process.cwd());
75
+ const persistedInventory = await readRepositoryInventory(sessionRoot);
76
+ let authorizedRepositoryRoots = [...persistedInventory.authorizedRoots];
77
+ let repositoryInventory = mergeRepositoryInventory(
78
+ persistedInventory.repositories,
79
+ workspace.repository
80
+ ? [
81
+ {
82
+ repository: workspace.repository,
83
+ branch: workspace.branch,
84
+ local_path: workspace.localPath,
85
+ },
86
+ ]
87
+ : [],
88
+ );
89
+ const availableCapabilities = await readCapabilities(dependencies);
90
+ const heartbeatDevice = dependencies.heartbeatDevice || heartbeatLocalDevice;
91
+ const runOptions = dependencies.runOptions || {};
92
+ const repositoryScanRoot = resolve(
93
+ String(
94
+ dependencies.repositoryScanRoot ||
95
+ process.env.SIMY_REPO_ROOT ||
96
+ (workspace.repository ? dirname(workspace.localPath) : workspace.localPath) ||
97
+ defaultRepositoryScanRoot(),
98
+ ),
99
+ );
100
+ let port = requestedPort;
101
+ let heartbeatTimer = null;
102
+ let heartbeatInFlight = null;
103
+
104
+ const restoreRemoteRuns = async () => {
105
+ if (!isSessionValid(session, Date.now(), apiOrigin) || !session?.device_id) return;
106
+ const snapshots = dependencies.listRemoteRuns
107
+ ? await dependencies.listRemoteRuns({
108
+ apiOrigin,
109
+ apiBaseUrl: session.api_base_url,
110
+ token: session.token,
111
+ })
112
+ : await listRemoteRuns({
113
+ apiOrigin,
114
+ apiBaseUrl: session.api_base_url,
115
+ token: session.token,
116
+ });
117
+ for (const snapshot of snapshots) {
118
+ if (!snapshot?.id || registry.has(snapshot.id)) continue;
119
+ const repository = findRepository(repositoryInventory, snapshot.charter?.repository);
120
+ const restoredRun = restoreRun({
121
+ snapshot,
122
+ session,
123
+ apiOrigin,
124
+ localPath: repository?.local_path || null,
125
+ });
126
+ registry.create(restoredRun);
127
+ await publishRestoredLocalCodingRun(restoredRun);
128
+ }
129
+ };
130
+
131
+ const heartbeat = () => {
132
+ if (!isSessionValid(session, Date.now(), apiOrigin)) return Promise.resolve(false);
133
+ if (heartbeatInFlight) return heartbeatInFlight;
134
+ heartbeatInFlight = heartbeatDevice({
135
+ apiOrigin,
136
+ apiBaseUrl: session.api_base_url,
137
+ token: session.token,
138
+ port,
139
+ capabilities: availableCapabilities,
140
+ repoInventory: repositoryInventory,
141
+ })
142
+ .then(() => true)
143
+ .finally(() => {
144
+ heartbeatInFlight = null;
145
+ });
146
+ return heartbeatInFlight;
147
+ };
148
+
149
+ const startHeartbeatTimer = () => {
150
+ if (heartbeatTimer) return;
151
+ heartbeatTimer = setInterval(() => {
152
+ void heartbeat().catch((error) => reportHeartbeatError(error, quiet));
153
+ }, DEVICE_HEARTBEAT_INTERVAL_MS);
154
+ heartbeatTimer.unref();
155
+ };
156
+
157
+ const synchronizeAuthorizedSession = async () => {
158
+ try {
159
+ await heartbeat();
160
+ } catch (error) {
161
+ reportHeartbeatError(error, quiet);
162
+ }
163
+ try {
164
+ await restoreRemoteRuns();
165
+ } catch (error) {
166
+ reportStartupConnectionError(error, quiet);
167
+ }
168
+ };
169
+
170
+ const scanRepositories = async (root) => {
171
+ const scan = dependencies.scanRepositories
172
+ ? await dependencies.scanRepositories(root)
173
+ : await scanGitRepositories(root);
174
+ repositoryInventory = mergeRepositoryInventory(repositoryInventory, scan.repositories);
175
+ authorizedRepositoryRoots = [
176
+ ...new Set([...authorizedRepositoryRoots, scan.root].filter(Boolean)),
177
+ ];
178
+ await writeRepositoryInventory(
179
+ {
180
+ authorizedRoots: authorizedRepositoryRoots,
181
+ repositories: repositoryInventory,
182
+ scannedAt: scan.scannedAt,
183
+ },
184
+ sessionRoot,
185
+ );
186
+ await heartbeat().catch((error) => reportHeartbeatError(error, quiet));
187
+ return {
188
+ ...scan,
189
+ discoveredRepositories: mergeRepositoryInventory(scan.repositories),
190
+ repositories: [...repositoryInventory],
191
+ };
192
+ };
193
+
194
+ const isRepositoryScanAuthorized = () =>
195
+ authorizedRepositoryRoots.some((item) => resolve(item) === repositoryScanRoot);
196
+
197
+ const refreshAuthorizedRepositoryInventory = async () => {
198
+ if (!isRepositoryScanAuthorized()) return null;
199
+ return scanRepositories(repositoryScanRoot);
200
+ };
201
+
202
+ const ensureRepositoryIndexed = async (repository) => {
203
+ const indexed = findRepository(repositoryInventory, repository);
204
+ if (indexed || !isRepositoryScanAuthorized()) return indexed;
205
+ await refreshAuthorizedRepositoryInventory();
206
+ return findRepository(repositoryInventory, repository);
207
+ };
208
+
209
+ const selectRepository = async (run, repository) => {
210
+ const selected = repository?.local_path
211
+ ? repositoryInventory.find(
212
+ (item) =>
213
+ item.local_path === repository.local_path &&
214
+ item.repository.toLowerCase() === repository.repository.toLowerCase(),
215
+ )
216
+ : findRepository(repositoryInventory, repository?.repository || repository);
217
+ if (!selected) throw new Error("Select a repository discovered by the authorized scan.");
218
+ if (run && selected.repository.toLowerCase() !== run.request.repository.toLowerCase()) {
219
+ throw new Error(`This run requires ${run.request.repository}; select that local repository.`);
220
+ }
221
+ if (!run) return selected;
222
+ return continueLocalCodingRunAfterRepositoryApproval(
223
+ run,
224
+ { repository: selected.repository, localPath: selected.local_path },
225
+ runOptions,
226
+ );
227
+ };
31
228
 
32
229
  const server = createServer(async (req, res) => {
33
230
  try {
@@ -53,7 +250,36 @@ export async function startAgent({
53
250
  return;
54
251
  }
55
252
  if (req.method === "GET" && url.pathname === "/v1/capabilities") {
56
- json(res, 200, await capabilities());
253
+ json(res, 200, await readCapabilities(dependencies));
254
+ return;
255
+ }
256
+ if (req.method === "POST" && url.pathname === "/v1/coding-loop/preflight") {
257
+ if (!isSessionValid(session, Date.now(), apiOrigin)) {
258
+ json(res, 401, { error: "simy session expired; run simy again" });
259
+ return;
260
+ }
261
+ const body = await readJson(req);
262
+ const repository = String(body.repository || "").trim();
263
+ if (!repository) {
264
+ json(res, 400, { error: "repository is required" });
265
+ return;
266
+ }
267
+ if (body.backend !== "codex" && body.backend !== "claude") {
268
+ json(res, 400, { error: "backend must be codex or claude" });
269
+ return;
270
+ }
271
+ const backend = body.backend;
272
+ const indexedRepository = await ensureRepositoryIndexed(repository);
273
+ const environment = await inspectCodingLoopEnvironment({
274
+ repository,
275
+ backend,
276
+ localPath:
277
+ typeof body.local_path === "string"
278
+ ? body.local_path
279
+ : indexedRepository?.local_path || null,
280
+ dependencies,
281
+ });
282
+ json(res, 200, environment);
57
283
  return;
58
284
  }
59
285
  if (req.method === "POST" && url.pathname === "/v1/auth/complete") {
@@ -66,14 +292,28 @@ export async function startAgent({
66
292
  json(res, 403, { error: "authorization origin does not match the configured host" });
67
293
  return;
68
294
  }
295
+ let apiBaseUrl;
296
+ try {
297
+ apiBaseUrl = resolveWebApiBaseUrl(apiOrigin, body.api_base_url);
298
+ } catch (error) {
299
+ json(res, 503, {
300
+ error: error instanceof Error ? error.message : "CLI API endpoint is unavailable",
301
+ });
302
+ return;
303
+ }
69
304
  session = {
70
305
  token: String(body.token || ""),
71
306
  device_id: typeof body.device_id === "string" ? body.device_id : null,
72
307
  api_origin: apiOrigin,
308
+ api_base_url: apiBaseUrl,
73
309
  expires_at: typeof body.expires_at === "string" ? body.expires_at : expiresAtFromNow(),
74
310
  };
75
311
  await writeSession(apiOrigin, session, sessionRoot);
312
+ startHeartbeatTimer();
313
+ loginUrl = null;
314
+ registry.emit("change", registry.list());
76
315
  json(res, 200, { ok: true, expires_at: session.expires_at });
316
+ void synchronizeAuthorizedSession();
77
317
  return;
78
318
  }
79
319
  if (req.method === "POST" && url.pathname === "/v1/coding-loop/start") {
@@ -81,9 +321,10 @@ export async function startAgent({
81
321
  json(res, 401, { error: "simy session expired; run simy again" });
82
322
  return;
83
323
  }
84
- const body = await readJson(req);
324
+ const { body, attachments } = await readCodingLoopStart(req);
85
325
  const verified = await verifyLaunchChallenge({
86
326
  apiOrigin,
327
+ apiBaseUrl: session.api_base_url,
87
328
  token: session.token,
88
329
  runId: String(body.run_id || ""),
89
330
  challenge: String(body.challenge || ""),
@@ -101,6 +342,18 @@ export async function startAgent({
101
342
  json(res, 409, { error: "coding loop run already exists" });
102
343
  return;
103
344
  }
345
+ let stagedAttachments = [];
346
+ try {
347
+ stagedAttachments = await stageRunAttachments({
348
+ runId,
349
+ attachments,
350
+ manifest: attachments.length > 0 ? body.attachment_manifest ?? [] : undefined,
351
+ });
352
+ } catch (error) {
353
+ json(res, 400, { error: error instanceof Error ? error.message : "attachment rejected" });
354
+ return;
355
+ }
356
+ const indexedRepository = await ensureRepositoryIndexed(body.repository);
104
357
  const run = createRun({
105
358
  runId,
106
359
  request: {
@@ -111,7 +364,10 @@ export async function startAgent({
111
364
  : null,
112
365
  requirement: String(body.requirement || ""),
113
366
  repository: String(body.repository || ""),
114
- local_path: typeof body.local_path === "string" ? body.local_path : null,
367
+ local_path:
368
+ typeof body.local_path === "string"
369
+ ? body.local_path
370
+ : indexedRepository?.local_path || null,
115
371
  base_branch: typeof body.base_branch === "string" ? body.base_branch : "dev",
116
372
  max_attempts: body.max_attempts,
117
373
  ui_evidence_root:
@@ -135,16 +391,171 @@ export async function startAgent({
135
391
  typeof body.design_review_url === "string" ? body.design_review_url : "",
136
392
  must_not: Array.isArray(body.must_not) ? body.must_not : [],
137
393
  proposal_id: typeof body.proposal_id === "string" ? body.proposal_id : null,
394
+ attachments: stagedAttachments,
138
395
  },
139
396
  session,
140
397
  apiOrigin,
141
398
  });
142
- registry.create(run);
143
- void startLocalCodingRun(run);
399
+ try {
400
+ registry.create(run);
401
+ } catch (error) {
402
+ await cleanupRunAttachments(stagedAttachments);
403
+ throw error;
404
+ }
405
+ void startLocalCodingRun(run, dependencies.runOptions);
144
406
  json(res, 202, { ok: true, run_id: run.id });
145
407
  return;
146
408
  }
147
409
 
410
+ const controlMatch = url.pathname.match(/^\/v1\/coding-loop\/([^/]+)\/control$/);
411
+ if (req.method === "POST" && controlMatch) {
412
+ if (!isSessionValid(session, Date.now(), apiOrigin)) {
413
+ json(res, 401, { error: "simy session expired; run simy again" });
414
+ return;
415
+ }
416
+ const run = registry.get(decodeURIComponent(controlMatch[1]));
417
+ if (!run) {
418
+ json(res, 404, { error: "run not found" });
419
+ return;
420
+ }
421
+ const body = await readJson(req);
422
+ if (body.action === "stop") {
423
+ await stopLocalCodingRun(run);
424
+ if (run.status !== "stopped" || run.controlState !== "stopped") {
425
+ json(res, 500, { error: "local executor stop was not confirmed" });
426
+ return;
427
+ }
428
+ } else if (body.action === "pause") {
429
+ pauseLocalCodingRun(run);
430
+ } else if (body.action === "resume") {
431
+ resumeLocalCodingRun(run);
432
+ } else {
433
+ json(res, 400, { error: "action must be pause, resume, or stop" });
434
+ return;
435
+ }
436
+ json(res, 200, {
437
+ ok: true,
438
+ run_id: run.id,
439
+ action: body.action,
440
+ state: run.status,
441
+ control_state: run.controlState,
442
+ delivery_status: "delivered",
443
+ });
444
+ return;
445
+ }
446
+
447
+ const guidanceMatch = url.pathname.match(/^\/v1\/coding-loop\/([^/]+)\/guidance$/);
448
+ if (req.method === "POST" && guidanceMatch) {
449
+ if (!isSessionValid(session, Date.now(), apiOrigin)) {
450
+ json(res, 401, { error: "simy session expired; run simy again" });
451
+ return;
452
+ }
453
+ const run = registry.get(decodeURIComponent(guidanceMatch[1]));
454
+ if (!run) {
455
+ json(res, 404, { error: "run not found" });
456
+ return;
457
+ }
458
+ const body = await readJson(req);
459
+ const message = String(body.message || "").trim();
460
+ if (!message) {
461
+ json(res, 400, { error: "message is required" });
462
+ return;
463
+ }
464
+ if (!run.operation && !run.child) {
465
+ json(res, 409, { error: "run is not actively executing" });
466
+ return;
467
+ }
468
+ queueLocalCodingGuidance(run, message);
469
+ json(res, 202, {
470
+ ok: true,
471
+ run_id: run.id,
472
+ delivery_status: "queued",
473
+ });
474
+ return;
475
+ }
476
+
477
+ const hilMatch = url.pathname.match(/^\/v1\/coding-loop\/([^/]+)\/hil$/);
478
+ if (hilMatch) {
479
+ if (!isSessionValid(session, Date.now(), apiOrigin)) {
480
+ json(res, 401, { error: "simy session expired; run simy again" });
481
+ return;
482
+ }
483
+ const run = registry.get(decodeURIComponent(hilMatch[1]));
484
+ if (!run) {
485
+ json(res, 404, { error: "run not found" });
486
+ return;
487
+ }
488
+ if (!isLocalRepositoryApprovalPending(run)) {
489
+ json(res, 409, { error: "run is not waiting for local repository approval" });
490
+ return;
491
+ }
492
+
493
+ if (req.method === "GET") {
494
+ json(res, 200, {
495
+ request: localRepositoryHilRequest(
496
+ run,
497
+ repositoryScanRoot,
498
+ authorizedRepositoryRoots,
499
+ ),
500
+ });
501
+ return;
502
+ }
503
+
504
+ if (req.method === "POST") {
505
+ const body = await readJson(req);
506
+ const requestId = localRepositoryHilRequestId(run);
507
+ if (body.request_id !== requestId) {
508
+ json(res, 409, { error: "HIL request is stale or does not match this run" });
509
+ return;
510
+ }
511
+ if (body.decision === "reject") {
512
+ json(res, 200, {
513
+ ok: true,
514
+ request_id: requestId,
515
+ decision: "rejected",
516
+ state: "waiting_human",
517
+ });
518
+ return;
519
+ }
520
+ if (body.decision !== "approve") {
521
+ json(res, 400, { error: "decision must be approve or reject" });
522
+ return;
523
+ }
524
+
525
+ const scan = await scanRepositories(repositoryScanRoot);
526
+ const selected = findRepository(scan.discoveredRepositories, run.request.repository);
527
+ if (!selected) {
528
+ json(res, 404, {
529
+ error: `${run.request.repository} was not found under ${scan.root}`,
530
+ code: "repository_not_found_in_scan",
531
+ root: scan.root,
532
+ repository: run.request.repository,
533
+ discovered_count: scan.discoveredRepositories.length,
534
+ truncated: scan.truncated,
535
+ });
536
+ return;
537
+ }
538
+
539
+ const continuation = continueLocalCodingRunAfterRepositoryApproval(
540
+ run,
541
+ { repository: selected.repository, localPath: selected.local_path },
542
+ runOptions,
543
+ );
544
+ json(res, 202, {
545
+ ok: true,
546
+ request_id: requestId,
547
+ decision: "approved",
548
+ run_id: run.id,
549
+ repository: selected.repository,
550
+ local_path: selected.local_path,
551
+ root: scan.root,
552
+ state: "resuming",
553
+ });
554
+ void continuation.catch((error) => reportRepositoryResumeError(error, quiet));
555
+ return;
556
+ }
557
+ }
558
+
148
559
  const recheckMatch = url.pathname.match(/^\/v1\/coding-loop\/([^/]+)\/recheck$/);
149
560
  if (req.method === "POST" && recheckMatch) {
150
561
  const run = registry.get(decodeURIComponent(recheckMatch[1]));
@@ -175,63 +586,305 @@ export async function startAgent({
175
586
 
176
587
  await new Promise((resolve) => server.listen(requestedPort, "127.0.0.1", resolve));
177
588
  const address = server.address();
178
- const port = typeof address === "object" && address ? address.port : requestedPort;
179
- console.log(`SIMY local agent listening on http://127.0.0.1:${port}`);
180
- console.log(`SIMY Web host: ${apiOrigin}`);
589
+ port = typeof address === "object" && address ? address.port : requestedPort;
590
+ if (!quiet) {
591
+ console.log(`SIMY local agent listening on http://127.0.0.1:${port}`);
592
+ console.log(`SIMY Web host: ${apiOrigin}`);
593
+ }
181
594
 
595
+ let loginUrl = null;
182
596
  if (!isSessionValid(session, Date.now(), apiOrigin)) {
183
- const loginUrl = new URL("/local-cli/connect", apiOrigin);
597
+ loginUrl = new URL("/local-cli/connect", apiOrigin);
184
598
  loginUrl.searchParams.set("port", String(port));
185
599
  loginUrl.searchParams.set("nonce", authNonce);
186
- console.log(`Sign in to SIMY: ${loginUrl.toString()}`);
187
- console.log(`Session file: ${sessionPath(apiOrigin, sessionRoot)}`);
600
+ if (!quiet) {
601
+ console.log(`Sign in to SIMY: ${loginUrl.toString()}`);
602
+ console.log(`Session file: ${sessionPath(apiOrigin, sessionRoot)}`);
603
+ }
604
+ }
605
+
606
+ if (isSessionValid(session, Date.now(), apiOrigin)) {
607
+ try {
608
+ await refreshAuthorizedRepositoryInventory();
609
+ await heartbeat();
610
+ await restoreRemoteRuns();
611
+ } catch (error) {
612
+ reportStartupConnectionError(error, quiet);
613
+ }
614
+ startHeartbeatTimer();
188
615
  }
189
616
 
190
- return { server, port };
617
+ server.on("close", () => {
618
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
619
+ registry.close();
620
+ });
621
+ return {
622
+ server,
623
+ port,
624
+ registry,
625
+ webOrigin: apiOrigin,
626
+ get loginUrl() {
627
+ return loginUrl?.toString() ?? null;
628
+ },
629
+ workspace,
630
+ repositoryScanRoot,
631
+ capabilities: availableCapabilities,
632
+ controls: {
633
+ repositoryInventory: () => [...repositoryInventory],
634
+ scanRepositories,
635
+ selectRepository,
636
+ create: async (input) => {
637
+ if (!isSessionValid(session, Date.now(), apiOrigin)) {
638
+ throw new Error("Sign in to SIMY before starting a coding task.");
639
+ }
640
+ const requirement = String(input.requirement || "").trim();
641
+ const repository = String(input.repository || "").trim();
642
+ const backend = input.backend === "claude" ? "claude" : "codex";
643
+ if (!requirement) throw new Error("Describe the coding task first.");
644
+ if (!repository) throw new Error("Set a repository with /repo owner/name.");
645
+ if (availableCapabilities?.backends?.[backend] !== true) {
646
+ throw new Error(`${backend === "claude" ? "Claude Code" : "Codex"} is not available on PATH.`);
647
+ }
648
+ const indexedRepository = await ensureRepositoryIndexed(repository);
649
+ const request = {
650
+ requirement,
651
+ repository,
652
+ local_path: indexedRepository?.local_path || input.localPath || workspace.localPath,
653
+ base_branch: String(input.baseBranch || "dev"),
654
+ backend,
655
+ max_attempts: 3,
656
+ acceptance_criteria: [],
657
+ expected_tests: [],
658
+ expected_evidence: [],
659
+ required_checks: [],
660
+ require_human_approval: true,
661
+ must_not: [],
662
+ attachments: await referenceLocalAttachmentPaths(input.attachmentPaths || []),
663
+ };
664
+ await resolveRepositoryPath(request);
665
+ const remoteRun = dependencies.createRemoteRun
666
+ ? await dependencies.createRemoteRun(request)
667
+ : await createRemoteRun({
668
+ apiOrigin,
669
+ apiBaseUrl: session.api_base_url,
670
+ token: session.token,
671
+ request,
672
+ });
673
+ const runId = String(remoteRun?.id || "");
674
+ if (!runId) throw new Error("SIMY Web did not return a coding run id.");
675
+ const run = createRun({ runId, request, session, apiOrigin });
676
+ registry.create(run);
677
+ void startLocalCodingRun(run, runOptions);
678
+ return run;
679
+ },
680
+ continue: (run, guidance) => continueLocalCodingRun(run, guidance, runOptions),
681
+ applyDecision: (run, decision) => applyLocalHumanDecision(run, decision, runOptions),
682
+ queueGuidance: queueLocalCodingGuidance,
683
+ pause: pauseLocalCodingRun,
684
+ resume: resumeLocalCodingRun,
685
+ stop: stopLocalCodingRun,
686
+ recheck: (run) =>
687
+ recheckLocalCodingRun(run, { collectEvidence: runOptions.collectEvidence }),
688
+ },
689
+ };
690
+ }
691
+
692
+ async function createRemoteRun({ apiOrigin, apiBaseUrl, token, request }) {
693
+ const response = await fetch(webApiUrl("runs", { webOrigin: apiOrigin, apiBaseUrl }), {
694
+ method: "POST",
695
+ headers: webApiHeaders(
696
+ { token },
697
+ { "Content-Type": "application/json" },
698
+ ),
699
+ body: JSON.stringify({
700
+ requirement: request.requirement,
701
+ repository: request.repository,
702
+ backend: request.backend,
703
+ base_branch: request.base_branch,
704
+ max_attempts: request.max_attempts,
705
+ acceptance_criteria: request.acceptance_criteria,
706
+ expected_tests: request.expected_tests,
707
+ expected_evidence: request.expected_evidence,
708
+ must_not: request.must_not,
709
+ }),
710
+ });
711
+ const payload = await response.json().catch(() => null);
712
+ if (!response.ok) {
713
+ throw new Error(webApiErrorMessage(payload, response.status));
714
+ }
715
+ return payload?.run;
716
+ }
717
+
718
+ async function listRemoteRuns({ apiOrigin, apiBaseUrl, token }) {
719
+ const response = await fetch(webApiUrl("runs", { webOrigin: apiOrigin, apiBaseUrl }), {
720
+ method: "GET",
721
+ headers: webApiHeaders({ token }),
722
+ signal: AbortSignal.timeout(5_000),
723
+ });
724
+ const payload = await response.json().catch(() => null);
725
+ if (!response.ok) {
726
+ throw new Error(webApiErrorMessage(payload, response.status));
727
+ }
728
+ return Array.isArray(payload?.runs) ? payload.runs : [];
729
+ }
730
+
731
+ export async function heartbeatLocalDevice({
732
+ apiOrigin,
733
+ apiBaseUrl,
734
+ token,
735
+ port,
736
+ capabilities,
737
+ repoInventory,
738
+ }) {
739
+ const response = await fetch(
740
+ webApiUrl("devices/heartbeat", { webOrigin: apiOrigin, apiBaseUrl }),
741
+ {
742
+ method: "POST",
743
+ headers: webApiHeaders(
744
+ { token },
745
+ { "Content-Type": "application/json" },
746
+ ),
747
+ body: JSON.stringify({
748
+ port,
749
+ capabilities,
750
+ repo_inventory: repoInventory,
751
+ }),
752
+ signal: AbortSignal.timeout(5_000),
753
+ },
754
+ );
755
+ if (!response.ok) {
756
+ const payload = await response.json().catch(() => null);
757
+ throw new Error(webApiErrorMessage(payload, response.status));
758
+ }
759
+ }
760
+
761
+ function reportHeartbeatError(error, quiet) {
762
+ if (quiet) return;
763
+ console.error(
764
+ `SIMY device heartbeat failed: ${error instanceof Error ? error.message : String(error)}`,
765
+ );
766
+ }
767
+
768
+ function reportStartupConnectionError(error, quiet) {
769
+ if (quiet) return;
770
+ console.error(
771
+ `SIMY startup synchronization failed: ${error instanceof Error ? error.message : String(error)}`,
772
+ );
773
+ }
774
+
775
+ function reportRepositoryResumeError(error, quiet) {
776
+ if (quiet) return;
777
+ console.error(
778
+ `SIMY repository-approved run failed to resume: ${error instanceof Error ? error.message : String(error)}`,
779
+ );
780
+ }
781
+
782
+ function localRepositoryHilRequestId(run) {
783
+ return `local-repository-scan:${run.id}`;
784
+ }
785
+
786
+ function localRepositoryHilRequest(run, root, authorizedRoots) {
787
+ return {
788
+ id: localRepositoryHilRequestId(run),
789
+ kind: "local_repository_scan",
790
+ status: "pending",
791
+ title: "Allow SIMY CLI to find the local repository",
792
+ description: `Scan ${root} for the ${run.request.repository} Git checkout, then continue this run.`,
793
+ details: {
794
+ repository: run.request.repository,
795
+ root,
796
+ previously_authorized: authorizedRoots.some((item) => resolve(item) === root),
797
+ reads: ["directory names", "Git origin remotes", "current Git branches"],
798
+ excludes: [
799
+ "source file contents",
800
+ "hidden directories",
801
+ "dependency directories",
802
+ "cache directories",
803
+ "symbolic links",
804
+ ],
805
+ },
806
+ actions: [
807
+ { id: "approve", label: "Allow scan", tone: "primary" },
808
+ { id: "reject", label: "Not now", tone: "secondary" },
809
+ ],
810
+ };
191
811
  }
192
812
 
193
813
  async function capabilities() {
194
- const [codex, claude] = await Promise.all([commandAvailable("codex"), commandAvailable("claude")]);
814
+ const [codex, claude] = await Promise.all([
815
+ resolveBackendExecutable("codex"),
816
+ resolveBackendExecutable("claude"),
817
+ ]);
195
818
  return {
196
- backends: { codex, claude },
819
+ backends: { codex: Boolean(codex), claude: Boolean(claude) },
820
+ features: { repository_scan_approval: true },
197
821
  session_ttl_hours: 48,
198
822
  };
199
823
  }
200
824
 
201
- async function commandAvailable(command) {
202
- const { spawn } = await import("node:child_process");
203
- return new Promise((resolve) => {
204
- let settled = false;
205
- let timeout;
206
- const child = spawn(command, ["--version"], { stdio: "ignore" });
207
- const finish = (available) => {
208
- if (settled) return;
209
- settled = true;
210
- if (timeout) clearTimeout(timeout);
211
- resolve(available);
212
- };
213
- timeout = setTimeout(() => {
214
- child.kill("SIGTERM");
215
- finish(false);
216
- }, 1500);
217
- child.on("error", () => finish(false));
218
- child.on("close", (code) => finish(code === 0));
825
+ async function readCapabilities(dependencies) {
826
+ return dependencies.capabilities ? dependencies.capabilities() : capabilities();
827
+ }
828
+
829
+ async function inspectCodingLoopEnvironment({ repository, backend, localPath, dependencies }) {
830
+ const checks = [
831
+ {
832
+ key: "session",
833
+ status: "passed",
834
+ summary: "Local CLI session is connected to this SIMY Web origin.",
835
+ },
836
+ ];
837
+ let repositoryPath = null;
838
+ try {
839
+ repositoryPath = await resolveRepositoryPath({ repository, local_path: localPath });
840
+ checks.push({
841
+ key: "repository",
842
+ status: "passed",
843
+ summary: `Verified ${repository} against the local Git origin.`,
844
+ });
845
+ } catch (error) {
846
+ checks.push({
847
+ key: "repository",
848
+ status: "failed",
849
+ summary: error instanceof Error ? error.message : "Local repository could not be resolved.",
850
+ });
851
+ }
852
+
853
+ const available = await readCapabilities(dependencies);
854
+ const backendAvailable = available?.backends?.[backend] === true;
855
+ checks.push({
856
+ key: "backend",
857
+ status: backendAvailable ? "passed" : "failed",
858
+ summary: backendAvailable
859
+ ? `${backend === "claude" ? "Claude Code" : "Codex"} is available locally.`
860
+ : `${backend === "claude" ? "Claude Code" : "Codex"} is not available on PATH.`,
219
861
  });
862
+
863
+ return {
864
+ ready: Boolean(repositoryPath && backendAvailable),
865
+ repository,
866
+ backend,
867
+ local_path: repositoryPath,
868
+ checks,
869
+ };
220
870
  }
221
871
 
222
- async function verifyLaunchChallenge({ apiOrigin, token, runId, challenge }) {
872
+ async function verifyLaunchChallenge({ apiOrigin, apiBaseUrl, token, runId, challenge }) {
223
873
  try {
224
- const response = await fetch(new URL("/api/local-cli/challenges/verify", apiOrigin), {
225
- method: "POST",
226
- headers: {
227
- Authorization: `Bearer ${token}`,
228
- "Content-Type": "application/json",
874
+ const response = await fetch(
875
+ webApiUrl("challenges/verify", { webOrigin: apiOrigin, apiBaseUrl }),
876
+ {
877
+ method: "POST",
878
+ headers: webApiHeaders(
879
+ { token },
880
+ { "Content-Type": "application/json" },
881
+ ),
882
+ body: JSON.stringify({ run_id: runId, challenge }),
229
883
  },
230
- body: JSON.stringify({ run_id: runId, challenge }),
231
- });
884
+ );
232
885
  if (response.ok) return { ok: true };
233
886
  const payload = await response.json().catch(() => null);
234
- return { ok: false, error: payload?.error ? String(payload.error) : `HTTP ${response.status}` };
887
+ return { ok: false, error: webApiErrorMessage(payload, response.status) };
235
888
  } catch (err) {
236
889
  return { ok: false, error: err instanceof Error ? err.message : "challenge check failed" };
237
890
  }
@@ -284,3 +937,26 @@ async function readJson(req) {
284
937
  for await (const chunk of req) raw += chunk;
285
938
  return raw ? JSON.parse(raw) : {};
286
939
  }
940
+
941
+ async function readCodingLoopStart(req) {
942
+ const contentType = String(req.headers["content-type"] || "");
943
+ if (!contentType.toLowerCase().startsWith("multipart/form-data")) {
944
+ return { body: await readJson(req), attachments: [] };
945
+ }
946
+ const chunks = [];
947
+ let size = 0;
948
+ for await (const chunk of req) {
949
+ size += chunk.length;
950
+ if (size > 52 * 1024 * 1024) throw new Error("attachment request exceeds the 52 MB limit");
951
+ chunks.push(Buffer.from(chunk));
952
+ }
953
+ const form = await new Response(Buffer.concat(chunks), {
954
+ headers: { "Content-Type": contentType },
955
+ }).formData();
956
+ const payload = form.get("payload");
957
+ if (typeof payload !== "string") throw new Error("multipart payload field is required");
958
+ const attachments = form
959
+ .getAll("attachments")
960
+ .filter((value) => value && typeof value === "object" && typeof value.arrayBuffer === "function");
961
+ return { body: JSON.parse(payload), attachments };
962
+ }