@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/README.md +45 -4
- package/package.json +16 -3
- package/src/agent.js +717 -41
- package/src/backend-executable.js +44 -0
- package/src/browser.js +59 -0
- package/src/console/app.js +1042 -0
- package/src/console/commands.js +100 -0
- package/src/console/index.js +25 -0
- package/src/index.js +22 -1
- package/src/local-attachments.js +270 -0
- package/src/orchestrator/contract.js +1 -0
- package/src/orchestrator/independent-audit.js +25 -0
- package/src/orchestrator/index.js +1 -1
- package/src/orchestrator/instruction.js +27 -1
- package/src/orchestrator/loop.js +65 -25
- package/src/orchestrator/presentation.js +189 -0
- package/src/orchestrator/result.js +11 -0
- package/src/provider-stream.js +327 -0
- package/src/repository-inventory.js +216 -0
- package/src/run-registry.js +44 -0
- package/src/runner.js +565 -66
- package/src/web-api.js +66 -0
- package/src/workspace-context.js +37 -0
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
597
|
+
loginUrl = new URL("/local-cli/connect", apiOrigin);
|
|
184
598
|
loginUrl.searchParams.set("port", String(port));
|
|
185
599
|
loginUrl.searchParams.set("nonce", authNonce);
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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([
|
|
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
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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(
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
231
|
-
});
|
|
884
|
+
);
|
|
232
885
|
if (response.ok) return { ok: true };
|
|
233
886
|
const payload = await response.json().catch(() => null);
|
|
234
|
-
return { ok: false, error:
|
|
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
|
+
}
|