@awak-app/simy-cli 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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";
@@ -16,19 +28,211 @@ import {
16
28
  sessionPath,
17
29
  writeSession,
18
30
  } from "./session-store.js";
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";
19
55
 
20
- const DEFAULT_WEB_ORIGIN = "http://localhost:3000";
21
- const ALLOWED_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$|^https:\/\/app\.simy\.one$/;
56
+ const DEVICE_HEARTBEAT_INTERVAL_MS = 20_000;
22
57
 
23
- export async function startAgent({ requestedPort = 0, daemon = false } = {}) {
24
- const apiOrigin = process.env.SIMY_API_ORIGIN || DEFAULT_WEB_ORIGIN;
58
+ export async function startAgent({
59
+ requestedPort = 0,
60
+ daemon = false,
61
+ webOrigin = null,
62
+ sessionRoot,
63
+ dependencies = {},
64
+ quiet = false,
65
+ } = {}) {
66
+ const apiOrigin = resolveWebOrigin(webOrigin);
25
67
  const registry = new LocalRunRegistry();
26
68
  const authNonce = randomBytes(16).toString("base64url");
27
- let session = await readSession();
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
+ };
28
228
 
29
229
  const server = createServer(async (req, res) => {
30
230
  try {
31
- applyCors(req, res);
231
+ if (!isRequestOriginAllowed(req.headers.origin, apiOrigin)) {
232
+ json(res, 403, { error: "request origin does not match the configured SIMY Web host" });
233
+ return;
234
+ }
235
+ applyCors(req, res, apiOrigin);
32
236
  if (req.method === "OPTIONS") {
33
237
  res.writeHead(204).end();
34
238
  return;
@@ -39,13 +243,43 @@ export async function startAgent({ requestedPort = 0, daemon = false } = {}) {
39
243
  json(res, 200, {
40
244
  ok: true,
41
245
  daemon,
42
- session_valid: isSessionValid(session),
246
+ web_origin: apiOrigin,
247
+ session_valid: isSessionValid(session, Date.now(), apiOrigin),
43
248
  session_expires_at: session?.expires_at ?? null,
44
249
  });
45
250
  return;
46
251
  }
47
252
  if (req.method === "GET" && url.pathname === "/v1/capabilities") {
48
- 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);
49
283
  return;
50
284
  }
51
285
  if (req.method === "POST" && url.pathname === "/v1/auth/complete") {
@@ -54,24 +288,43 @@ export async function startAgent({ requestedPort = 0, daemon = false } = {}) {
54
288
  json(res, 401, { error: "invalid nonce" });
55
289
  return;
56
290
  }
291
+ if (body.api_origin !== apiOrigin) {
292
+ json(res, 403, { error: "authorization origin does not match the configured host" });
293
+ return;
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
+ }
57
304
  session = {
58
305
  token: String(body.token || ""),
59
306
  device_id: typeof body.device_id === "string" ? body.device_id : null,
60
- api_origin: typeof body.api_origin === "string" ? body.api_origin : apiOrigin,
307
+ api_origin: apiOrigin,
308
+ api_base_url: apiBaseUrl,
61
309
  expires_at: typeof body.expires_at === "string" ? body.expires_at : expiresAtFromNow(),
62
310
  };
63
- await writeSession(session);
311
+ await writeSession(apiOrigin, session, sessionRoot);
312
+ startHeartbeatTimer();
313
+ loginUrl = null;
314
+ registry.emit("change", registry.list());
64
315
  json(res, 200, { ok: true, expires_at: session.expires_at });
316
+ void synchronizeAuthorizedSession();
65
317
  return;
66
318
  }
67
319
  if (req.method === "POST" && url.pathname === "/v1/coding-loop/start") {
68
- if (!isSessionValid(session)) {
320
+ if (!isSessionValid(session, Date.now(), apiOrigin)) {
69
321
  json(res, 401, { error: "simy session expired; run simy again" });
70
322
  return;
71
323
  }
72
- const body = await readJson(req);
324
+ const { body, attachments } = await readCodingLoopStart(req);
73
325
  const verified = await verifyLaunchChallenge({
74
- apiOrigin: session.api_origin || apiOrigin,
326
+ apiOrigin,
327
+ apiBaseUrl: session.api_base_url,
75
328
  token: session.token,
76
329
  runId: String(body.run_id || ""),
77
330
  challenge: String(body.challenge || ""),
@@ -89,6 +342,18 @@ export async function startAgent({ requestedPort = 0, daemon = false } = {}) {
89
342
  json(res, 409, { error: "coding loop run already exists" });
90
343
  return;
91
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);
92
357
  const run = createRun({
93
358
  runId,
94
359
  request: {
@@ -99,7 +364,10 @@ export async function startAgent({ requestedPort = 0, daemon = false } = {}) {
99
364
  : null,
100
365
  requirement: String(body.requirement || ""),
101
366
  repository: String(body.repository || ""),
102
- 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,
103
371
  base_branch: typeof body.base_branch === "string" ? body.base_branch : "dev",
104
372
  max_attempts: body.max_attempts,
105
373
  ui_evidence_root:
@@ -123,16 +391,167 @@ export async function startAgent({ requestedPort = 0, daemon = false } = {}) {
123
391
  typeof body.design_review_url === "string" ? body.design_review_url : "",
124
392
  must_not: Array.isArray(body.must_not) ? body.must_not : [],
125
393
  proposal_id: typeof body.proposal_id === "string" ? body.proposal_id : null,
394
+ attachments: stagedAttachments,
126
395
  },
127
396
  session,
128
- apiOrigin: session.api_origin || apiOrigin,
397
+ apiOrigin,
129
398
  });
130
- registry.create(run);
131
- 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);
132
406
  json(res, 202, { ok: true, run_id: run.id });
133
407
  return;
134
408
  }
135
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
+ } else if (body.action === "pause") {
425
+ pauseLocalCodingRun(run);
426
+ } else if (body.action === "resume") {
427
+ resumeLocalCodingRun(run);
428
+ } else {
429
+ json(res, 400, { error: "action must be pause, resume, or stop" });
430
+ return;
431
+ }
432
+ json(res, 200, {
433
+ ok: true,
434
+ run_id: run.id,
435
+ action: body.action,
436
+ state: run.status,
437
+ control_state: run.controlState,
438
+ delivery_status: "delivered",
439
+ });
440
+ return;
441
+ }
442
+
443
+ const guidanceMatch = url.pathname.match(/^\/v1\/coding-loop\/([^/]+)\/guidance$/);
444
+ if (req.method === "POST" && guidanceMatch) {
445
+ if (!isSessionValid(session, Date.now(), apiOrigin)) {
446
+ json(res, 401, { error: "simy session expired; run simy again" });
447
+ return;
448
+ }
449
+ const run = registry.get(decodeURIComponent(guidanceMatch[1]));
450
+ if (!run) {
451
+ json(res, 404, { error: "run not found" });
452
+ return;
453
+ }
454
+ const body = await readJson(req);
455
+ const message = String(body.message || "").trim();
456
+ if (!message) {
457
+ json(res, 400, { error: "message is required" });
458
+ return;
459
+ }
460
+ if (!run.operation && !run.child) {
461
+ json(res, 409, { error: "run is not actively executing" });
462
+ return;
463
+ }
464
+ queueLocalCodingGuidance(run, message);
465
+ json(res, 202, {
466
+ ok: true,
467
+ run_id: run.id,
468
+ delivery_status: "queued",
469
+ });
470
+ return;
471
+ }
472
+
473
+ const hilMatch = url.pathname.match(/^\/v1\/coding-loop\/([^/]+)\/hil$/);
474
+ if (hilMatch) {
475
+ if (!isSessionValid(session, Date.now(), apiOrigin)) {
476
+ json(res, 401, { error: "simy session expired; run simy again" });
477
+ return;
478
+ }
479
+ const run = registry.get(decodeURIComponent(hilMatch[1]));
480
+ if (!run) {
481
+ json(res, 404, { error: "run not found" });
482
+ return;
483
+ }
484
+ if (!isLocalRepositoryApprovalPending(run)) {
485
+ json(res, 409, { error: "run is not waiting for local repository approval" });
486
+ return;
487
+ }
488
+
489
+ if (req.method === "GET") {
490
+ json(res, 200, {
491
+ request: localRepositoryHilRequest(
492
+ run,
493
+ repositoryScanRoot,
494
+ authorizedRepositoryRoots,
495
+ ),
496
+ });
497
+ return;
498
+ }
499
+
500
+ if (req.method === "POST") {
501
+ const body = await readJson(req);
502
+ const requestId = localRepositoryHilRequestId(run);
503
+ if (body.request_id !== requestId) {
504
+ json(res, 409, { error: "HIL request is stale or does not match this run" });
505
+ return;
506
+ }
507
+ if (body.decision === "reject") {
508
+ json(res, 200, {
509
+ ok: true,
510
+ request_id: requestId,
511
+ decision: "rejected",
512
+ state: "waiting_human",
513
+ });
514
+ return;
515
+ }
516
+ if (body.decision !== "approve") {
517
+ json(res, 400, { error: "decision must be approve or reject" });
518
+ return;
519
+ }
520
+
521
+ const scan = await scanRepositories(repositoryScanRoot);
522
+ const selected = findRepository(scan.discoveredRepositories, run.request.repository);
523
+ if (!selected) {
524
+ json(res, 404, {
525
+ error: `${run.request.repository} was not found under ${scan.root}`,
526
+ code: "repository_not_found_in_scan",
527
+ root: scan.root,
528
+ repository: run.request.repository,
529
+ discovered_count: scan.discoveredRepositories.length,
530
+ truncated: scan.truncated,
531
+ });
532
+ return;
533
+ }
534
+
535
+ const continuation = continueLocalCodingRunAfterRepositoryApproval(
536
+ run,
537
+ { repository: selected.repository, localPath: selected.local_path },
538
+ runOptions,
539
+ );
540
+ json(res, 202, {
541
+ ok: true,
542
+ request_id: requestId,
543
+ decision: "approved",
544
+ run_id: run.id,
545
+ repository: selected.repository,
546
+ local_path: selected.local_path,
547
+ root: scan.root,
548
+ state: "starting",
549
+ });
550
+ void continuation.catch((error) => reportRepositoryResumeError(error, quiet));
551
+ return;
552
+ }
553
+ }
554
+
136
555
  const recheckMatch = url.pathname.match(/^\/v1\/coding-loop\/([^/]+)\/recheck$/);
137
556
  if (req.method === "POST" && recheckMatch) {
138
557
  const run = registry.get(decodeURIComponent(recheckMatch[1]));
@@ -163,62 +582,305 @@ export async function startAgent({ requestedPort = 0, daemon = false } = {}) {
163
582
 
164
583
  await new Promise((resolve) => server.listen(requestedPort, "127.0.0.1", resolve));
165
584
  const address = server.address();
166
- const port = typeof address === "object" && address ? address.port : requestedPort;
167
- console.log(`SIMY local agent listening on http://127.0.0.1:${port}`);
585
+ port = typeof address === "object" && address ? address.port : requestedPort;
586
+ if (!quiet) {
587
+ console.log(`SIMY local agent listening on http://127.0.0.1:${port}`);
588
+ console.log(`SIMY Web host: ${apiOrigin}`);
589
+ }
168
590
 
169
- if (!isSessionValid(session)) {
170
- const loginUrl = new URL("/local-cli/connect", apiOrigin);
591
+ let loginUrl = null;
592
+ if (!isSessionValid(session, Date.now(), apiOrigin)) {
593
+ loginUrl = new URL("/local-cli/connect", apiOrigin);
171
594
  loginUrl.searchParams.set("port", String(port));
172
595
  loginUrl.searchParams.set("nonce", authNonce);
173
- console.log(`Sign in to SIMY: ${loginUrl.toString()}`);
174
- console.log(`Session file: ${sessionPath()}`);
596
+ if (!quiet) {
597
+ console.log(`Sign in to SIMY: ${loginUrl.toString()}`);
598
+ console.log(`Session file: ${sessionPath(apiOrigin, sessionRoot)}`);
599
+ }
175
600
  }
176
601
 
177
- return { server, port };
602
+ if (isSessionValid(session, Date.now(), apiOrigin)) {
603
+ try {
604
+ await refreshAuthorizedRepositoryInventory();
605
+ await heartbeat();
606
+ await restoreRemoteRuns();
607
+ } catch (error) {
608
+ reportStartupConnectionError(error, quiet);
609
+ }
610
+ startHeartbeatTimer();
611
+ }
612
+
613
+ server.on("close", () => {
614
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
615
+ registry.close();
616
+ });
617
+ return {
618
+ server,
619
+ port,
620
+ registry,
621
+ webOrigin: apiOrigin,
622
+ get loginUrl() {
623
+ return loginUrl?.toString() ?? null;
624
+ },
625
+ workspace,
626
+ repositoryScanRoot,
627
+ capabilities: availableCapabilities,
628
+ controls: {
629
+ repositoryInventory: () => [...repositoryInventory],
630
+ scanRepositories,
631
+ selectRepository,
632
+ create: async (input) => {
633
+ if (!isSessionValid(session, Date.now(), apiOrigin)) {
634
+ throw new Error("Sign in to SIMY before starting a coding task.");
635
+ }
636
+ const requirement = String(input.requirement || "").trim();
637
+ const repository = String(input.repository || "").trim();
638
+ const backend = input.backend === "claude" ? "claude" : "codex";
639
+ if (!requirement) throw new Error("Describe the coding task first.");
640
+ if (!repository) throw new Error("Set a repository with /repo owner/name.");
641
+ if (availableCapabilities?.backends?.[backend] !== true) {
642
+ throw new Error(`${backend === "claude" ? "Claude Code" : "Codex"} is not available on PATH.`);
643
+ }
644
+ const indexedRepository = await ensureRepositoryIndexed(repository);
645
+ const request = {
646
+ requirement,
647
+ repository,
648
+ local_path: indexedRepository?.local_path || input.localPath || workspace.localPath,
649
+ base_branch: String(input.baseBranch || "dev"),
650
+ backend,
651
+ max_attempts: 3,
652
+ acceptance_criteria: [],
653
+ expected_tests: [],
654
+ expected_evidence: [],
655
+ required_checks: [],
656
+ require_human_approval: true,
657
+ must_not: [],
658
+ attachments: await referenceLocalAttachmentPaths(input.attachmentPaths || []),
659
+ };
660
+ await resolveRepositoryPath(request);
661
+ const remoteRun = dependencies.createRemoteRun
662
+ ? await dependencies.createRemoteRun(request)
663
+ : await createRemoteRun({
664
+ apiOrigin,
665
+ apiBaseUrl: session.api_base_url,
666
+ token: session.token,
667
+ request,
668
+ });
669
+ const runId = String(remoteRun?.id || "");
670
+ if (!runId) throw new Error("SIMY Web did not return a coding run id.");
671
+ const run = createRun({ runId, request, session, apiOrigin });
672
+ registry.create(run);
673
+ void startLocalCodingRun(run, runOptions);
674
+ return run;
675
+ },
676
+ continue: (run, guidance) => continueLocalCodingRun(run, guidance, runOptions),
677
+ applyDecision: (run, decision) => applyLocalHumanDecision(run, decision, runOptions),
678
+ queueGuidance: queueLocalCodingGuidance,
679
+ pause: pauseLocalCodingRun,
680
+ resume: resumeLocalCodingRun,
681
+ stop: stopLocalCodingRun,
682
+ recheck: (run) =>
683
+ recheckLocalCodingRun(run, { collectEvidence: runOptions.collectEvidence }),
684
+ },
685
+ };
686
+ }
687
+
688
+ async function createRemoteRun({ apiOrigin, apiBaseUrl, token, request }) {
689
+ const response = await fetch(webApiUrl("runs", { webOrigin: apiOrigin, apiBaseUrl }), {
690
+ method: "POST",
691
+ headers: webApiHeaders(
692
+ { token },
693
+ { "Content-Type": "application/json" },
694
+ ),
695
+ body: JSON.stringify({
696
+ requirement: request.requirement,
697
+ repository: request.repository,
698
+ backend: request.backend,
699
+ base_branch: request.base_branch,
700
+ max_attempts: request.max_attempts,
701
+ acceptance_criteria: request.acceptance_criteria,
702
+ expected_tests: request.expected_tests,
703
+ expected_evidence: request.expected_evidence,
704
+ must_not: request.must_not,
705
+ }),
706
+ });
707
+ const payload = await response.json().catch(() => null);
708
+ if (!response.ok) {
709
+ throw new Error(webApiErrorMessage(payload, response.status));
710
+ }
711
+ return payload?.run;
712
+ }
713
+
714
+ async function listRemoteRuns({ apiOrigin, apiBaseUrl, token }) {
715
+ const response = await fetch(webApiUrl("runs", { webOrigin: apiOrigin, apiBaseUrl }), {
716
+ method: "GET",
717
+ headers: webApiHeaders({ token }),
718
+ signal: AbortSignal.timeout(5_000),
719
+ });
720
+ const payload = await response.json().catch(() => null);
721
+ if (!response.ok) {
722
+ throw new Error(webApiErrorMessage(payload, response.status));
723
+ }
724
+ return Array.isArray(payload?.runs) ? payload.runs : [];
725
+ }
726
+
727
+ export async function heartbeatLocalDevice({
728
+ apiOrigin,
729
+ apiBaseUrl,
730
+ token,
731
+ port,
732
+ capabilities,
733
+ repoInventory,
734
+ }) {
735
+ const response = await fetch(
736
+ webApiUrl("devices/heartbeat", { webOrigin: apiOrigin, apiBaseUrl }),
737
+ {
738
+ method: "POST",
739
+ headers: webApiHeaders(
740
+ { token },
741
+ { "Content-Type": "application/json" },
742
+ ),
743
+ body: JSON.stringify({
744
+ port,
745
+ capabilities,
746
+ repo_inventory: repoInventory,
747
+ }),
748
+ signal: AbortSignal.timeout(5_000),
749
+ },
750
+ );
751
+ if (!response.ok) {
752
+ const payload = await response.json().catch(() => null);
753
+ throw new Error(webApiErrorMessage(payload, response.status));
754
+ }
755
+ }
756
+
757
+ function reportHeartbeatError(error, quiet) {
758
+ if (quiet) return;
759
+ console.error(
760
+ `SIMY device heartbeat failed: ${error instanceof Error ? error.message : String(error)}`,
761
+ );
762
+ }
763
+
764
+ function reportStartupConnectionError(error, quiet) {
765
+ if (quiet) return;
766
+ console.error(
767
+ `SIMY startup synchronization failed: ${error instanceof Error ? error.message : String(error)}`,
768
+ );
769
+ }
770
+
771
+ function reportRepositoryResumeError(error, quiet) {
772
+ if (quiet) return;
773
+ console.error(
774
+ `SIMY repository-approved run failed to resume: ${error instanceof Error ? error.message : String(error)}`,
775
+ );
776
+ }
777
+
778
+ function localRepositoryHilRequestId(run) {
779
+ return `local-repository-scan:${run.id}`;
780
+ }
781
+
782
+ function localRepositoryHilRequest(run, root, authorizedRoots) {
783
+ return {
784
+ id: localRepositoryHilRequestId(run),
785
+ kind: "local_repository_scan",
786
+ status: "pending",
787
+ title: "Allow SIMY CLI to find the local repository",
788
+ description: `Scan ${root} for the ${run.request.repository} Git checkout, then continue this run.`,
789
+ details: {
790
+ repository: run.request.repository,
791
+ root,
792
+ previously_authorized: authorizedRoots.some((item) => resolve(item) === root),
793
+ reads: ["directory names", "Git origin remotes", "current Git branches"],
794
+ excludes: [
795
+ "source file contents",
796
+ "hidden directories",
797
+ "dependency directories",
798
+ "cache directories",
799
+ "symbolic links",
800
+ ],
801
+ },
802
+ actions: [
803
+ { id: "approve", label: "Allow scan", tone: "primary" },
804
+ { id: "reject", label: "Not now", tone: "secondary" },
805
+ ],
806
+ };
178
807
  }
179
808
 
180
809
  async function capabilities() {
181
- const [codex, claude] = await Promise.all([commandAvailable("codex"), commandAvailable("claude")]);
810
+ const [codex, claude] = await Promise.all([
811
+ resolveBackendExecutable("codex"),
812
+ resolveBackendExecutable("claude"),
813
+ ]);
182
814
  return {
183
- backends: { codex, claude },
815
+ backends: { codex: Boolean(codex), claude: Boolean(claude) },
816
+ features: { repository_scan_approval: true },
184
817
  session_ttl_hours: 48,
185
818
  };
186
819
  }
187
820
 
188
- async function commandAvailable(command) {
189
- const { spawn } = await import("node:child_process");
190
- return new Promise((resolve) => {
191
- let settled = false;
192
- let timeout;
193
- const child = spawn(command, ["--version"], { stdio: "ignore" });
194
- const finish = (available) => {
195
- if (settled) return;
196
- settled = true;
197
- if (timeout) clearTimeout(timeout);
198
- resolve(available);
199
- };
200
- timeout = setTimeout(() => {
201
- child.kill("SIGTERM");
202
- finish(false);
203
- }, 1500);
204
- child.on("error", () => finish(false));
205
- child.on("close", (code) => finish(code === 0));
821
+ async function readCapabilities(dependencies) {
822
+ return dependencies.capabilities ? dependencies.capabilities() : capabilities();
823
+ }
824
+
825
+ async function inspectCodingLoopEnvironment({ repository, backend, localPath, dependencies }) {
826
+ const checks = [
827
+ {
828
+ key: "session",
829
+ status: "passed",
830
+ summary: "Local CLI session is connected to this SIMY Web origin.",
831
+ },
832
+ ];
833
+ let repositoryPath = null;
834
+ try {
835
+ repositoryPath = await resolveRepositoryPath({ repository, local_path: localPath });
836
+ checks.push({
837
+ key: "repository",
838
+ status: "passed",
839
+ summary: `Verified ${repository} against the local Git origin.`,
840
+ });
841
+ } catch (error) {
842
+ checks.push({
843
+ key: "repository",
844
+ status: "failed",
845
+ summary: error instanceof Error ? error.message : "Local repository could not be resolved.",
846
+ });
847
+ }
848
+
849
+ const available = await readCapabilities(dependencies);
850
+ const backendAvailable = available?.backends?.[backend] === true;
851
+ checks.push({
852
+ key: "backend",
853
+ status: backendAvailable ? "passed" : "failed",
854
+ summary: backendAvailable
855
+ ? `${backend === "claude" ? "Claude Code" : "Codex"} is available locally.`
856
+ : `${backend === "claude" ? "Claude Code" : "Codex"} is not available on PATH.`,
206
857
  });
858
+
859
+ return {
860
+ ready: Boolean(repositoryPath && backendAvailable),
861
+ repository,
862
+ backend,
863
+ local_path: repositoryPath,
864
+ checks,
865
+ };
207
866
  }
208
867
 
209
- async function verifyLaunchChallenge({ apiOrigin, token, runId, challenge }) {
868
+ async function verifyLaunchChallenge({ apiOrigin, apiBaseUrl, token, runId, challenge }) {
210
869
  try {
211
- const response = await fetch(new URL("/api/local-cli/challenges/verify", apiOrigin), {
212
- method: "POST",
213
- headers: {
214
- Authorization: `Bearer ${token}`,
215
- "Content-Type": "application/json",
870
+ const response = await fetch(
871
+ webApiUrl("challenges/verify", { webOrigin: apiOrigin, apiBaseUrl }),
872
+ {
873
+ method: "POST",
874
+ headers: webApiHeaders(
875
+ { token },
876
+ { "Content-Type": "application/json" },
877
+ ),
878
+ body: JSON.stringify({ run_id: runId, challenge }),
216
879
  },
217
- body: JSON.stringify({ run_id: runId, challenge }),
218
- });
880
+ );
219
881
  if (response.ok) return { ok: true };
220
882
  const payload = await response.json().catch(() => null);
221
- return { ok: false, error: payload?.error ? String(payload.error) : `HTTP ${response.status}` };
883
+ return { ok: false, error: webApiErrorMessage(payload, response.status) };
222
884
  } catch (err) {
223
885
  return { ok: false, error: err instanceof Error ? err.message : "challenge check failed" };
224
886
  }
@@ -248,11 +910,14 @@ function streamRun(res, run) {
248
910
  res.on("close", () => run.emitter.off("event", listener));
249
911
  }
250
912
 
251
- function applyCors(req, res) {
913
+ function applyCors(req, res, webOrigin) {
252
914
  const origin = req.headers.origin;
253
- if (origin && ALLOWED_ORIGIN_RE.test(origin)) {
254
- res.setHeader("Access-Control-Allow-Origin", origin);
915
+ if (origin === webOrigin) {
916
+ res.setHeader("Access-Control-Allow-Origin", webOrigin);
255
917
  res.setHeader("Vary", "Origin");
918
+ if (req.headers["access-control-request-private-network"] === "true") {
919
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
920
+ }
256
921
  }
257
922
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
258
923
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
@@ -268,3 +933,26 @@ async function readJson(req) {
268
933
  for await (const chunk of req) raw += chunk;
269
934
  return raw ? JSON.parse(raw) : {};
270
935
  }
936
+
937
+ async function readCodingLoopStart(req) {
938
+ const contentType = String(req.headers["content-type"] || "");
939
+ if (!contentType.toLowerCase().startsWith("multipart/form-data")) {
940
+ return { body: await readJson(req), attachments: [] };
941
+ }
942
+ const chunks = [];
943
+ let size = 0;
944
+ for await (const chunk of req) {
945
+ size += chunk.length;
946
+ if (size > 52 * 1024 * 1024) throw new Error("attachment request exceeds the 52 MB limit");
947
+ chunks.push(Buffer.from(chunk));
948
+ }
949
+ const form = await new Response(Buffer.concat(chunks), {
950
+ headers: { "Content-Type": contentType },
951
+ }).formData();
952
+ const payload = form.get("payload");
953
+ if (typeof payload !== "string") throw new Error("multipart payload field is required");
954
+ const attachments = form
955
+ .getAll("attachments")
956
+ .filter((value) => value && typeof value === "object" && typeof value.arrayBuffer === "function");
957
+ return { body: JSON.parse(payload), attachments };
958
+ }