@awak-app/simy-cli 0.2.3 → 0.3.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,6 +1,7 @@
1
1
  import { createServer } from "node:http";
2
2
  import { randomBytes } from "node:crypto";
3
- import { dirname, resolve } from "node:path";
3
+ import { createReadStream } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
4
5
 
5
6
  import {
6
7
  applyLocalHumanDecision,
@@ -33,7 +34,9 @@ import { isRequestOriginAllowed, resolveWebOrigin } from "./web-origin.js";
33
34
  import {
34
35
  cleanupExpiredRuns,
35
36
  cleanupRunAttachments,
37
+ prepareRunAttachments,
36
38
  stageRunAttachments,
39
+ stagePreparedRunAttachments,
37
40
  referenceLocalAttachmentPaths,
38
41
  } from "./local-attachments.js";
39
42
  import { discoverWorkspace } from "./workspace-context.js";
@@ -63,6 +66,7 @@ import {
63
66
  mergeRepositoryInventory,
64
67
  readRepositoryInventory,
65
68
  scanGitRepositories,
69
+ verifyRepositoryIdentity,
66
70
  writeRepositoryInventory,
67
71
  } from "./repository-inventory.js";
68
72
  import {
@@ -80,6 +84,37 @@ import {
80
84
  classifyExecutionRequirement,
81
85
  EXECUTION_GUARDRAIL_POLICY_VERSION,
82
86
  } from "./execution-guardrail.js";
87
+ import {
88
+ createLocalTask,
89
+ createLocalTaskId,
90
+ createLocalTaskRequestFingerprint,
91
+ hashLocalTaskIdempotencyKey,
92
+ localTaskArtifact,
93
+ localTaskSnapshot,
94
+ LocalTaskRegistry,
95
+ localTasksRoot,
96
+ persistLocalTask,
97
+ prepareLocalTaskWorkspace,
98
+ restoreLocalTasks,
99
+ startLocalTask,
100
+ stopLocalTask,
101
+ } from "./local-task.js";
102
+ import {
103
+ createDurableLocalTaskApi,
104
+ createDurableLocalTaskWorker,
105
+ } from "./durable-local-task-worker.js";
106
+ import { createLocalTaskAttachmentStore } from "./local-task-attachment-store.js";
107
+ import {
108
+ LOCAL_TASK_FILE_CAPABILITY_REGISTRY,
109
+ LOCAL_TASK_FILE_CAPABILITY_VERSION,
110
+ LocalTaskFileCapabilityError,
111
+ localTaskFileErrorPayload,
112
+ } from "./local-task-file-capabilities.js";
113
+ import {
114
+ LocalTaskScenarioPackError,
115
+ normalizeLocalTaskScenarioPack,
116
+ validateLocalTaskScenarioInputs,
117
+ } from "./local-task-scenario-packs.js";
83
118
 
84
119
  const DEVICE_HEARTBEAT_INTERVAL_MS = 20_000;
85
120
 
@@ -95,10 +130,25 @@ export async function startAgent({
95
130
  const apiOrigin = resolveWebOrigin(webOrigin);
96
131
  const registry = new LocalRunRegistry();
97
132
  const directRegistry = new DirectTaskRegistry();
133
+ const localTaskRegistry = new LocalTaskRegistry();
134
+ const taskStorageRoot = localTasksRoot(
135
+ dependencies.localTasksRoot || (sessionRoot ? join(sessionRoot, "tasks") : undefined),
136
+ );
137
+ const localTaskAttachmentStore =
138
+ dependencies.localTaskAttachmentStore ||
139
+ createLocalTaskAttachmentStore({
140
+ root:
141
+ dependencies.localTaskAttachmentRoot ||
142
+ join(dirname(taskStorageRoot), "attachment-staging"),
143
+ });
144
+ await localTaskAttachmentStore.cleanupExpired?.();
98
145
  const authNonce = randomBytes(16).toString("base64url");
99
146
  let session = await readSession(apiOrigin, sessionRoot);
100
147
  if (session && sessionRequiresWebAuthorization(session, apiOrigin)) session = null;
101
148
  await cleanupExpiredRuns();
149
+ for (const task of await restoreLocalTasks({ root: taskStorageRoot })) {
150
+ localTaskRegistry.create(task);
151
+ }
102
152
  const workspace = dependencies.discoverWorkspace
103
153
  ? await dependencies.discoverWorkspace()
104
154
  : await discoverWorkspace(dependencies.cwd ?? process.cwd());
@@ -117,10 +167,36 @@ export async function startAgent({
117
167
  : [],
118
168
  );
119
169
  const installMode = updateManager?.snapshot?.()?.install_mode ?? null;
120
- const availableCapabilities = withCliContract(
121
- await readCapabilities(dependencies),
122
- installMode,
123
- );
170
+ const inspectedCapabilities = await readCapabilities(dependencies);
171
+ const contractSourceCapabilities =
172
+ inspectedCapabilities?.features?.local_task_control_plane === true
173
+ ? {
174
+ ...inspectedCapabilities,
175
+ features: {
176
+ ...inspectedCapabilities.features,
177
+ local_task_control_plane: daemon,
178
+ pipeline_local_task_artifacts:
179
+ daemon &&
180
+ inspectedCapabilities.features.pipeline_local_task_artifacts === true,
181
+ durable_local_task_attachments: daemon,
182
+ durable_local_task_steps:
183
+ daemon &&
184
+ inspectedCapabilities.features.durable_local_task_steps === true,
185
+ bounded_local_task_subtasks:
186
+ daemon &&
187
+ inspectedCapabilities.features.bounded_local_task_subtasks === true,
188
+ },
189
+ }
190
+ : inspectedCapabilities;
191
+ let availableCapabilities;
192
+ const refreshAvailableCapabilities = () => {
193
+ availableCapabilities = withCliContract(contractSourceCapabilities, installMode, {
194
+ authenticated:
195
+ isSessionValid(session, Date.now(), apiOrigin) && Boolean(session?.device_id),
196
+ });
197
+ return availableCapabilities;
198
+ };
199
+ refreshAvailableCapabilities();
124
200
  const heartbeatDevice = dependencies.heartbeatDevice || heartbeatLocalDevice;
125
201
  const runOptions = dependencies.runOptions || {};
126
202
  const repositoryScanRoot = resolve(
@@ -134,6 +210,7 @@ export async function startAgent({
134
210
  let port = requestedPort;
135
211
  let heartbeatTimer = null;
136
212
  let heartbeatInFlight = null;
213
+ let durableLocalTaskWorker = null;
137
214
 
138
215
  const restoreRemoteRuns = async () => {
139
216
  if (!isSessionValid(session, Date.now(), apiOrigin) || !session?.device_id) return;
@@ -165,15 +242,21 @@ export async function startAgent({
165
242
  const heartbeat = () => {
166
243
  if (!isSessionValid(session, Date.now(), apiOrigin)) return Promise.resolve(false);
167
244
  if (heartbeatInFlight) return heartbeatInFlight;
168
- heartbeatInFlight = heartbeatDevice({
169
- apiOrigin,
170
- apiBaseUrl: session.api_base_url,
171
- token: session.token,
172
- port,
173
- capabilities: availableCapabilities,
174
- repoInventory: repositoryInventory,
175
- })
176
- .then(() => true)
245
+ heartbeatInFlight = Promise.resolve()
246
+ .then(() =>
247
+ heartbeatDevice({
248
+ apiOrigin,
249
+ apiBaseUrl: session.api_base_url,
250
+ token: session.token,
251
+ port,
252
+ capabilities: refreshAvailableCapabilities(),
253
+ repoInventory: repositoryInventory,
254
+ }),
255
+ )
256
+ .then(async () => {
257
+ if (durableLocalTaskWorker) await durableLocalTaskWorker.poll();
258
+ return true;
259
+ })
177
260
  .finally(() => {
178
261
  heartbeatInFlight = null;
179
262
  });
@@ -234,12 +317,51 @@ export async function startAgent({
234
317
  };
235
318
 
236
319
  const ensureRepositoryIndexed = async (repository) => {
320
+ const verifyIdentity =
321
+ dependencies.verifyRepositoryIdentity || verifyRepositoryIdentity;
237
322
  const indexed = findRepository(repositoryInventory, repository);
238
- if (indexed || !isRepositoryScanAuthorized()) return indexed;
323
+ const verified = indexed
324
+ ? await verifyIdentity(indexed, repository)
325
+ : null;
326
+ if (verified || !isRepositoryScanAuthorized()) return verified;
239
327
  await refreshAuthorizedRepositoryInventory();
240
- return findRepository(repositoryInventory, repository);
328
+ const refreshed = findRepository(repositoryInventory, repository);
329
+ return refreshed
330
+ ? await verifyIdentity(refreshed, repository)
331
+ : null;
241
332
  };
242
333
 
334
+ if (
335
+ daemon &&
336
+ availableCapabilities?.features?.local_task_control_plane === true
337
+ ) {
338
+ const durableApi =
339
+ dependencies.durableLocalTaskApi ||
340
+ createDurableLocalTaskApi({
341
+ getConnection: () => ({
342
+ apiOrigin,
343
+ apiBaseUrl: session?.api_base_url,
344
+ token: session?.token,
345
+ }),
346
+ fetchImpl: dependencies.fetch || globalThis.fetch,
347
+ });
348
+ const createWorker =
349
+ dependencies.createDurableLocalTaskWorker ||
350
+ createDurableLocalTaskWorker;
351
+ durableLocalTaskWorker = createWorker({
352
+ api: durableApi,
353
+ registry: localTaskRegistry,
354
+ storageRoot: taskStorageRoot,
355
+ workspaceRoot: dependencies.localTaskWorkspaceRoot,
356
+ resolveRepository: ensureRepositoryIndexed,
357
+ runLocalTask: dependencies.runLocalTask || startLocalTask,
358
+ attachmentStore: localTaskAttachmentStore,
359
+ getDeviceId: () => session?.device_id || null,
360
+ beginWork: () => acquireUpdateWork(updateManager),
361
+ onError: (error) => reportDurableLocalTaskError(error, quiet),
362
+ });
363
+ }
364
+
243
365
  const selectRepository = async (run, repository) => {
244
366
  if (run) ensureAcceptingNewWork(updateManager);
245
367
  const selected = repository?.local_path
@@ -279,10 +401,14 @@ export async function startAgent({
279
401
 
280
402
  const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
281
403
  const agenticLoopPath = canonicalAgenticLoopPath(url.pathname);
404
+ const startsLocalExecution =
405
+ url.pathname === "/v1/local-tasks/start" ||
406
+ url.pathname === "/v1/direct-execution/start";
282
407
  if (
283
408
  req.method === "POST" &&
284
- agenticLoopPath.startsWith("/v1/agentic-loop") &&
285
- agenticLoopPath !== "/v1/agentic-loop/preflight"
409
+ ((agenticLoopPath.startsWith("/v1/agentic-loop") &&
410
+ agenticLoopPath !== "/v1/agentic-loop/preflight") ||
411
+ startsLocalExecution)
286
412
  ) {
287
413
  const releaseUpdateWork = acquireUpdateWork(updateManager);
288
414
  if (!releaseUpdateWork) {
@@ -306,7 +432,65 @@ export async function startAgent({
306
432
  return;
307
433
  }
308
434
  if (req.method === "GET" && url.pathname === "/v1/capabilities") {
309
- json(res, 200, availableCapabilities);
435
+ json(res, 200, refreshAvailableCapabilities());
436
+ return;
437
+ }
438
+ if (
439
+ req.method === "POST" &&
440
+ url.pathname === "/v1/local-tasks/attachments/stage"
441
+ ) {
442
+ if (!req.headers.origin) {
443
+ json(res, 403, {
444
+ error: "Open SIMY Web to attach files to a Local Task.",
445
+ code: "local_task_attachment_web_origin_required",
446
+ recovery: "reattach_file",
447
+ });
448
+ return;
449
+ }
450
+ if (!isSessionValid(session, Date.now(), apiOrigin) || !session?.device_id) {
451
+ json(res, 401, {
452
+ error: "Reconnect SIMY CLI before attaching files.",
453
+ code: "local_task_attachment_device_unavailable",
454
+ recovery: "reattach_file",
455
+ });
456
+ return;
457
+ }
458
+ try {
459
+ const files = await readAttachmentStage(req);
460
+ const prepared = await prepareRunAttachments({ attachments: files });
461
+ const staged = await localTaskAttachmentStore.stage({
462
+ deviceId: session.device_id,
463
+ idempotencyKey: req.headers["idempotency-key"],
464
+ files: prepared,
465
+ });
466
+ res.setHeader("Cache-Control", "no-store");
467
+ json(res, staged.replayed ? 200 : 201, {
468
+ ok: true,
469
+ replayed: staged.replayed,
470
+ attachment_refs: staged.refs,
471
+ });
472
+ } catch (error) {
473
+ if (error instanceof LocalTaskFileCapabilityError) {
474
+ json(
475
+ res,
476
+ error.code === "local_task_attachment_idempotency_conflict"
477
+ ? 409
478
+ : error.code === "local_task_attachment_request_too_large"
479
+ ? 413
480
+ : 400,
481
+ localTaskFileErrorPayload(error),
482
+ );
483
+ return;
484
+ }
485
+ json(res, 500, {
486
+ error:
487
+ "SIMY CLI could not safely prepare these files. Keep SIMY CLI running and attach them again.",
488
+ code: "local_task_attachment_stage_failed",
489
+ recovery: "reattach_file",
490
+ recovery_action:
491
+ LOCAL_TASK_FILE_CAPABILITY_REGISTRY.recovery_actions.reattach_file,
492
+ });
493
+ }
310
494
  return;
311
495
  }
312
496
  if (req.method === "POST" && agenticLoopPath === "/v1/agentic-loop/preflight") {
@@ -388,6 +572,356 @@ export async function startAgent({
388
572
  void synchronizeAuthorizedSession();
389
573
  return;
390
574
  }
575
+ const localTaskArtifactMatch = url.pathname.match(
576
+ /^\/v1\/local-tasks\/([^/]+)\/artifacts\/([^/]+)$/,
577
+ );
578
+ if (req.method === "GET" && localTaskArtifactMatch) {
579
+ if (!isSessionValid(session, Date.now(), apiOrigin)) {
580
+ json(res, 401, { error: "simy session expired; run simy again" });
581
+ return;
582
+ }
583
+ const task = localTaskRegistry.get(decodeURIComponent(localTaskArtifactMatch[1]));
584
+ const artifact = await localTaskArtifact(
585
+ task,
586
+ decodeURIComponent(localTaskArtifactMatch[2]),
587
+ );
588
+ if (!task || !artifact) {
589
+ json(res, 404, { error: "Local Task artifact not found" });
590
+ return;
591
+ }
592
+ res.writeHead(200, {
593
+ "Content-Type": artifact.mime_type,
594
+ "Content-Length": String(artifact.size_bytes),
595
+ "Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(artifact.name)}`,
596
+ "X-Content-Type-Options": "nosniff",
597
+ });
598
+ createReadStream(artifact.local_path)
599
+ .on("error", () => {
600
+ if (!res.headersSent) json(res, 500, { error: "Local Task artifact is unavailable" });
601
+ else res.destroy();
602
+ })
603
+ .pipe(res);
604
+ return;
605
+ }
606
+ const localTaskStreamMatch = url.pathname.match(/^\/v1\/local-tasks\/([^/]+)\/stream$/);
607
+ if (req.method === "GET" && localTaskStreamMatch) {
608
+ if (!isSessionValid(session, Date.now(), apiOrigin)) {
609
+ json(res, 401, { error: "simy session expired; run simy again" });
610
+ return;
611
+ }
612
+ streamLocalTask(res, localTaskRegistry.get(decodeURIComponent(localTaskStreamMatch[1])));
613
+ return;
614
+ }
615
+ const localTaskControlMatch = url.pathname.match(/^\/v1\/local-tasks\/([^/]+)\/control$/);
616
+ if (req.method === "POST" && localTaskControlMatch) {
617
+ if (!isSessionValid(session, Date.now(), apiOrigin)) {
618
+ json(res, 401, { error: "simy session expired; run simy again" });
619
+ return;
620
+ }
621
+ const task = localTaskRegistry.get(decodeURIComponent(localTaskControlMatch[1]));
622
+ if (!task) {
623
+ json(res, 404, { error: "Local Task not found" });
624
+ return;
625
+ }
626
+ const body = await readJson(req);
627
+ if (body.action !== "stop") {
628
+ json(res, 400, { error: "Local Task control action must be stop" });
629
+ return;
630
+ }
631
+ await stopLocalTask(task);
632
+ json(res, 200, { ok: true, task: localTaskSnapshot(task) });
633
+ return;
634
+ }
635
+ const localTaskMatch = url.pathname.match(/^\/v1\/local-tasks\/([^/]+)$/);
636
+ if (req.method === "GET" && url.pathname === "/v1/local-tasks/reconcile") {
637
+ if (!isSessionValid(session, Date.now(), apiOrigin)) {
638
+ json(res, 401, { error: "simy session expired; run simy again" });
639
+ return;
640
+ }
641
+ let task;
642
+ try {
643
+ task = localTaskRegistry.getByIdempotencyKey(req.headers["idempotency-key"]);
644
+ } catch (error) {
645
+ json(res, 400, { error: error instanceof Error ? error.message : String(error) });
646
+ return;
647
+ }
648
+ if (!task) {
649
+ res.setHeader("Cache-Control", "no-store");
650
+ json(res, 404, { error: "Local Task request not found" });
651
+ return;
652
+ }
653
+ res.setHeader("Cache-Control", "no-store");
654
+ res.setHeader("Location", `/v1/local-tasks/${task.id}`);
655
+ json(res, 200, { ok: true, replayed: true, task: localTaskSnapshot(task) });
656
+ return;
657
+ }
658
+ if (req.method === "GET" && localTaskMatch) {
659
+ if (!isSessionValid(session, Date.now(), apiOrigin)) {
660
+ json(res, 401, { error: "simy session expired; run simy again" });
661
+ return;
662
+ }
663
+ const task = localTaskRegistry.get(decodeURIComponent(localTaskMatch[1]));
664
+ if (!task) {
665
+ json(res, 404, { error: "Local Task not found" });
666
+ return;
667
+ }
668
+ json(res, 200, { task: localTaskSnapshot(task) });
669
+ return;
670
+ }
671
+ if (req.method === "POST" && url.pathname === "/v1/local-tasks/start") {
672
+ if (!isSessionValid(session, Date.now(), apiOrigin)) {
673
+ json(res, 401, { error: "simy session expired; run simy again" });
674
+ return;
675
+ }
676
+ const { body, attachments } = await readCodingLoopStart(req);
677
+ const instruction = String(body.instruction || "").trim();
678
+ if (!instruction) {
679
+ json(res, 400, { error: "instruction is required" });
680
+ return;
681
+ }
682
+ if (body.backend !== "codex" && body.backend !== "claude") {
683
+ json(res, 400, { error: "backend must be codex or claude" });
684
+ return;
685
+ }
686
+ const idempotencyKey = String(
687
+ req.headers["idempotency-key"] || body.client_request_id || "",
688
+ ).trim();
689
+ const guardrail = classifyExecutionRequirement(instruction);
690
+ if (
691
+ guardrail.decision !== "direct_executor" ||
692
+ body.guardrail?.policy_version !== EXECUTION_GUARDRAIL_POLICY_VERSION ||
693
+ body.guardrail?.decision !== "direct_executor"
694
+ ) {
695
+ json(res, 409, {
696
+ error: "This request is not authorized as a Local Task.",
697
+ code: "LOCAL_TASK_GUARD_REQUIRED",
698
+ guardrail,
699
+ });
700
+ return;
701
+ }
702
+ if (
703
+ body.operation !== guardrail.operation ||
704
+ body.permission_mode !== guardrail.permission_mode ||
705
+ body.max_provider_invocations !== 1
706
+ ) {
707
+ json(res, 409, {
708
+ error: "Local Task permissions do not match the task-routing decision.",
709
+ code: "LOCAL_TASK_GUARD_MISMATCH",
710
+ guardrail,
711
+ });
712
+ return;
713
+ }
714
+ const workspaceMode = String(
715
+ body.workspace_mode || guardrail.workspace_kind || "task_sandbox",
716
+ );
717
+ const repository = String(body.repository || "").trim() || null;
718
+ if (workspaceMode !== "repository" && workspaceMode !== "task_sandbox") {
719
+ json(res, 400, {
720
+ error: "workspace_mode must be task_sandbox or repository",
721
+ code: "unsupported_local_task_workspace",
722
+ });
723
+ return;
724
+ }
725
+ let preparedAttachments;
726
+ try {
727
+ preparedAttachments = await prepareRunAttachments({
728
+ attachments,
729
+ manifest: attachments.length > 0 ? body.attachment_manifest ?? [] : undefined,
730
+ });
731
+ } catch (error) {
732
+ json(res, 400, {
733
+ error: error instanceof Error ? error.message : "attachment rejected",
734
+ });
735
+ return;
736
+ }
737
+ let scenarioPack;
738
+ try {
739
+ scenarioPack = normalizeLocalTaskScenarioPack(body.scenario_pack);
740
+ validateLocalTaskScenarioInputs(scenarioPack, {
741
+ instruction,
742
+ attachments: preparedAttachments,
743
+ });
744
+ } catch (error) {
745
+ if (error instanceof LocalTaskScenarioPackError) {
746
+ json(res, 400, {
747
+ error: error.message,
748
+ code: error.code,
749
+ recovery: error.recovery,
750
+ });
751
+ return;
752
+ }
753
+ throw error;
754
+ }
755
+ let requestFingerprint;
756
+ try {
757
+ requestFingerprint = createLocalTaskRequestFingerprint({
758
+ instruction,
759
+ backend: body.backend,
760
+ operation: guardrail.operation,
761
+ permissionMode: guardrail.permission_mode,
762
+ workspaceMode,
763
+ repository,
764
+ expectedOutputs: body.expected_outputs,
765
+ providerTokenBudget: body.provider_token_budget,
766
+ timeoutMs: body.timeout_ms,
767
+ attachmentManifest: preparedAttachments,
768
+ scenarioPack,
769
+ });
770
+ hashLocalTaskIdempotencyKey(idempotencyKey);
771
+ } catch (error) {
772
+ json(res, 400, { error: error instanceof Error ? error.message : String(error) });
773
+ return;
774
+ }
775
+ let requestClaim;
776
+ while (true) {
777
+ requestClaim = localTaskRegistry.claimRequest(idempotencyKey, requestFingerprint);
778
+ if (requestClaim.status === "conflict") {
779
+ json(res, 409, {
780
+ error: "Idempotency-Key was already used for a different Local Task request.",
781
+ code: "LOCAL_TASK_IDEMPOTENCY_CONFLICT",
782
+ });
783
+ return;
784
+ }
785
+ if (requestClaim.status === "existing") {
786
+ res.setHeader("Location", `/v1/local-tasks/${requestClaim.task.id}`);
787
+ json(res, 200, {
788
+ ok: true,
789
+ replayed: true,
790
+ task: localTaskSnapshot(requestClaim.task),
791
+ });
792
+ return;
793
+ }
794
+ if (requestClaim.status === "pending") {
795
+ const pendingTask = await requestClaim.promise;
796
+ if (!pendingTask) continue;
797
+ res.setHeader("Location", `/v1/local-tasks/${pendingTask.id}`);
798
+ json(res, 200, {
799
+ ok: true,
800
+ replayed: true,
801
+ task: localTaskSnapshot(pendingTask),
802
+ });
803
+ return;
804
+ }
805
+ break;
806
+ }
807
+
808
+ let requestClaimCompleted = false;
809
+ let releaseUpdateWork = null;
810
+ let runtimeOwnsUpdateWork = false;
811
+ try {
812
+ if (!acceptsNewWork(updateManager)) {
813
+ json(res, 503, updateInProgressResponse(updateManager));
814
+ return;
815
+ }
816
+ releaseUpdateWork = acquireUpdateWork(updateManager);
817
+ if (!releaseUpdateWork) {
818
+ json(res, 503, updateInProgressResponse(updateManager));
819
+ return;
820
+ }
821
+ const inspection = normalizeBackendInspection(body.backend, availableCapabilities);
822
+ if (!inspection.compatible) {
823
+ const summary = backendPreflightSummary(body.backend, inspection);
824
+ json(res, 409, {
825
+ error: summary,
826
+ code: `desktop_executor_${inspection.status}`,
827
+ });
828
+ return;
829
+ }
830
+
831
+ let workspace;
832
+ if (workspaceMode === "repository") {
833
+ if (!repository) {
834
+ json(res, 400, { error: "repository is required for this Local Task" });
835
+ return;
836
+ }
837
+ const indexedRepository = await ensureRepositoryIndexed(repository);
838
+ if (!indexedRepository?.local_path) {
839
+ json(res, 409, {
840
+ error: `${repository} is not authorized on this SIMY CLI.`,
841
+ code: "repository_not_authorized",
842
+ });
843
+ return;
844
+ }
845
+ const id = createLocalTaskId();
846
+ const prepared = await prepareLocalTaskWorkspace({
847
+ taskId: id,
848
+ root: taskStorageRoot,
849
+ workspaceRoot: taskStorageRoot,
850
+ });
851
+ workspace = {
852
+ ...prepared,
853
+ id,
854
+ workspace_path: indexedRepository.local_path,
855
+ };
856
+ } else {
857
+ const id = createLocalTaskId();
858
+ workspace = {
859
+ id,
860
+ ...(await prepareLocalTaskWorkspace({
861
+ taskId: id,
862
+ root: taskStorageRoot,
863
+ workspaceRoot: dependencies.localTaskWorkspaceRoot,
864
+ })),
865
+ };
866
+ }
867
+ const stagedAttachments = await stagePreparedRunAttachments({
868
+ runId: workspace.id,
869
+ prepared: preparedAttachments,
870
+ root: taskStorageRoot,
871
+ });
872
+ const task = createLocalTask({
873
+ id: workspace.id,
874
+ instruction,
875
+ backend: body.backend,
876
+ operation: guardrail.operation,
877
+ permissionMode: guardrail.permission_mode,
878
+ workspaceMode,
879
+ repository,
880
+ workspacePath: workspace.workspace_path,
881
+ taskRoot: workspace.task_root,
882
+ inputsRoot: workspace.inputs_root,
883
+ outputsRoot: workspace.outputs_root,
884
+ outputsRootExpected: workspace.outputs_root_expected,
885
+ attachments: stagedAttachments,
886
+ expectedOutputs: body.expected_outputs,
887
+ scenarioPack,
888
+ providerTokenBudget: body.provider_token_budget,
889
+ timeoutMs: body.timeout_ms,
890
+ idempotencyKeyHash: hashLocalTaskIdempotencyKey(idempotencyKey),
891
+ requestFingerprint,
892
+ });
893
+ await persistLocalTask(task);
894
+ localTaskRegistry.create(task);
895
+ requestClaim.complete(task);
896
+ requestClaimCompleted = true;
897
+ const runLocalTask = dependencies.runLocalTask || startLocalTask;
898
+ task.operation_promise = Promise.resolve(runLocalTask(task))
899
+ .catch(async (error) => {
900
+ task.status = "failed";
901
+ task.control_state = "complete";
902
+ task.error = error instanceof Error ? error.message : String(error);
903
+ task.reason_code ||= "local_task_runtime_error";
904
+ task.completed_at = new Date().toISOString();
905
+ task.updated_at = task.completed_at;
906
+ await persistLocalTask(task);
907
+ })
908
+ .finally(() => {
909
+ task.operation_promise = null;
910
+ releaseUpdateWork();
911
+ });
912
+ runtimeOwnsUpdateWork = true;
913
+ res.setHeader("Location", `/v1/local-tasks/${task.id}`);
914
+ json(res, 202, {
915
+ ok: true,
916
+ replayed: false,
917
+ task: localTaskSnapshot(task),
918
+ });
919
+ return;
920
+ } finally {
921
+ if (!requestClaimCompleted) requestClaim.release();
922
+ if (releaseUpdateWork && !runtimeOwnsUpdateWork) releaseUpdateWork();
923
+ }
924
+ }
391
925
  const directExecutionMatch = url.pathname.match(/^\/v1\/direct-execution\/([^/]+)$/);
392
926
  if (req.method === "GET" && directExecutionMatch) {
393
927
  if (!isSessionValid(session, Date.now(), apiOrigin)) {
@@ -486,11 +1020,18 @@ export async function startAgent({
486
1020
  });
487
1021
  directRegistry.create(task);
488
1022
  const runDirectTask = dependencies.runDirectTask || startDirectTask;
489
- void Promise.resolve(runDirectTask(task)).catch((error) => {
490
- task.status = "failed";
491
- task.error = error instanceof Error ? error.message : String(error);
492
- task.updated_at = new Date().toISOString();
493
- });
1023
+ const releaseUpdateWork = acquireUpdateWork(updateManager);
1024
+ if (!releaseUpdateWork) {
1025
+ json(res, 503, updateInProgressResponse(updateManager));
1026
+ return;
1027
+ }
1028
+ void Promise.resolve(runDirectTask(task))
1029
+ .catch((error) => {
1030
+ task.status = "failed";
1031
+ task.error = error instanceof Error ? error.message : String(error);
1032
+ task.updated_at = new Date().toISOString();
1033
+ })
1034
+ .finally(releaseUpdateWork);
494
1035
  json(res, 202, { ok: true, task: directTaskSnapshot(task) });
495
1036
  return;
496
1037
  }
@@ -878,6 +1419,10 @@ export async function startAgent({
878
1419
 
879
1420
  json(res, 404, { error: "not found" });
880
1421
  } catch (err) {
1422
+ if (err instanceof LocalTaskFileCapabilityError) {
1423
+ json(res, 400, localTaskFileErrorPayload(err));
1424
+ return;
1425
+ }
881
1426
  json(res, 500, { error: err instanceof Error ? err.message : "internal error" });
882
1427
  }
883
1428
  });
@@ -915,23 +1460,38 @@ export async function startAgent({
915
1460
  startHeartbeatTimer();
916
1461
  }
917
1462
 
1463
+ let shutdownPromise = null;
1464
+ const shutdown = () => {
1465
+ if (shutdownPromise) return shutdownPromise;
1466
+ shutdownPromise = (async () => {
1467
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
1468
+ await durableLocalTaskWorker?.close?.();
1469
+ updateManager?.stop?.();
1470
+ registry.close();
1471
+ localTaskRegistry.close();
1472
+ })();
1473
+ return shutdownPromise;
1474
+ };
918
1475
  server.on("close", () => {
919
- if (heartbeatTimer) clearInterval(heartbeatTimer);
920
- updateManager?.stop?.();
921
- registry.close();
1476
+ void shutdown().catch((error) => reportDurableLocalTaskError(error, quiet));
922
1477
  });
923
1478
  return {
924
1479
  server,
925
1480
  port,
926
1481
  registry,
927
1482
  directRegistry,
1483
+ localTaskRegistry,
1484
+ durableLocalTaskWorker,
1485
+ shutdown,
928
1486
  webOrigin: apiOrigin,
929
1487
  get loginUrl() {
930
1488
  return loginUrl?.toString() ?? null;
931
1489
  },
932
1490
  workspace,
933
1491
  repositoryScanRoot,
934
- capabilities: availableCapabilities,
1492
+ get capabilities() {
1493
+ return refreshAvailableCapabilities();
1494
+ },
935
1495
  updates: updateManager,
936
1496
  controls: {
937
1497
  repositoryInventory: () => [...repositoryInventory],
@@ -1132,6 +1692,13 @@ function reportHeartbeatError(error, quiet) {
1132
1692
  );
1133
1693
  }
1134
1694
 
1695
+ function reportDurableLocalTaskError(error, quiet) {
1696
+ if (quiet) return;
1697
+ console.error(
1698
+ `SIMY Local Task worker: ${error instanceof Error ? error.message : String(error)}`,
1699
+ );
1700
+ }
1701
+
1135
1702
  function reportStartupConnectionError(error, quiet) {
1136
1703
  if (quiet) return;
1137
1704
  console.error(
@@ -1212,6 +1779,18 @@ async function capabilities() {
1212
1779
  executor_version_preflight: true,
1213
1780
  desktop_executor: true,
1214
1781
  direct_execution: true,
1782
+ local_tasks: true,
1783
+ local_task_attachments: true,
1784
+ local_task_artifacts: true,
1785
+ local_task_streaming: true,
1786
+ local_task_idempotency: true,
1787
+ local_task_reconciliation: true,
1788
+ local_task_control_plane: true,
1789
+ pipeline_local_task_artifacts: true,
1790
+ durable_local_task_attachments: true,
1791
+ durable_local_task_scenario_packs: true,
1792
+ durable_local_task_steps: true,
1793
+ bounded_local_task_subtasks: true,
1215
1794
  },
1216
1795
  session_ttl_hours: 48,
1217
1796
  };
@@ -1375,6 +1954,38 @@ function canonicalAgenticLoopPath(pathname) {
1375
1954
  return pathname.replace(/^\/v1\/coding-loop(?=\/|$)/, "/v1/agentic-loop");
1376
1955
  }
1377
1956
 
1957
+ function streamLocalTask(res, task) {
1958
+ if (!task) {
1959
+ json(res, 404, { error: "Local Task not found" });
1960
+ return;
1961
+ }
1962
+ res.writeHead(200, {
1963
+ "Content-Type": "text/event-stream",
1964
+ "Cache-Control": "no-cache, no-transform",
1965
+ Connection: "keep-alive",
1966
+ });
1967
+ const send = (event, data) => {
1968
+ res.write(`event: ${event}\n`);
1969
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
1970
+ };
1971
+ send("snapshot", localTaskSnapshot(task));
1972
+ const listener = (event) => {
1973
+ send(event.type || "event", event);
1974
+ if (["succeeded", "failed", "stopped"].includes(event.state)) {
1975
+ send("snapshot", localTaskSnapshot(task));
1976
+ }
1977
+ };
1978
+ task.emitter.on("event", listener);
1979
+ const heartbeat = setInterval(() => res.write(": heartbeat\n\n"), 15_000);
1980
+ heartbeat.unref?.();
1981
+ const close = () => {
1982
+ clearInterval(heartbeat);
1983
+ task.emitter.off("event", listener);
1984
+ };
1985
+ res.on("close", close);
1986
+ res.on("error", close);
1987
+ }
1988
+
1378
1989
  function streamRun(res, run) {
1379
1990
  if (!run) {
1380
1991
  json(res, 404, { error: "run not found" });
@@ -1408,7 +2019,11 @@ function applyCors(req, res, webOrigin) {
1408
2019
  res.setHeader("Access-Control-Allow-Private-Network", "true");
1409
2020
  }
1410
2021
  }
1411
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
2022
+ res.setHeader(
2023
+ "Access-Control-Allow-Headers",
2024
+ "Content-Type, Authorization, Idempotency-Key",
2025
+ );
2026
+ res.setHeader("Access-Control-Expose-Headers", "Location");
1412
2027
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
1413
2028
  }
1414
2029
 
@@ -1428,20 +2043,91 @@ async function readCodingLoopStart(req) {
1428
2043
  if (!contentType.toLowerCase().startsWith("multipart/form-data")) {
1429
2044
  return { body: await readJson(req), attachments: [] };
1430
2045
  }
2046
+ const form = await readMultipartForm(req);
2047
+ const payload = form.get("payload");
2048
+ if (typeof payload !== "string") throw new Error("multipart payload field is required");
2049
+ const attachments = form
2050
+ .getAll("attachments")
2051
+ .filter((value) => value && typeof value === "object" && typeof value.arrayBuffer === "function");
2052
+ return { body: JSON.parse(payload), attachments };
2053
+ }
2054
+
2055
+ async function readAttachmentStage(req) {
2056
+ const contentType = String(req.headers["content-type"] || "");
2057
+ if (!contentType.toLowerCase().startsWith("multipart/form-data")) {
2058
+ throw new LocalTaskFileCapabilityError(
2059
+ "Choose files to attach before continuing.",
2060
+ {
2061
+ code: "local_task_attachment_multipart_required",
2062
+ recoveryAction: {
2063
+ id: "reattach_file",
2064
+ label: "Attach the files again",
2065
+ description: "Choose the original files again.",
2066
+ },
2067
+ },
2068
+ );
2069
+ }
2070
+ const form = await readMultipartForm(req);
2071
+ const requestedVersion = form.get("capability_version");
2072
+ if (
2073
+ requestedVersion !== null &&
2074
+ requestedVersion !== LOCAL_TASK_FILE_CAPABILITY_VERSION
2075
+ ) {
2076
+ throw new LocalTaskFileCapabilityError(
2077
+ "Update SIMY CLI before attaching files.",
2078
+ {
2079
+ code: "local_task_attachment_version_unsupported",
2080
+ recoveryAction: {
2081
+ id: "update_cli",
2082
+ label: "Update SIMY CLI",
2083
+ description: "Update and restart SIMY CLI, then attach the files again.",
2084
+ },
2085
+ },
2086
+ );
2087
+ }
2088
+ // Caller-provided names, MIME types, sizes, and hashes are intentionally not
2089
+ // trusted. The CLI derives and verifies the complete manifest from File bytes.
2090
+ return form
2091
+ .getAll("attachments")
2092
+ .filter(
2093
+ (value) =>
2094
+ value &&
2095
+ typeof value === "object" &&
2096
+ typeof value.arrayBuffer === "function",
2097
+ );
2098
+ }
2099
+
2100
+ export async function readMultipartForm(
2101
+ req,
2102
+ { maxBytes = 52 * 1024 * 1024 } = {},
2103
+ ) {
2104
+ const contentType = String(req.headers["content-type"] || "");
2105
+ const declaredSize = Number(req.headers["content-length"]);
2106
+ if (Number.isFinite(declaredSize) && declaredSize > maxBytes) {
2107
+ throw multipartTooLargeError();
2108
+ }
1431
2109
  const chunks = [];
1432
2110
  let size = 0;
1433
2111
  for await (const chunk of req) {
1434
2112
  size += chunk.length;
1435
- if (size > 52 * 1024 * 1024) throw new Error("attachment request exceeds the 52 MB limit");
2113
+ if (size > maxBytes) throw multipartTooLargeError();
1436
2114
  chunks.push(Buffer.from(chunk));
1437
2115
  }
1438
- const form = await new Response(Buffer.concat(chunks), {
2116
+ return new Response(Buffer.concat(chunks), {
1439
2117
  headers: { "Content-Type": contentType },
1440
2118
  }).formData();
1441
- const payload = form.get("payload");
1442
- if (typeof payload !== "string") throw new Error("multipart payload field is required");
1443
- const attachments = form
1444
- .getAll("attachments")
1445
- .filter((value) => value && typeof value === "object" && typeof value.arrayBuffer === "function");
1446
- return { body: JSON.parse(payload), attachments };
2119
+ }
2120
+
2121
+ function multipartTooLargeError() {
2122
+ return new LocalTaskFileCapabilityError(
2123
+ "The attached files are larger than the 50 MiB request limit.",
2124
+ {
2125
+ code: "local_task_attachment_request_too_large",
2126
+ recoveryAction: {
2127
+ id: "reduce_file_size",
2128
+ label: "Use smaller files",
2129
+ description: "Compress, shorten, or split the files before attaching them again.",
2130
+ },
2131
+ },
2132
+ );
1447
2133
  }