@awak-app/simy-cli 0.7.18 → 0.7.20

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 CHANGED
@@ -248,15 +248,19 @@ Closing the window keeps the agent running. Open the app again or choose
248
248
  **Show SIMY** from its menu bar icon to return. **Quit SIMY** asks before
249
249
  interrupting active tasks. The normal `simy` command remains available.
250
250
 
251
- Signed patch/minor updates wait for idle and verify the new runtime before
252
- handoff; failed startup restores the previous installed version. After a runtime
253
- update, choose **Restart to update** to load the updated native interface. This
254
- action asks to stop running tasks before restarting. **Stop task…** in a task
251
+ The macOS **Automatic updates** switch saves this Mac's choice across restarts.
252
+ Compatible signed installations enable it by default. Patch/minor updates wait
253
+ for idle and verify the new runtime before handoff; failed startup restores the
254
+ previous installed version. The app then switches to the verified new interface
255
+ when no unfinished work or confirmation is pending. Turning the switch off keeps
256
+ manual checks and **Download DMG** available. An explicit `SIMY_AUTO_UPDATE=0`
257
+ remains authoritative, and major updates continue to use the DMG.
258
+ **Stop task…** in a task
255
259
  detail asks for confirmation and terminates its local Provider process, keeping
256
260
  existing output and history. An explicit update can likewise stop active work
257
261
  after confirmation; automatic updates still wait for idle. **⌘Q** exits SIMY and
258
- its background services directly, including when work is running. The window reports App and runtime versions separately while they
259
- differ, and normal app launches select the newer installed interface.
262
+ its background services directly, including when work is running. The window shows
263
+ the app version, and normal app launches select the newer installed interface.
260
264
 
261
265
  Older installers that predate native app relaunch support need a one-time
262
266
  reinstallation from the official download page to update their Applications
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.7.18",
3
+ "version": "0.7.20",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -86,6 +86,7 @@ export class CliAutoUpdater extends EventEmitter {
86
86
  install_mode: options.installMode,
87
87
  automatic: Boolean(
88
88
  !options.disabled &&
89
+ options.autoUpdatesEnabled !== false &&
89
90
  options.daemon &&
90
91
  (options.installMode === "global_npm" ||
91
92
  (options.installMode === MACOS_INSTALLER_MODE && options.installerMarker?.teamId)) &&
@@ -101,7 +102,20 @@ export class CliAutoUpdater extends EventEmitter {
101
102
  }
102
103
 
103
104
  snapshot() {
104
- return structuredClone({ ...this.#snapshot, requires_task_stop: !this.#isIdle() });
105
+ const supported = !this.#options.disabled && this.#options.daemon && this.#options.requestedPort === 0 &&
106
+ (this.#options.installMode === 'global_npm' || this.#options.installMode === MACOS_INSTALLER_MODE && Boolean(this.#options.installerMarker?.teamId));
107
+ return structuredClone({ ...this.#snapshot, auto_update_supported: Boolean(supported),
108
+ auto_update_enabled: this.#snapshot.automatic, auto_update_busy: Boolean(this.#applying || this.#confirmedUpdate), requires_task_stop: !this.#isIdle() });
109
+ }
110
+
111
+ setAutomaticUpdates(enabled) {
112
+ if (typeof enabled !== 'boolean') throw new Error('Choose whether automatic updates are enabled.');
113
+ if (enabled && !this.snapshot().auto_update_supported) throw new Error('Automatic updates are unavailable for this installation.');
114
+ if (this.#applying || this.#confirmedUpdate) throw new Error('Wait for the current update to finish.');
115
+ this.#options.autoUpdatesEnabled = enabled;
116
+ if (!enabled) this.#clearIdleTimer();
117
+ this.#setState(!enabled && this.#snapshot.state === 'waiting_for_idle' ? 'update_available' : this.#snapshot.state,
118
+ { automatic: enabled, error: null });
105
119
  }
106
120
 
107
121
  // Close admission before cancellation begins. The same transaction owns the
@@ -435,6 +449,7 @@ export async function createCliAutoUpdater({
435
449
  interactive,
436
450
  requestedPort = 0,
437
451
  disabled = false,
452
+ autoUpdatesEnabled = true,
438
453
  packageRoot = PACKAGE_ROOT,
439
454
  webOrigin,
440
455
  dependencies = {},
@@ -458,6 +473,7 @@ export async function createCliAutoUpdater({
458
473
  interactive: Boolean(interactive),
459
474
  requestedPort,
460
475
  disabled,
476
+ autoUpdatesEnabled,
461
477
  currentVersion: packageInfo.version,
462
478
  installMode,
463
479
  installerMarker,
@@ -10,10 +10,11 @@ import {
10
10
  LOCAL_TASK_STEP_CONTEXT_SCHEMA,
11
11
  LOCAL_TASK_STEP_SCHEMA,
12
12
  MAX_LOCAL_TASK_PLAN_STEPS,
13
+ WORKFLOW_LOCAL_TASK_ARTIFACT_DELIVERY,
13
14
  } from "./durable-local-task-steps-contract.js";
14
15
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
15
16
 
16
- export const CLI_VERSION = "0.7.18";
17
+ export const CLI_VERSION = "0.7.20";
17
18
  export const CLI_API_CONTRACT_VERSION = 10;
18
19
 
19
20
  export function narrativeImageCapabilityMetadata() {
@@ -43,6 +44,7 @@ export function withCliContract(
43
44
  plan_schema_version: LOCAL_TASK_PLAN_SCHEMA,
44
45
  step_schema_version: LOCAL_TASK_STEP_SCHEMA,
45
46
  context_schema_version: LOCAL_TASK_STEP_CONTEXT_SCHEMA,
47
+ workflow_artifact_delivery: WORKFLOW_LOCAL_TASK_ARTIFACT_DELIVERY,
46
48
  max_steps: MAX_LOCAL_TASK_PLAN_STEPS,
47
49
  },
48
50
  execution_capabilities: buildExecutionCapabilityContract({
@@ -8,7 +8,7 @@ export function validDesktopAccessPath(path, slug) {
8
8
  }
9
9
  export const desktopAccessReady = value => value?.state === 'ready';
10
10
  const recoveryActions = new Set(['refresh', 'reconnect', 'login', 'authorization_open', 'organization_select', 'organization_cancel', 'organizations_refresh',
11
- 'check_update', 'update', 'prepare_update_restart', 'quit', 'task_stop', 'delivery_stop', 'delivery_pause', 'delivery_reject',
11
+ 'check_update', 'auto_update_set_enabled', 'update', 'prepare_update_restart', 'quit', 'task_stop', 'delivery_stop', 'delivery_pause', 'delivery_reject',
12
12
  'provider_disconnect', 'desktop_access_refresh', 'desktop_access_settings', 'desktop_access_members']);
13
13
  export function desktopActionAllowed(access, command) {
14
14
  return desktopAccessReady(access) || recoveryActions.has(command?.action) ||
@@ -1,4 +1,5 @@
1
1
  import { createRemoteHostManager } from './session-supervision/remote-hosts.js';
2
+ import { readDesktopAutoUpdate, writeDesktopAutoUpdate } from './desktop-update-settings.js';
2
3
  import { createUpdateRuntime } from './update-detachment/runtime.js';
3
4
  import { createCoordinatorReconciler } from "./update-detachment/reconciliation-adapter.js";
4
5
  import { createLedgerCoordinatorAdapters } from "./update-detachment/ledger-adapter.js";
@@ -169,6 +170,11 @@ export async function startDesktopHost({ input = process.stdin, output = process
169
170
  let encodedStatus = null, lastStatus = null, updateProgress = null;
170
171
  let remoteHosts;
171
172
  const emitStatus = status => {
173
+ const updateSettings = updater?.snapshot?.() || {};
174
+ status = { ...status, update: { ...status.update,
175
+ auto_update_supported: updateSettings.auto_update_supported === true,
176
+ auto_update_enabled: updateSettings.auto_update_enabled === true,
177
+ auto_update_busy: updateSettings.auto_update_busy === true } };
172
178
  if (env.SIMY_REMOTE_CHILD === "1") status = { ...status, remoteSessionProtocol: 1 };
173
179
  if (remoteHosts) {
174
180
  void remoteHosts.update(providerLinks.codex.connected ? status : { ...status, access: { state: "unavailable" } }).catch(() => {});
@@ -644,8 +650,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
644
650
  const sampledAccess = structuredClone(status.access || { state: 'unavailable', organizationId: null, organizationName: null });
645
651
  if (!await acceptStatusAccess(sampledAccess)) { publishAgain = true; return; }
646
652
  status.access = sampledAccess;
647
- status.updateDetachment = updateRuntime.snapshot(coordinator.snapshot(),updater?.snapshot?.());
648
- if(!desktopAccessReady(sampledAccess))status.updateDetachment={...status.updateDetachment,tasks:[],actions:status.updateDetachment.actions.filter(action=>action==='cancel_detach_update')};
653
+ status.updateDetachment = updateRuntime.snapshot(coordinator.snapshot(),updater?.snapshot?.(),{accessReady:desktopAccessReady(sampledAccess)});
649
654
  const requestedLinks = await decorateProviderSettings(status);
650
655
  const sameScope = desktopAccessReady(currentAccess) &&
651
656
  accessScope(lastStatus?.access) === accessScope(currentAccess);
@@ -694,8 +699,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
694
699
  const sampledAccess = structuredClone(status.access || { state: 'unavailable', organizationId: null, organizationName: null });
695
700
  if (!await acceptStatusAccess(sampledAccess)) { publishAgain = true; return; }
696
701
  status.access = sampledAccess;
697
- status.updateDetachment = updateRuntime.snapshot(coordinator.snapshot(),updater?.snapshot?.());
698
- if(!desktopAccessReady(sampledAccess))status.updateDetachment={...status.updateDetachment,tasks:[],actions:status.updateDetachment.actions.filter(action=>action==='cancel_detach_update')};
702
+ status.updateDetachment = updateRuntime.snapshot(coordinator.snapshot(),updater?.snapshot?.(),{accessReady:desktopAccessReady(sampledAccess)});
699
703
  const requestedScope = accessScope(currentAccess);
700
704
  const requestedLinks = await decorateProviderSettings(status);
701
705
  const allRecords = await recordReader.list();
@@ -841,6 +845,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
841
845
  updater = await (dependencies.createUpdater || (process.platform === "win32" ? (() => createWindowsDesktopUpdater({ version: packageInfo.version, disabled: env.SIMY_AUTO_UPDATE === "0" })) : createCliAutoUpdater))({
842
846
  daemon: true, interactive: true, webOrigin,
843
847
  disabled: env.SIMY_AUTO_UPDATE === "0",
848
+ autoUpdatesEnabled: readDesktopAutoUpdate(root),
844
849
  ...(updatePackageRoot ? { packageRoot: updatePackageRoot } : {}),
845
850
  dependencies: { packageInfo: updatePackageRoot ? await readCliPackageInfo(updatePackageRoot) : packageInfo,
846
851
  logger: message => diagnostics.write(`update: ${message}`) },
@@ -1055,7 +1060,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1055
1060
  return;
1056
1061
  }
1057
1062
  if(command.action==='cancel_detach_update') {
1058
- try {updateRuntime.validate(command,coordinator.snapshot(),updater?.snapshot?.());coordinator.cancel();emit({type:'result',id:command.id,ok:true});}
1063
+ try {updateRuntime.validate(command,coordinator.snapshot(),updater?.snapshot?.(),{accessReady:desktopAccessReady(currentAccess)});coordinator.cancel();emit({type:'result',id:command.id,ok:true});}
1059
1064
  catch {emit({type:'result',id:command.id,ok:false,message:'This update operation changed. Refresh its status.'});}
1060
1065
  return;
1061
1066
  }
@@ -1124,7 +1129,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1124
1129
  const access = await accessSnapshot();
1125
1130
  const commandScope = accessScope(access);
1126
1131
  if (!desktopActionAllowed(access, command)) throw Object.assign(new Error('Desktop access is required.'), { code: 'desktop_access_required' });
1127
- const recoveryControl = ['provider_disconnect', 'session_unmonitor'].includes(action) ||
1132
+ const recoveryControl = ['provider_disconnect', 'session_unmonitor'].includes(action) || action === 'auto_update_set_enabled' && command.enabled === false ||
1128
1133
  ['session_set_auto_continue', 'session_set_email_reports', 'team_thread_share'].includes(action) && command.enabled === false;
1129
1134
  if (['detach_update', 'update_reconnect'].includes(action)) requireDesktopAccess(access);
1130
1135
  else if (!recoveryControl && desktopAccessReady(access) && !desktopActionAllowed({ state: 'unavailable' }, command)) await authorizeWork();
@@ -1278,6 +1283,16 @@ export async function startDesktopHost({ input = process.stdin, output = process
1278
1283
  if (!result.opened) throw new Error('Browser unavailable.');
1279
1284
  } catch { throw Object.assign(new Error('Browser unavailable.'), { code: 'desktop_browser_unavailable' }); }
1280
1285
  }
1286
+ } else if (action === 'auto_update_set_enabled') {
1287
+ if (typeof command.enabled !== 'boolean' || typeof updater.setAutomaticUpdates !== 'function') throw new Error('Automatic updates are unavailable.');
1288
+ const previous = updater.snapshot().auto_update_enabled === true;
1289
+ updater.setAutomaticUpdates(command.enabled);
1290
+ try { writeDesktopAutoUpdate(root, command.enabled); }
1291
+ catch (error) { updater.setAutomaticUpdates(previous); throw error; }
1292
+ emit({ type: 'result', id: command.id, ok: true });
1293
+ await publish();
1294
+ if (command.enabled) setImmediate(() => { if (!closed && !relay) void updater.checkNow().catch(() => {}); });
1295
+ return;
1281
1296
  } else if (action === "check_update") await updater.checkNow();
1282
1297
  else if (action === "update") {
1283
1298
  const status = await agent.getDesktopStatus();
@@ -0,0 +1,15 @@
1
+ import { join } from 'node:path';
2
+ import { readState, atomicSave } from './update-detachment/state-store.js';
3
+
4
+ export function readDesktopAutoUpdate(root) {
5
+ try {
6
+ const settings = readState(join(root, 'desktop-update-settings.json'));
7
+ return settings === null ? true : settings.enabled === true;
8
+ }
9
+ catch { return false; }
10
+ }
11
+
12
+ export function writeDesktopAutoUpdate(root, enabled) {
13
+ if (typeof enabled !== 'boolean') throw new Error('Choose whether automatic updates are enabled.');
14
+ atomicSave(join(root, 'desktop-update-settings.json'), { schemaVersion: 1, enabled });
15
+ }
@@ -20,6 +20,11 @@ export const LOCAL_TASK_PLAN_ORCHESTRATION_SCHEMA =
20
20
  export const LOCAL_TASK_PLAN_SCHEMA = "local_task_plan.v1";
21
21
  export const LOCAL_TASK_STEP_SCHEMA = "local_task_step.v1";
22
22
  export const LOCAL_TASK_STEP_CONTEXT_SCHEMA = "local_task_step_context.v1";
23
+ // This optional field stays on the existing closed claim schema. Backend only
24
+ // emits it to devices that advertise the matching capability, so older strict
25
+ // clients never receive a field they cannot parse.
26
+ export const WORKFLOW_LOCAL_TASK_ARTIFACT_DELIVERY =
27
+ "workflow_local_task_artifact.v1";
23
28
  export const MAX_LOCAL_TASK_PLAN_STEPS = 8;
24
29
 
25
30
  const UUID_PATTERN =
@@ -254,6 +259,9 @@ export function normalizeDurableLocalTaskStepClaim(
254
259
  "lease_expires_at",
255
260
  "claim_replayed",
256
261
  ...(Object.hasOwn(claim, "model") ? ["model"] : []),
262
+ ...(Object.hasOwn(claim, "workflow_artifact_delivery")
263
+ ? ["workflow_artifact_delivery"]
264
+ : []),
257
265
  ]),
258
266
  "claim",
259
267
  );
@@ -264,6 +272,15 @@ export function normalizeDurableLocalTaskStepClaim(
264
272
  fail("Unsupported durable Local Task step disposition.");
265
273
  }
266
274
  const model = claim.model == null ? null : requiredString(claim.model, "model", 160);
275
+ const workflowArtifactDelivery = claim.workflow_artifact_delivery === undefined
276
+ ? false
277
+ : claim.workflow_artifact_delivery === WORKFLOW_LOCAL_TASK_ARTIFACT_DELIVERY;
278
+ if (
279
+ claim.workflow_artifact_delivery !== undefined &&
280
+ !workflowArtifactDelivery
281
+ ) {
282
+ fail("Unsupported durable Local Task WorkFlow artifact delivery contract.");
283
+ }
267
284
  const turnId = requiredUuid(claim.turn_id, "turn_id");
268
285
  const plan = normalizePlan(claim.plan);
269
286
  const currentStepId = requiredStepId(claim.current_step_id, "current_step_id");
@@ -309,6 +326,7 @@ export function normalizeDurableLocalTaskStepClaim(
309
326
  leaseEpoch: null,
310
327
  leaseExpiresAtMs: null,
311
328
  claimReplayed: false,
329
+ workflowArtifactDelivery,
312
330
  };
313
331
  }
314
332
 
@@ -366,6 +384,7 @@ export function normalizeDurableLocalTaskStepClaim(
366
384
  leaseEpoch,
367
385
  leaseExpiresAtMs,
368
386
  claimReplayed: claim.claim_replayed,
387
+ workflowArtifactDelivery,
369
388
  remainingProviderTokens: remainingTokens,
370
389
  remainingTimeoutMs: remainingMs,
371
390
  };
@@ -361,6 +361,19 @@ export function createDurableLocalTaskApi({
361
361
  { ambiguousOnTransport: true },
362
362
  );
363
363
  },
364
+ async prepareWorkflowArtifact({ turnId, workerId, leaseEpoch, localArtifactId, name, mimeType, sizeBytes, sha256 }) {
365
+ return request(`local-tasks/${encodeURIComponent(turnId)}/workflow-artifacts/prepare`, {
366
+ turn_id: turnId, worker_id: workerId, lease_epoch: leaseEpoch,
367
+ local_artifact_id: localArtifactId, name, mime_type: mimeType,
368
+ size_bytes: sizeBytes, sha256,
369
+ }, { ambiguousOnTransport: true });
370
+ },
371
+ async commitWorkflowArtifact({ uploadId, turnId, workerId, leaseEpoch, localArtifactId, sha256, sizeBytes }) {
372
+ return request(`local-tasks/${encodeURIComponent(turnId)}/workflow-artifacts/commit`, {
373
+ upload_id: uploadId, turn_id: turnId, worker_id: workerId, lease_epoch: leaseEpoch,
374
+ local_artifact_id: localArtifactId, sha256, size_bytes: sizeBytes,
375
+ }, { ambiguousOnTransport: true });
376
+ },
364
377
  async heartbeatStep(input) {
365
378
  const contract = stepContract.heartbeat(input);
366
379
  const payload = await request(
@@ -677,6 +690,7 @@ export function createDurableLocalTaskWorker({
677
690
  persist = persistLocalTask,
678
691
  stopTask = stopLocalTask,
679
692
  materializeArtifacts = materializePipelineArtifacts,
693
+ materializeWorkflowArtifacts = materializeWorkflowLocalTaskArtifacts,
680
694
  attachmentStore = null,
681
695
  getDeviceId = () => null,
682
696
  readPhotoSource = readLocalPhotoSource,
@@ -996,10 +1010,6 @@ export function createDurableLocalTaskWorker({
996
1010
  if (!await checkLease()) return;
997
1011
  await runHeartbeat();
998
1012
  if (leaseLost) return;
999
- if (heartbeatTimer) {
1000
- clearIntervalImpl(heartbeatTimer);
1001
- heartbeatTimer = null;
1002
- }
1003
1013
  const status = completed.status === "stopped"
1004
1014
  ? "cancelled"
1005
1015
  : boundary === "unknown"
@@ -1017,25 +1027,59 @@ export function createDurableLocalTaskWorker({
1017
1027
  status,
1018
1028
  reportFailureCode,
1019
1029
  );
1030
+ const workflowArtifactDelivery =
1031
+ normalized.workflowArtifactDelivery ||
1032
+ context.workflowArtifactDelivery === true;
1020
1033
  let artifactRefs = [];
1021
1034
  try {
1022
1035
  artifactRefs = buildLocalTaskArtifactRefs(
1023
1036
  completed.artifacts,
1024
1037
  completed.id,
1025
1038
  );
1039
+ // Durable step claims intentionally have a closed contract and do not
1040
+ // repeat the parent execution spec. Preserve the parent-scoped
1041
+ // WorkFlow delivery contract in the same worker context so each
1042
+ // step's verified bytes are materialized before its terminal receipt.
1043
+ if (
1044
+ reportStatus === "succeeded" &&
1045
+ workflowArtifactDelivery &&
1046
+ artifactRefs.length > 0
1047
+ ) {
1048
+ artifactRefs = await materializeWorkflowArtifacts({
1049
+ api,
1050
+ turnId: normalized.turnId,
1051
+ task: completed,
1052
+ artifactRefs,
1053
+ workerId,
1054
+ leaseEpoch: normalized.leaseEpoch,
1055
+ });
1056
+ }
1026
1057
  } catch (error) {
1027
1058
  artifactRefs = [];
1028
1059
  if (status !== "cancelled") {
1029
- reportStatus = status === "unknown" ? "unknown" : "failed";
1030
- reportFailureCode = "artifact_verification_failed";
1031
- reportSummary =
1032
- "SIMY could not safely verify this durable step's output files.";
1060
+ const contractFailure = error instanceof LocalTaskArtifactContractError;
1061
+ const ambiguousDelivery =
1062
+ workflowArtifactDelivery &&
1063
+ (error?.ambiguous === true || error?.status >= 500);
1064
+ reportStatus = status === "unknown" || ambiguousDelivery
1065
+ ? "unknown"
1066
+ : "failed";
1067
+ reportFailureCode = contractFailure
1068
+ ? "artifact_verification_failed"
1069
+ : "artifact_materialization_failed";
1070
+ reportSummary = workflowArtifactDelivery && ambiguousDelivery
1071
+ ? workflowArtifactRecoverySummary()
1072
+ : reportFailureCode === "artifact_verification_failed"
1073
+ ? "SIMY could not safely verify this durable step's output files."
1074
+ : "SIMY created the durable step output files locally but could not safely attach them to this WorkFlow run.";
1033
1075
  completed.status =
1034
1076
  reportStatus === "failed" ? "failed" : completed.status;
1035
1077
  completed.reason_code = reportFailureCode;
1036
1078
  completed.error = error.message;
1037
- completed.artifacts = [];
1038
- if (completed.result) completed.result.artifacts = [];
1079
+ if (contractFailure) {
1080
+ completed.artifacts = [];
1081
+ if (completed.result) completed.result.artifacts = [];
1082
+ }
1039
1083
  await persist(completed);
1040
1084
  }
1041
1085
  onError(
@@ -1450,17 +1494,16 @@ export function createDurableLocalTaskWorker({
1450
1494
  deliveryStage = "artifacts";
1451
1495
  if (
1452
1496
  (reportStatus === "succeeded" || native) &&
1453
- (normalized.pipelineContext || normalized.narrativeImageRequest || native) &&
1497
+ (normalized.pipelineContext || normalized.workflowArtifactDelivery || normalized.narrativeImageRequest || native) &&
1454
1498
  artifactRefs.length > 0
1455
1499
  ) {
1456
- artifactRefs = await materializeArtifacts({
1457
- api,
1458
- turnId: normalized.turnId,
1459
- task: completed,
1460
- artifactRefs,
1461
- ...(native ? { preserveAmbiguity: true, workerId, leaseEpoch: normalized.leaseEpoch } : {}),
1462
- ...(normalized.narrativeImageRequest ? { nativeImageReceipt: completed.native_image_receipt, workerId, leaseEpoch: normalized.leaseEpoch } : {}),
1463
- });
1500
+ artifactRefs = normalized.workflowArtifactDelivery
1501
+ ? await materializeWorkflowArtifacts({ api, turnId: normalized.turnId, task: completed,
1502
+ artifactRefs, workerId, leaseEpoch: normalized.leaseEpoch })
1503
+ : await materializeArtifacts({ api, turnId: normalized.turnId, task: completed, artifactRefs,
1504
+ ...(native ? { preserveAmbiguity: true, workerId, leaseEpoch: normalized.leaseEpoch } : {}),
1505
+ ...(normalized.narrativeImageRequest ? { nativeImageReceipt: completed.native_image_receipt, workerId, leaseEpoch: normalized.leaseEpoch } : {}),
1506
+ });
1464
1507
  }
1465
1508
  } catch (error) {
1466
1509
  const contractFailure = error instanceof LocalTaskArtifactContractError;
@@ -1471,7 +1514,7 @@ export function createDurableLocalTaskWorker({
1471
1514
  if (status !== "cancelled") {
1472
1515
  // A gateway 5xx can hide a committed image. Never present that outcome
1473
1516
  // as a clean failure that invites a second generation.
1474
- reportStatus = status === "unknown" || ((normalized.narrativeImageRequest || native) &&
1517
+ reportStatus = status === "unknown" || ((normalized.narrativeImageRequest || native || normalized.workflowArtifactDelivery) &&
1475
1518
  (error?.ambiguous === true || error?.status >= 500)) ? "unknown" : "failed";
1476
1519
  reportFailureCode = normalized.narrativeImageRequest && error?.code === "narrative_image_receipt_missing"
1477
1520
  ? "narrative_image_receipt_missing"
@@ -1483,6 +1526,8 @@ export function createDurableLocalTaskWorker({
1483
1526
  reportSummary =
1484
1527
  native ? recoverySummary(reportStatus, reportFailureCode) : normalized.narrativeImageRequest
1485
1528
  ? "SIMY could not confirm the generated image delivery. Keep the local output for verification; do not regenerate it."
1529
+ : normalized.workflowArtifactDelivery && reportStatus === "unknown"
1530
+ ? workflowArtifactRecoverySummary()
1486
1531
  : deliveryStage === "destination"
1487
1532
  ? "SIMY could not confirm delivery to the selected local folder. Existing files and local outputs were preserved."
1488
1533
  : contractFailure
@@ -1507,7 +1552,7 @@ export function createDurableLocalTaskWorker({
1507
1552
  reportFailureCode,
1508
1553
  )
1509
1554
  : workerError(
1510
- normalized.narrativeImageRequest
1555
+ normalized.narrativeImageRequest || (normalized.workflowArtifactDelivery && reportStatus === "unknown")
1511
1556
  ? reportSummary
1512
1557
  : `SIMY could not persist the Local Task output files for this Pipeline run. ${errorMessage(error)} Keep SIMY CLI running, check the connection, and start a new Pipeline run.`,
1513
1558
  reportFailureCode,
@@ -1787,11 +1832,18 @@ export function createDurableLocalTaskWorker({
1787
1832
  releaseWork();
1788
1833
  return false;
1789
1834
  }
1790
- if ((isLocalTaskPlanOrchestration(claim) || isDurableLocalTaskStepClaim(claim)) &&
1791
- (claim.execution_spec?.delegation || claim.execution_spec?.delegation_target || claim.execution_spec?.native_identity))
1792
- throw workerError('Native delegation requires the admitted single provider turn.', 'native_delegation_execution_mode_unsupported');
1835
+ // An orchestration envelope only authorizes the CLI to submit a
1836
+ // controlled plan. It does not dispatch a Provider. Its follow-up
1837
+ // may be the existing single-provider native claim, but must never
1838
+ // become a durable step: executeStepClaim has no native delegation
1839
+ // receipt contract.
1840
+ const orchestrationCarriesNativeDelegation = isLocalTaskPlanOrchestration(claim) &&
1841
+ Boolean(claim.execution_spec?.delegation || claim.execution_spec?.delegation_target || claim.execution_spec?.native_identity);
1793
1842
  if (isLocalTaskPlanOrchestration(claim)) {
1794
1843
  const orchestration = claim;
1844
+ context.workflowArtifactDelivery =
1845
+ orchestration.execution_spec?.workflow_artifact_delivery ===
1846
+ "workflow_local_task_artifact.v1";
1795
1847
  const controlledPlan = buildControlledLocalTaskPlan(orchestration);
1796
1848
  await prepareDurableStepAttachments({
1797
1849
  orchestration,
@@ -1820,6 +1872,9 @@ export function createDurableLocalTaskWorker({
1820
1872
  "local_task_plan_claim_mismatch",
1821
1873
  );
1822
1874
  }
1875
+ if (orchestrationCarriesNativeDelegation && isDurableLocalTaskStepClaim(claim)) {
1876
+ throw workerError('Native delegation requires the admitted single provider turn.', 'native_delegation_execution_mode_unsupported');
1877
+ }
1823
1878
  }
1824
1879
  if (requestedTurnId && claim.turn_id !== requestedTurnId) {
1825
1880
  throw workerError(
@@ -2042,6 +2097,7 @@ function normalizeClaim(claim, { leaseSeconds, nowMs = Date.now() } = {}) {
2042
2097
  throw workerError("Unsupported narrative image execution scope.", "invalid_narrative_image_execution_scope");
2043
2098
  }
2044
2099
  const pipelineContext = normalizePipelineContext(spec);
2100
+ const workflowArtifactDelivery = spec.workflow_artifact_delivery === "workflow_local_task_artifact.v1";
2045
2101
  const scenarioPack = normalizeLocalTaskScenarioPack(spec.scenario_pack);
2046
2102
  return {
2047
2103
  turnId,
@@ -2052,6 +2108,7 @@ function normalizeClaim(claim, { leaseSeconds, nowMs = Date.now() } = {}) {
2052
2108
  uploadedRefs,
2053
2109
  spec,
2054
2110
  pipelineContext,
2111
+ workflowArtifactDelivery,
2055
2112
  scenarioPack,
2056
2113
  narrativeImageRequest,
2057
2114
  };
@@ -2724,6 +2781,61 @@ export async function materializePipelineArtifacts({
2724
2781
  return materialized;
2725
2782
  }
2726
2783
 
2784
+ export async function materializeWorkflowLocalTaskArtifacts({
2785
+ api, turnId, task, artifactRefs, workerId, leaseEpoch,
2786
+ }) {
2787
+ if (
2788
+ !api || typeof api.prepareWorkflowArtifact !== "function" ||
2789
+ typeof api.uploadArtifact !== "function" || typeof api.commitWorkflowArtifact !== "function"
2790
+ ) {
2791
+ throw workerError(
2792
+ "This SIMY CLI cannot persist WorkFlow output files. Update SIMY CLI and retry the WorkFlow run.",
2793
+ "artifact_materialization_unsupported",
2794
+ );
2795
+ }
2796
+ const materialized = [];
2797
+ for (const ref of artifactRefs) {
2798
+ const artifact = await localTaskArtifact(task, ref.id);
2799
+ if (!artifact) throw workerError("The WorkFlow output changed before it could be attached.", "artifact_materialization_source_changed");
2800
+ const bytes = await readFile(artifact.local_path);
2801
+ const digest = createHash("sha256").update(bytes).digest("hex");
2802
+ if (bytes.byteLength !== ref.size_bytes || digest !== ref.sha256.toLowerCase()) {
2803
+ throw workerError("The WorkFlow output changed before it could be attached.", "artifact_materialization_source_changed");
2804
+ }
2805
+ const prepared = await retryAmbiguousArtifactStep(() => api.prepareWorkflowArtifact({
2806
+ turnId, workerId, leaseEpoch, localArtifactId: ref.id, name: ref.name,
2807
+ mimeType: ref.mime_type, sizeBytes: ref.size_bytes, sha256: ref.sha256.toLowerCase(),
2808
+ }), { preserveAmbiguity: true });
2809
+ validatePreparedArtifact(prepared);
2810
+ await uploadWorkflowArtifactForCommitVerification(api, {
2811
+ uploadUrl: prepared.upload_url, method: prepared.method, headers: prepared.headers, bytes,
2812
+ });
2813
+ const committed = await retryAmbiguousArtifactStep(() => api.commitWorkflowArtifact({
2814
+ uploadId: prepared.upload_id, turnId, workerId, leaseEpoch, localArtifactId: ref.id,
2815
+ sha256: ref.sha256.toLowerCase(), sizeBytes: ref.size_bytes,
2816
+ }), { preserveAmbiguity: true });
2817
+ materialized.push(validateWorkflowCommittedArtifact(committed, ref));
2818
+ }
2819
+ return materialized;
2820
+ }
2821
+
2822
+ async function uploadWorkflowArtifactForCommitVerification(api, input) {
2823
+ try {
2824
+ await retryAmbiguousArtifactStep(
2825
+ () => api.uploadArtifact(input),
2826
+ { preserveAmbiguity: true },
2827
+ );
2828
+ } catch (error) {
2829
+ // An immutable signed S3 PUT may have stored the exact bytes before its
2830
+ // response was lost. A later precondition failure only permits the
2831
+ // lease-fenced commit to verify that possibility; it is never publish
2832
+ // confirmation by itself.
2833
+ if (!(error instanceof DurableLocalTaskApiError) || error.status !== 412) {
2834
+ throw error;
2835
+ }
2836
+ }
2837
+ }
2838
+
2727
2839
  async function retryAmbiguousArtifactStep(operation, { preserveAmbiguity = false } = {}) {
2728
2840
  let lastError = null;
2729
2841
  let ambiguous = false;
@@ -2791,6 +2903,26 @@ function validateCommittedArtifact(value, expected) {
2791
2903
  };
2792
2904
  }
2793
2905
 
2906
+ function validateWorkflowCommittedArtifact(value, expected) {
2907
+ const artifact = value?.workflow_artifact;
2908
+ if (
2909
+ !artifact || typeof artifact !== "object" || !isUuid(String(artifact.artifact_id || "")) ||
2910
+ artifact.name !== expected.name || artifact.mime_type !== expected.mime_type ||
2911
+ artifact.size_bytes !== expected.size_bytes ||
2912
+ String(artifact.sha256 || "").toLowerCase() !== expected.sha256.toLowerCase()
2913
+ ) {
2914
+ throw workerError(
2915
+ "SIMY could not confirm the persisted WorkFlow output file.",
2916
+ "artifact_materialization_invalid_response",
2917
+ );
2918
+ }
2919
+ return {
2920
+ id: expected.id, local_task_id: expected.local_task_id, name: expected.name,
2921
+ relative_path: expected.relative_path, mime_type: expected.mime_type,
2922
+ size_bytes: expected.size_bytes, sha256: expected.sha256.toLowerCase(), verified: true,
2923
+ };
2924
+ }
2925
+
2794
2926
  function trustedArtifactUploadUrl(value) {
2795
2927
  let target;
2796
2928
  try {
@@ -2938,6 +3070,10 @@ function recoverySummary(status, failureCode) {
2938
3070
  return `The Local Task failed (${failureCode || "unknown error"}).`;
2939
3071
  }
2940
3072
 
3073
+ function workflowArtifactRecoverySummary() {
3074
+ return "SIMY could not confirm WorkFlow output delivery. Existing local output files were preserved. Keep SIMY CLI running while delivery is reconciled; do not rerun this WorkFlow.";
3075
+ }
3076
+
2941
3077
  function durableResultSummary(completed, status, failureCode) {
2942
3078
  // Process exit details such as signal-derived code 130 are useful only in
2943
3079
  // the local technical record. A user-requested stop has one stable public
@@ -156,7 +156,17 @@ async function runCodexDelegation({ selection, instruction, getInstruction, repo
156
156
  if (!accountMatches(account.account?.email)) throw fail('provider_account_mismatch');
157
157
  const metadata = await readCodexSession(client, selection.session_id, { signal });
158
158
  if (metadata.isRunning === true) throw fail('provider_session_busy');
159
- const configuration = await client.request('thread/resume', { threadId: selection.session_id, excludeTurns: true });
159
+ if (metadata.model !== selection.model) throw fail('provider_session_mismatch');
160
+ // A fresh app-server process otherwise resolves a resumed read-only
161
+ // session against the local default sandbox. Reapply only the already
162
+ // bound, narrower read contract before inspecting it; this never grants
163
+ // workspace-write authority or changes the selected session identity.
164
+ const resumeParams = { threadId: selection.session_id, excludeTurns: true };
165
+ if (permissionMode === 'read_only') {
166
+ resumeParams.model = selection.model;
167
+ resumeParams.sandbox = 'read-only';
168
+ }
169
+ const configuration = await client.request('thread/resume', resumeParams);
160
170
  if (configuration.thread?.id !== selection.session_id || configuration.model !== selection.model ||
161
171
  !configuration.cwd || await realpath(configuration.cwd) !== await realpath(nativeIdentity ? metadata.cwd : repositoryPath) ||
162
172
  workspaceMode === 'repository' && await realpath(configuration.cwd) !== await realpath(repositoryPath)) throw fail('provider_session_mismatch');
@@ -259,18 +259,22 @@ export function createUpdateRuntime({ root, origin, readContext, listRecords, ob
259
259
  };
260
260
  return {bind,boundary,reconcile,store,gate:adapters.gate,
261
261
  async initialize() {if(!transaction)write({connectionState:readDispatchGate(root).connection,phase:'startup',updateState:'pending'});},
262
- snapshot(base,update) {
262
+ snapshot(base,update,{accessReady=true}={}) {
263
263
  if(ownerTransferred)transaction=readState(file);
264
264
  const action=base.state==='connected' && update?.state==='waiting_for_idle'?'detach_update':base.state==='detaching'?'cancel_detach_update':!base.busy && ['detached','recovery_required'].includes(base.state)?'update_reconnect':null;
265
265
  const frame={...base,schemaVersion:1,connectionState:base.state,
266
- updateState:transaction?.updateState || 'pending',tasks:rows(),actions:action?[action]:[]};
266
+ updateState:transaction?.updateState || 'pending',updaterState:update?.state || 'unknown',tasks:rows(),actions:action?[action]:[]};
267
+ // Revision the exact frame the native client sees. Filtering after the
268
+ // fingerprint makes an access change look like conflicting data at the
269
+ // same revision, which correctly fences all native update actions.
270
+ if(!accessReady) {frame.tasks=[];frame.actions=frame.actions.filter(action=>action==='cancel_detach_update');}
267
271
  const fingerprint=digest(frame);
268
272
  if(!ownerTransferred && transaction && transaction.presentation!==fingerprint)write({presentation:fingerprint});
269
273
  presented={...frame,operationId:transaction?.operationId,revision:transaction?.revision || 0};
270
274
  return presented;
271
275
  },
272
- validate(command,base,update) {
273
- const frame=this.snapshot(base,update);
276
+ validate(command,base,update,options) {
277
+ const frame=this.snapshot(base,update,options);
274
278
  if(command.operationId!==frame.operationId || command.revision!==frame.revision || !frame.actions.includes(command.action) || transaction?.claimedCommands?.includes(command.id))throw failure('stale_operation');
275
279
  write({claimedCommands:[...(transaction?.claimedCommands || []).slice(-255),command.id]});
276
280
  },