@awak-app/simy-cli 0.7.17 → 0.7.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.7.17",
3
+ "version": "0.7.19",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.17";
17
+ export const CLI_VERSION = "0.7.19";
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({
@@ -773,7 +773,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
773
773
  for (const session of status.sessionSupervision.sessions) {
774
774
  const key = teamThreadKey(agent.getTeamThreadsScope?.() || {}, session);
775
775
  const own = status.teamThreads?.own.find(item => item.threadKey === key);
776
- session.teamSharing = { enabled: own?.enabled === true, pendingStop: status.teamThreads?.pendingStops.includes(key) === true };
776
+ session.teamSharing = { threadKey: key, enabled: own?.enabled === true, pendingStop: status.teamThreads?.pendingStops.includes(key) === true, confirmationRequired: status.teamThreads?.confirmationRequired.includes(key) === true };
777
777
  }
778
778
  const latestAccess = await accessSnapshot();
779
779
  if (JSON.stringify(await readProviderLinks(root)) !== requestedLinks ||
@@ -1138,9 +1138,20 @@ export async function startDesktopHost({ input = process.stdin, output = process
1138
1138
  else if(action === 'team_thread_open') ok = await teamThreads.open({memberId:command.memberId,threadKey:command.threadKey});
1139
1139
  else {
1140
1140
  if (command.organizationId !== undefined && command.organizationId !== access.organizationId) throw new Error('Sharing organization changed. Confirm again.');
1141
- const selected = sessionStatus.sessions.find(item => item.provider === command.provider && item.session === command.session);
1142
- if(!selected) throw new Error('Refresh and select your conversation again.');
1143
- ok = await teamThreads.setSharing(selected,command.enabled);
1141
+ // Publication reads this host's provider context. Never reinterpret
1142
+ // a remote or ambiguous identity as a same-ID local conversation.
1143
+ if ((command.hostId && command.hostId !== 'local') || (!command.hostId && remoteHosts?.hasSession(command.session))) {
1144
+ throw new Error('Team sharing requires an explicitly selected local conversation.');
1145
+ }
1146
+ const selected = sessionStatus.sessions.find(item => (!item.hostId || item.hostId === 'local') && item.provider === command.provider && item.session === command.session);
1147
+ if (command.enabled === false) {
1148
+ // Revocation uses authenticated own rows, never provider context.
1149
+ const key = command.threadKey ?? teamThreadKey(agent.getTeamThreadsScope(), { provider: command.provider, session: command.session });
1150
+ ok = await teamThreads.stopSharing(key);
1151
+ } else {
1152
+ if(!selected || command.threadKey) throw new Error('Refresh and select your conversation again.');
1153
+ ok = await teamThreads.setSharing(selected,command.enabled);
1154
+ }
1144
1155
  }
1145
1156
  emit({type:'result',id:command.id,ok:ok===true}); void publish().catch(() => {}); return;
1146
1157
  }
@@ -1457,6 +1468,22 @@ export async function startDesktopHost({ input = process.stdin, output = process
1457
1468
  await authorizeWork(commandScope);
1458
1469
  const result = await deadlines.decide(context, { ...command, requestId: command.requestId || command.decisionId });
1459
1470
  if (result.status === 'conflict') throw Object.assign(new Error('Deadline changed; refresh before deciding.'), { code: 'conflict' });
1471
+ } else if (action === "session_set_pinned") {
1472
+ if (typeof command.enabled !== 'boolean') throw new Error('Choose whether to pin this conversation.');
1473
+ const candidate = await selectedSession(command, { preferObserved: true });
1474
+ await authorizeWork(commandScope);
1475
+ const pinScope = command.pinScope ?? 'legacy';
1476
+ if (typeof pinScope !== 'string' || !pinScope || pinScope.length > 512) throw new Error('Invalid pin scope.');
1477
+ await updateRecord(deliveryRoot, command.provider, command.session, previous => {
1478
+ const scopes = new Set(Array.isArray(previous?.listPinScopes) ? previous.listPinScopes : previous?.listPinned ? ['legacy'] : []);
1479
+ if (command.enabled) scopes.add(pinScope); else scopes.delete(pinScope);
1480
+ return {
1481
+ ...(previous || { schemaVersion: 1, provider: command.provider, session: command.session, cwd: candidate.cwd, observedAt: Date.now() }),
1482
+ listPinned: scopes.size > 0, listPinScopes: [...scopes],
1483
+ ...(command.enabled ? { listMetadata: { title: candidate.title || '', createdAt: candidate.createdAt ?? null, lastActivityAt: candidate.lastActivityAt ?? null } } : {}),
1484
+ };
1485
+ });
1486
+ refreshSessions(true);
1460
1487
  } else if (action === "session_archive" || action === "session_restore") {
1461
1488
  const candidate = await selectedSession(command, { preferObserved: true });
1462
1489
  await authorizeWork(commandScope);
@@ -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');
@@ -25,7 +25,7 @@ function rememberMetadata(path, value) {
25
25
  function candidateMetadata(id, metadata, lastActivityAt, observedAt) {
26
26
  return {
27
27
  provider: 'claude', session: id, ...(metadata.parentSessionId ? { parentSession: { provider: 'claude', session: metadata.parentSessionId } } : {}), cwd: metadata.cwd, title: metadata.title,
28
- source: 'claude_cli', lastActivityAt,
28
+ source: 'claude_cli', lastActivityAt, createdAt: metadata.createdAt ?? null,
29
29
  observation: { source: 'claude_local_index', observedAt },
30
30
  availability: 'unsupported', model: null,
31
31
  capabilities: { canMonitor: true, canEnroll: false, canAutoContinue: false },
@@ -62,7 +62,7 @@ function metadataFromPrefix(bytes, id) {
62
62
  const text = bytes.toString('utf8');
63
63
  const complete = text.slice(0, text.lastIndexOf('\n') + 1);
64
64
  if (!complete) return null;
65
- let cwd = '', title = '', customTitle = '', parentSessionId = null, found = false;
65
+ let cwd = '', title = '', customTitle = '', parentSessionId = null, found = false, createdAt = null;
66
66
  for (const line of complete.split('\n').slice(0, MAX_LINES)) {
67
67
  if (!line) continue;
68
68
  let item;
@@ -73,6 +73,8 @@ function metadataFromPrefix(bytes, id) {
73
73
  if (item.type === 'custom-title' && item.isSidechain !== true) { customTitle = sessionTitle(item.customTitle); continue; }
74
74
  if (item.type !== 'user' && item.type !== 'assistant') continue;
75
75
  if (item.isSidechain === true) return null;
76
+ const timestamp = typeof item.timestamp === 'string' ? Date.parse(item.timestamp) : item.timestamp;
77
+ if (Number.isFinite(timestamp) && timestamp > 0) createdAt = createdAt === null ? timestamp : Math.min(createdAt, timestamp);
76
78
  if (typeof item.cwd !== 'string' || !isAbsolute(item.cwd)) continue;
77
79
  if (!cwd) cwd = resolve(item.cwd);
78
80
  // A conversation can cd into a child directory without changing identity.
@@ -80,7 +82,7 @@ function metadataFromPrefix(bytes, id) {
80
82
  if (!title) title = sessionTitle(item.slug);
81
83
  found = true;
82
84
  }
83
- return found ? { cwd, title: customTitle || title, parentSessionId } : null;
85
+ return found ? { cwd, title: customTitle || title, parentSessionId, createdAt } : null;
84
86
  }
85
87
 
86
88
  // /rename appends a custom-title envelope, often beyond the bounded prefix.
@@ -222,6 +222,7 @@ export function codexThreadMetadata(thread) {
222
222
  title: typeof thread.name === 'string' && thread.name.trim() ? thread.name.slice(0, 200) : '',
223
223
  source: typeof thread.source === 'string' ? thread.source : thread.source?.type || 'unknown',
224
224
  lastActivityAt: Number.isFinite(thread.updatedAt) ? thread.updatedAt * 1000 : null,
225
+ createdAt: Number.isFinite(thread.createdAt) && thread.createdAt > 0 ? thread.createdAt * 1000 : null,
225
226
  model: typeof thread.model === 'string' ? thread.model : null,
226
227
  // Preserve native wait flags for presentation without changing isRunning,
227
228
  // which also protects the provider writer lock. Unknown flags fail closed.
@@ -4,7 +4,7 @@ export const RECENT_SESSION_LIMIT = 200;
4
4
 
5
5
  // Explicit tracking and unsettled work remain visible outside recent history.
6
6
  // Completed, untracked records are history, not an unlimited extra catalog.
7
- export const retainedSession = record => Boolean(record?.autoContinue || record?.monitor || record?.tracked ||
7
+ export const retainedSession = record => Boolean(record?.listPinned || record?.autoContinue || record?.monitor || record?.tracked ||
8
8
  record?.run && !TERMINAL.has(record.run.state));
9
9
 
10
10
  export function recentSessionWindow(items, { limit = RECENT_SESSION_LIMIT, priority = () => false } = {}) {
@@ -231,8 +231,8 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
231
231
  // A hook observation can belong to an AI-created worker. Only explicit
232
232
  // supervision may restore a conversation absent from provider discovery.
233
233
  if (!byKey.has(key) && (retainedSession(record) || includeArchived && record.listHidden === true || requiredAncestors.has(key) && (record.observedAt || record.admission?.validatedAt))) byKey.set(key, {
234
- provider: record.provider, session: record.session, cwd: record.cwd, title: '', archived, source: record.admission?.source || 'hook',
235
- lastActivityAt: record.observedAt || record.admission?.validatedAt || record.run?.updatedAt || null,
234
+ provider: record.provider, session: record.session, cwd: record.cwd, title: record.listMetadata?.title || '', createdAt: record.listMetadata?.createdAt ?? null, archived, source: record.admission?.source || 'hook',
235
+ lastActivityAt: record.listMetadata?.lastActivityAt ?? record.observedAt ?? record.admission?.validatedAt ?? record.run?.updatedAt ?? null,
236
236
  observation: { source: record.admission?.source || `${record.provider}_hook`, observedAt: record.observedAt || record.admission?.validatedAt || null },
237
237
  availability: 'unknown', model: record.admission?.model || null,
238
238
  });
@@ -327,7 +327,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
327
327
  autoContinueEpoch: record?.autoContinue === true ? record.autoContinueEpoch : null,
328
328
  autoContinueAvailability: availability,
329
329
  ...autoContinueState(record, isRunning, availability),
330
- lastActivityAt: item.lastActivityAt, observation, availability: item.availability,
330
+ lastActivityAt: item.lastActivityAt, createdAt: item.createdAt ?? null, observation, availability: item.availability,
331
331
  taskId: run?.managed?.taskId || record?.associationTaskId || null,
332
332
  binding: { mode, taskId: run?.managed?.taskId || record?.associationTaskId || null },
333
333
  originalSessionTarget: originalSessionTarget(item.provider, item.session),
@@ -13,7 +13,7 @@ export const remoteHostId = alias => `remote-ssh-discovered:${alias}`;
13
13
  const shellQuote = value => "'" + value.replaceAll("'", "'\\''") + "'";
14
14
  const bootstrap = 'import { realpathSync } from "node:fs"; import { dirname, join } from "node:path"; import { pathToFileURL } from "node:url"; const entry = join(dirname(realpathSync(process.argv[1])), "session-supervision", "remote-entry.js"); await import(pathToFileURL(entry).href);';
15
15
  export const REMOTE_HOST_COMMAND = `exec sh -lc ${shellQuote(`exec env SIMY_REMOTE_CHILD=1 SIMY_AUTO_UPDATE=0 node --input-type=module -e ${shellQuote(bootstrap)} "$(command -v simy)"`)}`;
16
- const allowedActions = new Set(['refresh', 'session_statistics', 'session_archive', 'session_restore', 'session_track', 'session_untrack',
16
+ const allowedActions = new Set(['refresh', 'session_set_pinned', 'session_statistics', 'session_archive', 'session_restore', 'session_track', 'session_untrack',
17
17
  'session_monitor', 'session_unmonitor', 'session_enroll', 'session_set_auto_continue', 'session_analyze', 'session_continue', 'session_answer',
18
18
  'session_deadline_decide', 'session_action_refresh', 'session_action_select', 'session_action_unlink',
19
19
  'delivery_pause', 'delivery_stop', 'delivery_reject', 'delivery_resume', 'delivery_replan', 'delivery_approve',
@@ -113,6 +113,7 @@ export function createTeamThreads({
113
113
  selected = null,
114
114
  memberFilter = null,
115
115
  pending = new Map(),
116
+ publicationGrants = new Map(),
116
117
  storageFailed = false,
117
118
  lastAllowed = false;
118
119
  function scope() {
@@ -122,11 +123,12 @@ export function createTeamThreads({
122
123
  generation++;
123
124
  const sameIdentity = scopeKey === current.key;
124
125
  scopeKey = current.key;
125
- const own = sameIdentity ? value.own : [];
126
+ const own = sameIdentity && current.authenticated ? value.own : [];
126
127
  value = { ...empty(), own };
127
128
  selected = null;
128
129
  memberFilter = null;
129
130
  if (!sameIdentity) pending = new Map();
131
+ publicationGrants.clear();
130
132
  lastPoll = -Infinity;
131
133
  }
132
134
  lastAllowed = current.allowed;
@@ -135,7 +137,7 @@ export function createTeamThreads({
135
137
  }
136
138
  const snapshot = () => {
137
139
  scope();
138
- return structuredClone({ ...value, pendingStops: [...pending.keys()] });
140
+ return structuredClone({ ...value, pendingStops: [...pending.keys()], confirmationRequired: value.own.filter(row => row.enabled && publicationGrants.get(row.threadKey) !== row.version).map(row => row.threadKey) });
139
141
  };
140
142
  const fenceKey = (current, key) => digest(`${current.fenceKey}:${key}`);
141
143
  async function run(fn, allowOff = false) {
@@ -272,6 +274,11 @@ export function createTeamThreads({
272
274
  incomplete: result.incomplete,
273
275
  };
274
276
  }
277
+ function sourceObservedAt(candidate) {
278
+ const observed = candidate.observation?.observedAt;
279
+ if (!Number.isFinite(observed) || observed <= 0 || observed > now()) invalid();
280
+ return new Date(observed).toISOString();
281
+ }
275
282
  async function payload(candidate, signal) {
276
283
  const result = await readContext(candidate, { signal });
277
284
  if (!Array.isArray(result?.messages) || !result.messages.length) invalid();
@@ -295,7 +302,7 @@ export function createTeamThreads({
295
302
  240,
296
303
  ),
297
304
  state: running ? "running" : "unknown",
298
- observedAt: new Date(now()).toISOString(),
305
+ observedAt: sourceObservedAt(candidate),
299
306
  messages,
300
307
  truncated: result.truncated === true,
301
308
  incomplete: result.incomplete === true,
@@ -322,13 +329,18 @@ export function createTeamThreads({
322
329
  const fence = await fenceStore.read(fenceKey(current, item.threadKey));
323
330
  if (!valid()) return;
324
331
  if (fence) {
325
- pending.set(item.threadKey, fence.expectedVersion);
326
- await stop(send, current, item.threadKey, fence.expectedVersion);
332
+ // A lost OFF response (or intervening version) must reconcile against
333
+ // authenticated current state, rather than retry a stale CAS forever.
334
+ pending.set(item.threadKey, item.version);
335
+ if (item.enabled) await stop(send, current, item.threadKey, item.version);
336
+ else await clearStop(current, item.threadKey);
327
337
  }
328
338
  }
329
339
  if (publish && !storageFailed)
330
340
  for (const item of [...value.own]) {
331
- if (!item.enabled || pending.has(item.threadKey)) continue;
341
+ // Server consent alone cannot re-arm publication after a process restart:
342
+ // an OFF may have failed both durable storage and the network.
343
+ if (!item.enabled || pending.has(item.threadKey) || publicationGrants.get(item.threadKey) !== item.version) continue;
332
344
  const candidate = candidates.find(
333
345
  (c) => teamThreadKey(current, c) === item.threadKey,
334
346
  );
@@ -384,9 +396,23 @@ export function createTeamThreads({
384
396
  if (valid()) onChange();
385
397
  });
386
398
  },
387
- async setSharing(candidate, enabled) {
399
+ async stopSharing(threadKey) {
400
+ scope();
401
+ if (!keyPattern.test(threadKey) || !value.own.some(row => row.threadKey === threadKey)) return false;
402
+ return setSharing(null, false, threadKey);
403
+ },
404
+ setSharing,
405
+ close() {
406
+ closed = true;
407
+ controller?.abort();
408
+ generation++;
409
+ publicationGrants.clear();
410
+ value = empty();
411
+ },
412
+ };
413
+ async function setSharing(candidate, enabled, existingKey) {
388
414
  const current = scope(),
389
- key = teamThreadKey(current, candidate),
415
+ key = existingKey ?? teamThreadKey(current, candidate),
390
416
  item = value.own.find((row) => row.threadKey === key),
391
417
  version = item?.version ?? 0;
392
418
  if (
@@ -408,6 +434,7 @@ export function createTeamThreads({
408
434
  storageFailed = false;
409
435
  }
410
436
  if (!enabled) {
437
+ publicationGrants.delete(key);
411
438
  pending.set(key, version);
412
439
  controller?.abort();
413
440
  generation++;
@@ -442,16 +469,10 @@ export function createTeamThreads({
442
469
  receipt.version !== version + 1
443
470
  )
444
471
  invalid();
472
+ if (valid() && !pending.has(key)) publicationGrants.set(key, receipt.version);
445
473
  }
446
474
  if (current.allowed) await context(send);
447
475
  if (valid()) onChange();
448
476
  }, !enabled);
449
- },
450
- close() {
451
- closed = true;
452
- controller?.abort();
453
- generation++;
454
- value = empty();
455
- },
456
- };
477
+ }
457
478
  }