@awak-app/simy-cli 0.7.19 → 0.7.21

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
@@ -337,8 +341,10 @@ seven days. Usage counters alone leave work time, idle time and utilization
337
341
  unavailable; a single observed activity event is a measured zero-duration sample.
338
342
  Missing usage records remain distinct from recorded zero tokens.
339
343
 
340
- The desktop Sessions page loads a recent window of up to 200 conversations,
341
- selected by last activity. The macOS work list then prioritizes verified running
344
+ The desktop Sessions page loads the full available Codex and Claude Code
345
+ conversation catalog without a 200-conversation display limit. Live process
346
+ probes remain limited to recent conversations and explicitly retained work;
347
+ older rows remain visible with unverified execution state. The macOS work list then prioritizes verified running
342
348
  conversations, including families with running children, followed by work needing
343
349
  a human response and recent activity. Its All, Running, Needs attention, Completed
344
350
  and Archived views combine with search. Tracking and auto-continue settings never
@@ -358,13 +364,15 @@ Codex candidates come from CLI, VS Code and app-server
358
364
  conversation sources; non-interactive exec, unknown sources and delegated agents
359
365
  are excluded. A hook observation alone cannot restore a filtered conversation.
360
366
  Explicitly tracked, monitored, auto-continue and unsettled supervision records
361
- remain visible even outside that window. When a retained Codex conversation is
367
+ remain visible even during a provider discovery failure. When a retained Codex conversation is
362
368
  omitted from the recent provider page, SIMY reads its exact metadata without
363
369
  loading turns or taking writer ownership. The same interactive source filter and
364
370
  a matching active rollout path are required before restoring provider capabilities
365
371
  and probing its latest turn. Exact reads and turn probes share a bounded
366
372
  observation deadline. Claude discovery
367
- ranks file metadata before opening selected transcripts. A conflicting saved
373
+ reads metadata with a bounded I/O pool and bounded transcript prefixes. Full
374
+ catalog reads include every project and transcript, while exact conversation
375
+ content and statistics remain separate reads. A conflicting saved
368
376
  workspace leaves only that conversation's Action association unavailable; it
369
377
  does not stop discovery or change the saved identity. The page lets users
370
378
  choose monitoring or supported session supervision with a goal, acceptance
@@ -1300,9 +1308,9 @@ original machine. See [setup, access requirements and recovery](docs/remote-code
1300
1308
 
1301
1309
  ### Local conversation list on NVIDIA / Linux
1302
1310
 
1303
- Run `simy sessions` directly on the machine where Codex is running. It reads
1304
- that machine's actual Codex conversation metadata without starting the agent,
1305
- updater, analysis worker or a Delivery Loop. No Mac or SSH bridge is needed.
1311
+ Run `simy sessions` to list local and explicitly registered SSH hosts. It reads
1312
+ both providers' actual conversation metadata without starting the agent,
1313
+ updater, analysis worker or a Delivery Loop. Remote reads use SSH with the existing user identity and verified host key.
1306
1314
 
1307
1315
  ```sh
1308
1316
  simy sessions --query NVIDIA
@@ -1311,12 +1319,28 @@ simy sessions --json
1311
1319
  ```
1312
1320
 
1313
1321
  In the interactive list, use Up/Down to select a conversation, `/` to search its
1314
- title, folder or ID, Enter to finish searching, Esc to clear the filter, `r` to
1322
+ title, folder, provider or ID, Enter to finish searching, Esc to clear the filter, `r` to
1315
1323
  refresh and `q` to quit. The selected conversation shows its exact ID and folder.
1316
- Search covers the current recent window (up to 200 active conversations and,
1317
- with `--include-archived`, up to 200 archived conversations); older entries outside
1318
- that window are not searched. This view respects the saved
1319
- Codex provider connection setting and never enables automatic continuation.
1324
+ Search covers the full available catalog, including older conversations. Use
1325
+ `--include-archived` to include archived conversations from both providers.
1326
+ Each row identifies its provider; identical IDs in Codex and Claude remain
1327
+ separate rows. A provider failure preserves the other provider's results with
1328
+ an explicit incomplete warning (`complete: false` and `warnings` in JSON).
1329
+ This view respects both saved provider connection settings and never enables
1330
+ automatic continuation. Disconnected providers are not read.
1331
+
1332
+ The terminal command reads this host and hosts explicitly registered in
1333
+ `~/.simy/remote-hosts.json`. Use `--local-only` to skip SSH, and
1334
+ `--provider codex` or `--provider claude` to select a provider. Remote SIMY must
1335
+ also include these options; older hosts produce an incomplete warning instead
1336
+ of silently disappearing. Each row retains its host, provider and conversation
1337
+ ID; search also matches the host label. SSH reads run `simy sessions --local-only`
1338
+ and do not start a desktop host, supervisor, or provider turn. Both the local
1339
+ and remote provider connection settings apply.
1340
+
1341
+ The desktop bridge also includes both providers. Neither interface imports
1342
+ Codex desktop connection settings automatically or claims to discover
1343
+ unregistered hosts, another OS user's history, or malformed/unsupported records.
1320
1344
 
1321
1345
  The existing per-provider heartbeat now includes `device_io` with
1322
1346
  `schema_version: local_device_io.v1`. It distinguishes Codex verified native-cwd
@@ -1346,3 +1370,28 @@ When supervision is already live, install the candidate, request
1346
1370
  `runtime supervisor-handover --release ABSOLUTE_RELEASE`, and wait for activation.
1347
1371
  The watcher preserves the live supervisor while service registration is updated.
1348
1372
  No command here enrolls an unrelated conversation or renews an existing budget.
1373
+
1374
+ ### Resume after sleep, logout or reboot
1375
+
1376
+ The OS watchdog retains activation in `supervisor-runtime/control.json`. Sleep
1377
+ re-observes the existing process before replacing it. After logout/login or a
1378
+ shutdown/reboot, the registered OS service loads the same runtime and saved task
1379
+ state. Linux uses a systemd user service (user lingering permits running after
1380
+ logout); macOS uses a login LaunchAgent; Windows uses the interactive user's
1381
+ scheduled task. macOS/Windows recovery therefore starts after that user logs in.
1382
+ No work runs while the computer is powered off or asleep.
1383
+
1384
+ To enable durable recovery for one existing opted-in conversation:
1385
+
1386
+ ```sh
1387
+ ~/.simy/bin/simy delivery runtime supervisor-enable --provider codex --session SESSION_ID
1388
+ ~/.simy/bin/simy delivery runtime supervisor-watchdog-install
1389
+ ```
1390
+
1391
+ This selection binds its existing run and consent epoch, excluding other tasks.
1392
+ The recovered host starts by reading saved results and current artifacts. It
1393
+ retains the goal, budgets and unresolved effects; it does not replay an
1394
+ interrupted write or approve a pending external action. Explicit task
1395
+ stop/pause, provider permission prompts and unanswered user questions remain
1396
+ boundaries. `runtime supervisor-disable` is a durable explicit stop and is never
1397
+ reversed by waking or booting. A later explicit enable is required.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.7.19",
3
+ "version": "0.7.21",
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,
@@ -14,7 +14,7 @@ import {
14
14
  } from "./durable-local-task-steps-contract.js";
15
15
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
16
16
 
17
- export const CLI_VERSION = "0.7.19";
17
+ export const CLI_VERSION = "0.7.21";
18
18
  export const CLI_API_CONTRACT_VERSION = 10;
19
19
 
20
20
  export function narrativeImageCapabilityMetadata() {
@@ -824,7 +824,7 @@ function hookDeliveryContext(record, automatic = false) {
824
824
  `Preserve the current task and its permissions, pauses and stops; never restart or reset it automatically.`;
825
825
  }
826
826
  export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), executionCwd, upgradeRunId, previousRunId, reconcileRunId, objective, nextStep, maxMinutes, maxContinuations, maxFailures, continuationPolicy, managed, admission,
827
- liveHookAdmission, autoContinueEpoch, providerLinkEpoch, inspectProcess = processIdentity, now = Date.now() }) {
827
+ liveHookAdmission, autoContinueEpoch, followupAutoContinue = false, providerLinkEpoch, inspectProcess = processIdentity, now = Date.now() }) {
828
828
  const link = await providerLinkState(root, provider);
829
829
  if (providerLinkEpoch !== undefined && providerLinkEpoch !== link.epoch) throw Object.assign(new Error("SIMY provider connection changed before enrollment."), { code: "provider_link_changed" });
830
830
  if (!link.connected) throw Object.assign(new Error("Reconnect this provider to SIMY before using it."), { code: "provider_link_disconnected" });
@@ -871,6 +871,7 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
871
871
  if ((following || reconciling) && (provider !== 'codex' || !admitted || !managedState?.autoRecovery || managedState.capability !== 'codex_app_server' || managedState.initialAction.kind !== 'read' || managedState.contextBootstrap)) throw new Error('Follow-up requires exact-session managed supervision, a concrete goal and read-only reconciliation.');
872
872
  if (upgrading && (!managedState?.autonomyPolicy || managedState.initialAction.kind !== 'read' || managedState.contextBootstrap)) throw new Error('Upgrade requires read-only reconciliation and frozen acceptance of the original objective.');
873
873
  if (!managedState && continuationMode(continuationPolicy) === 'progress') throw new Error('Progress continuation requires frozen managed acceptance criteria.');
874
+ if (followupAutoContinue && !(following || reconciling)) throw new Error('Auto-continue follow-up requires the exact finished run.');
874
875
  if (autoContinueEpoch && managedState) managedState.autoContinueEpoch = autoContinueEpoch;
875
876
  if (managedState && previous.associationTaskId) {
876
877
  managedState.taskId = previous.associationTaskId;
@@ -916,6 +917,13 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
916
917
  if (saved && digest(saved) !== digest(archive)) throw new Error('Finished run archive differs; reconcile its history before enrolling follow-up work.');
917
918
  if (!saved) atomicSave(file, archive);
918
919
  }
920
+ // The explicit follow-up dialog enrolls and saves ON in one transaction.
921
+ // A failed/stale request never leaves a half-enabled preference behind.
922
+ if (followupAutoContinue) {
923
+ const epoch = randomUUID();
924
+ managedState.autoContinueEpoch = epoch;
925
+ return { ...previous, autoContinue: true, autoContinueEpoch: epoch, run };
926
+ }
919
927
  return { ...previous, run };
920
928
  });
921
929
  }
@@ -13,9 +13,11 @@ const MAX_MESSAGES = 24;
13
13
  const MAX_MESSAGE_CHARS = 1_500;
14
14
  const MAX_CANDIDATE_FILES = 200;
15
15
  export const MAX_GATEWAY_BODY_BYTES = 512 * 1024;
16
+ const MAX_STATUS_RECEIPTS = 10;
17
+ const MAX_STATUS_BODY_BYTES = 12 * 1024;
16
18
  const DEFAULT_LOOKBACK_DAYS = 90;
17
19
  const POLL_INTERVAL_MS = 15 * 60 * 1_000;
18
- const STATE_SCHEMA_VERSION = "work-pattern-history-sync.v6";
20
+ const STATE_SCHEMA_VERSION = "work-pattern-history-sync.v7";
19
21
  export const HISTORY_EXTRACTION_POLICY = "user_conversation.v2";
20
22
  const PROVIDER_CODEX = "codex";
21
23
  const PROVIDER_CLAUDE = "claude";
@@ -125,7 +127,20 @@ export async function syncCodexWorkPatterns({
125
127
  deviceId,
126
128
  });
127
129
  const persistedState = await readSyncState(statePath);
128
- const previousState = statusPayload.reset_cursor === true ? {} : persistedState;
130
+ const pendingReceipts = pendingReceiptMetadata(persistedState.pending_receipts);
131
+ if (pendingReceipts.length > 0) {
132
+ const pendingResult = await pollTimedOutReceipt({
133
+ endpoint,
134
+ token,
135
+ receipts: pendingReceipts,
136
+ fetchImpl,
137
+ signal,
138
+ authorizeProviders,
139
+ });
140
+ if (pendingResult.pending) return { status: "pending", uploaded: 0 };
141
+ }
142
+ const { pending_receipts: _pendingReceipts, ...resolvedPersistedState } = persistedState;
143
+ const previousState = statusPayload.reset_cursor === true ? {} : resolvedPersistedState;
129
144
  const state = { ...previousState, providers: Object.fromEntries(Object.entries(previousState.providers || {})
130
145
  .filter(([, cursor]) => cursor?.reset_pending !== true && (cursor?.lookback_days ?? previousState.lookback_days) === lookbackDays)) };
131
146
  const lookbackCutoffMs = now - lookbackDays * 24 * 60 * 60 * 1_000;
@@ -175,10 +190,12 @@ export async function syncCodexWorkPatterns({
175
190
  }))).flat();
176
191
  const selectedFiles = selectSessionFiles(files);
177
192
  const threads = [];
178
- const acknowledgedRevisions = new Set(providerSpecs.flatMap((spec) =>
193
+ const acknowledgedRevisions = new Set([
194
+ ...providerSpecs.flatMap((spec) =>
179
195
  Object.values(providerCursor(state, spec.provider).processed_files)
180
196
  .filter((file) => file.lineage_ref && file.revision_ref)
181
- .map((file) => `${file.lineage_ref}\0${file.revision_ref}`)));
197
+ .map((file) => `${file.lineage_ref}\0${file.revision_ref}`)),
198
+ ]);
182
199
  const successfullyReadFiles = [];
183
200
  const unreadableFiles = [];
184
201
  for (const file of selectedFiles) {
@@ -212,6 +229,7 @@ export async function syncCodexWorkPatterns({
212
229
  if (
213
230
  chunk.messages.length > 0
214
231
  && Date.parse(chunk.observed_at) >= lookbackCutoffMs
232
+ && !pendingReceipts.some(receipt => receiptKey(receipt) === threadReceiptKey(chunk))
215
233
  ) {
216
234
  threads.push(chunk);
217
235
  }
@@ -228,9 +246,38 @@ export async function syncCodexWorkPatterns({
228
246
  return { status: "up_to_date", uploaded: 0 };
229
247
  }
230
248
 
231
- const uploadResult = threads.length > 0
232
- ? await uploadThreadBatches({ endpoint, token, threads, fetchImpl, signal, authorizeProviders })
233
- : { uploaded: 0 };
249
+ let uploadResult = { uploaded: 0 };
250
+ if (threads.length > 0) {
251
+ // Persist only opaque receipt metadata before an admission request that
252
+ // could reach Crew after this process loses its response. A restart polls
253
+ // these tuples and never reconstructs a plaintext retry from them.
254
+ let attempted = [...pendingReceipts];
255
+ const recordAttempt = async (batch, remove = false) => {
256
+ const keys = new Set(batch.map(threadReceiptKey));
257
+ attempted = attempted.filter(receipt => !keys.has(receiptKey(receipt)));
258
+ if (!remove) attempted.push(...batch.map(receiptMetadata));
259
+ if (attempted.length) {
260
+ await writePendingReceiptState({ statePath, lookbackDays, providers: previousState.providers || {}, receipts: attempted, now });
261
+ } else {
262
+ await clearPendingReceiptState({ statePath, lookbackDays, providers: previousState.providers || {}, now });
263
+ }
264
+ };
265
+ uploadResult = await uploadThreadBatches({ endpoint, token, threads, fetchImpl, signal, authorizeProviders, recordAttempt });
266
+ if (uploadResult.pending_receipts?.length > 0) uploadResult.pending_receipts = attempted;
267
+ }
268
+ if (uploadResult.pending_receipts?.length > 0) {
269
+ await writePendingReceiptState({
270
+ statePath,
271
+ lookbackDays,
272
+ providers: previousState.providers || {},
273
+ receipts: uploadResult.pending_receipts,
274
+ now,
275
+ });
276
+ return { status: "pending", uploaded: 0 };
277
+ }
278
+ if (threads.length > 0 && uploadResult.uploaded !== threads.length) {
279
+ return { status: "pending", uploaded: uploadResult.uploaded };
280
+ }
234
281
  if (shouldAcknowledgeEmptyReset) {
235
282
  const { payload } = await postThreadBatch({ endpoint, token, threads: [], fetchImpl, signal, authorizeProviders });
236
283
  validateThreadBatchAcknowledgement(payload, []);
@@ -279,7 +326,7 @@ export function workPatternSyncStatePath({ sessionRoot, apiOrigin, accountId, de
279
326
  return join(sessionRoot, "work-pattern-history-sync", `${scopeHash}.json`);
280
327
  }
281
328
 
282
- async function uploadThreadBatches({ endpoint, token, threads, fetchImpl, signal, authorizeProviders }) {
329
+ async function uploadThreadBatches({ endpoint, token, threads, fetchImpl, signal, authorizeProviders, recordAttempt }) {
283
330
  const batches = [];
284
331
  let batch = [];
285
332
  for (const thread of threads) {
@@ -299,6 +346,7 @@ async function uploadThreadBatches({ endpoint, token, threads, fetchImpl, signal
299
346
  if (batch.length > 0) batches.push(batch);
300
347
 
301
348
  let uploaded = 0;
349
+ const pendingReceipts = [];
302
350
  for (const current of batches) {
303
351
  const result = await postBatchWithBounded413Recovery({
304
352
  endpoint,
@@ -307,26 +355,45 @@ async function uploadThreadBatches({ endpoint, token, threads, fetchImpl, signal
307
355
  fetchImpl,
308
356
  signal,
309
357
  authorizeProviders,
358
+ recordAttempt,
310
359
  });
311
360
  uploaded += result.uploaded;
361
+ if (result.pending_receipts?.length > 0) {
362
+ pendingReceipts.push(...result.pending_receipts);
363
+ break;
364
+ }
312
365
  }
313
- return { uploaded };
366
+ return pendingReceipts.length > 0
367
+ ? { uploaded, pending_receipts: pendingReceipts }
368
+ : { uploaded };
314
369
  }
315
370
 
316
- async function postBatchWithBounded413Recovery({ endpoint, token, threads, fetchImpl, signal, authorizeProviders }) {
317
- const { response, payload } = await postThreadBatch({
318
- endpoint,
319
- token,
320
- threads,
321
- fetchImpl,
322
- signal,
323
- allow413: true,
324
- authorizeProviders,
325
- });
371
+ async function postBatchWithBounded413Recovery({ endpoint, token, threads, fetchImpl, signal, authorizeProviders, recordAttempt }) {
372
+ await recordAttempt(threads);
373
+ let response, payload;
374
+ try {
375
+ ({ response, payload } = await postThreadBatch({
376
+ endpoint,
377
+ token,
378
+ threads,
379
+ fetchImpl,
380
+ signal,
381
+ allow413: true,
382
+ authorizeProviders,
383
+ }));
384
+ } catch (error) {
385
+ if (error?.definitePreAdmission === true) await recordAttempt(threads, true);
386
+ throw error;
387
+ }
326
388
  if (response.status !== 413) {
327
- validateThreadBatchAcknowledgement(payload, threads);
389
+ const partition = validateReceiptPartition(payload, threads.map(receiptMetadata));
390
+ if (partition.pending > 0 || payload?._receiptPending === true) {
391
+ return { uploaded: 0, pending_receipts: threads.map(receiptMetadata) };
392
+ }
393
+ if (partition.notFound > 0) throwIncompleteAcknowledgement();
328
394
  return { uploaded: threads.length };
329
395
  }
396
+ await recordAttempt(threads, true);
330
397
  if (threads.length > 1) {
331
398
  const middle = Math.ceil(threads.length / 2);
332
399
  const left = await postBatchWithBounded413Recovery({
@@ -336,7 +403,9 @@ async function postBatchWithBounded413Recovery({ endpoint, token, threads, fetch
336
403
  fetchImpl,
337
404
  signal,
338
405
  authorizeProviders,
406
+ recordAttempt,
339
407
  });
408
+ if (left.pending_receipts?.length) return left;
340
409
  const right = await postBatchWithBounded413Recovery({
341
410
  endpoint,
342
411
  token,
@@ -344,13 +413,19 @@ async function postBatchWithBounded413Recovery({ endpoint, token, threads, fetch
344
413
  fetchImpl,
345
414
  signal,
346
415
  authorizeProviders,
416
+ recordAttempt,
347
417
  });
348
- return { uploaded: left.uploaded + right.uploaded };
418
+ return {
419
+ uploaded: left.uploaded + right.uploaded,
420
+ pending_receipts: [...(left.pending_receipts || []), ...(right.pending_receipts || [])],
421
+ };
349
422
  }
350
- throw new Error(
423
+ const error = new Error(
351
424
  "Work-pattern observation chunk was rejected as too large; "
352
425
  + "the complete revision was not acknowledged and will be retried unchanged.",
353
426
  );
427
+ error.definitePreAdmission = true;
428
+ throw error;
354
429
  }
355
430
 
356
431
  async function postThreadBatch({
@@ -366,46 +441,171 @@ async function postThreadBatch({
366
441
  if (Buffer.byteLength(body, "utf8") > MAX_GATEWAY_BODY_BYTES) {
367
442
  throw new Error("Work-pattern observation payload exceeds the local CLI gateway limit.");
368
443
  }
369
- await authorizeProviders();
370
- const response = await fetchImpl(endpoint, {
371
- method: "POST",
372
- headers: webApiHeaders({ token }, { "Content-Type": "application/json" }),
373
- body,
374
- signal: signalWithTimeout(signal, 120_000),
375
- });
444
+ try {
445
+ await authorizeProviders();
446
+ } catch (error) {
447
+ error.definitePreAdmission = true;
448
+ throw error;
449
+ }
450
+ let response;
451
+ try {
452
+ response = await fetchImpl(endpoint, {
453
+ method: "POST",
454
+ headers: webApiHeaders({ token }, { "Content-Type": "application/json" }),
455
+ body,
456
+ signal: signalWithTimeout(signal, 120_000),
457
+ });
458
+ } catch (error) {
459
+ if (signal?.aborted || !isTimeout(error)) throw error;
460
+ const polled = await pollTimedOutReceipt({ endpoint, token, threads, fetchImpl, signal, authorizeProviders });
461
+ return polled.pending
462
+ ? { response: polled.response, payload: { ...polled.payload, _receiptPending: true } }
463
+ : polled;
464
+ }
376
465
  const payload = await response.json().catch(() => null);
377
466
  if (!response.ok && !(allow413 && response.status === 413)) {
378
- throw new Error(webApiErrorMessage(payload, response.status));
467
+ if (response.status >= 500 || response.status === 408 || response.status === 429) {
468
+ const polled = await pollTimedOutReceipt({ endpoint, token, threads, fetchImpl, signal, authorizeProviders });
469
+ return polled.pending
470
+ ? { response: polled.response, payload: { ...polled.payload, _receiptPending: true } }
471
+ : polled;
472
+ }
473
+ const error = new Error(webApiErrorMessage(payload, response.status));
474
+ error.definitePreAdmission = response.status >= 400 && response.status < 500 && response.status !== 409;
475
+ throw error;
379
476
  }
380
477
  return { response, payload };
381
478
  }
382
479
 
480
+ function isTimeout(error) {
481
+ return error?.name === "TimeoutError" || error?.name === "AbortError";
482
+ }
483
+
484
+ async function pollTimedOutReceipt({ endpoint, token, threads, receipts, fetchImpl, signal, authorizeProviders }) {
485
+ const expectedReceipts = receipts || threads.map(receiptMetadata);
486
+ const batches = receiptBatches(expectedReceipts);
487
+ await authorizeProviders();
488
+ const responses = [];
489
+ let hasPending = false;
490
+ for (const batch of batches) {
491
+ const response = await fetchImpl(new URL("status/", endpoint), {
492
+ method: "POST",
493
+ headers: webApiHeaders({ token }, { "Content-Type": "application/json" }),
494
+ body: JSON.stringify({ receipts: batch }),
495
+ signal: signalWithTimeout(signal, 5_000),
496
+ });
497
+ const payload = await response.json().catch(() => null);
498
+ if (!response.ok) throw new Error(webApiErrorMessage(payload, response.status));
499
+ const partition = validateReceiptPartition(payload, batch);
500
+ if (partition.notFound > 0) throwIncompleteAcknowledgement();
501
+ hasPending ||= partition.pending > 0;
502
+ responses.push({ response, payload });
503
+ }
504
+ const last = responses.at(-1);
505
+ return hasPending
506
+ ? { response: last.response, payload: last.payload, pending: true }
507
+ : { response: last.response, payload: last.payload };
508
+ }
509
+
383
510
  function validateThreadBatchAcknowledgement(payload, threads) {
511
+ const partition = validateReceiptPartition(payload, threads.map(receiptMetadata));
512
+ if (partition.pending > 0 || partition.notFound > 0) throwIncompleteAcknowledgement();
513
+ }
514
+
515
+ function validateReceiptPartition(payload, expectedReceipts) {
516
+ const accepted = Array.isArray(payload?.accepted_receipts) ? payload.accepted_receipts : [];
517
+ const pending = Array.isArray(payload?.pending_receipts) ? payload.pending_receipts : [];
518
+ const notFound = Array.isArray(payload?.not_found_receipts) ? payload.not_found_receipts : [];
384
519
  if (
385
- payload?.raw_content_persisted !== false ||
386
- !Number.isInteger(payload?.accepted) ||
387
- payload.accepted !== threads.length ||
388
- !Array.isArray(payload.accepted_receipts) ||
389
- payload.accepted_receipts.length !== threads.length
390
- ) {
520
+ payload?.raw_content_persisted !== false
521
+ || !Number.isInteger(payload?.accepted)
522
+ || payload.accepted !== accepted.length
523
+ || (payload?.pending !== undefined && (!Number.isInteger(payload.pending) || payload.pending !== pending.length))
524
+ || (payload?.not_found !== undefined && (!Number.isInteger(payload.not_found) || payload.not_found !== notFound.length))
525
+ ) throwIncompleteAcknowledgement();
526
+ const expected = expectedReceipts.map(receiptKey).sort();
527
+ const actual = [...accepted, ...pending, ...notFound].map(receiptKey).sort();
528
+ if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
391
529
  throwIncompleteAcknowledgement();
392
530
  }
393
- const expected = threads.map(threadReceiptKey).sort();
394
- const actual = payload.accepted_receipts.map(receiptKey).sort();
395
- for (let index = 0; index < expected.length; index += 1) {
396
- if (actual[index] !== expected[index]) throwIncompleteAcknowledgement();
397
- }
531
+ if (new Set(actual).size !== actual.length) throwIncompleteAcknowledgement();
532
+ return { accepted: accepted.length, pending: pending.length, notFound: notFound.length };
398
533
  }
399
534
 
400
535
  function threadReceiptKey(thread) {
401
- return receiptKey({
536
+ return receiptKey(receiptMetadata(thread));
537
+ }
538
+
539
+ function receiptMetadata(thread) {
540
+ return {
402
541
  source_ref: thread.source_ref,
403
542
  revision_ref: thread.revision_ref,
404
543
  lineage_ref: thread.lineage_ref,
405
544
  chunk_index: thread.chunk_index,
406
545
  chunk_count: thread.chunk_count,
407
546
  message_offset: thread.message_offset,
408
- message_count: Array.isArray(thread.messages) ? thread.messages.length : -1,
547
+ message_count: Array.isArray(thread.messages)
548
+ ? thread.messages.length
549
+ : (Number.isInteger(thread.message_count) ? thread.message_count : -1),
550
+ extraction_policy: thread.extraction_policy,
551
+ };
552
+ }
553
+
554
+ function pendingReceiptMetadata(receipts) {
555
+ if (receipts === undefined) return [];
556
+ if (!Array.isArray(receipts)) {
557
+ throw new Error("Pending work-pattern receipt state is invalid; history was not resent.");
558
+ }
559
+ return receipts.map((receipt) => {
560
+ if (
561
+ !/^(?:codex|claude):[0-9a-f]{64}$/.test(receipt?.source_ref || "")
562
+ || !/^(?:codex|claude):[0-9a-f]{64}$/.test(receipt?.lineage_ref || "")
563
+ || !/^[0-9a-f]{64}$/.test(receipt?.revision_ref || "")
564
+ || receipt?.extraction_policy !== HISTORY_EXTRACTION_POLICY
565
+ || !Number.isInteger(receipt?.chunk_index)
566
+ || !Number.isInteger(receipt?.chunk_count)
567
+ || !Number.isInteger(receipt?.message_offset)
568
+ || !Number.isInteger(receipt?.message_count)
569
+ ) {
570
+ throw new Error("Pending work-pattern receipt state is invalid; history was not resent.");
571
+ }
572
+ return receipt;
573
+ });
574
+ }
575
+
576
+ function receiptBatches(receipts) {
577
+ const batches = [];
578
+ let batch = [];
579
+ for (const receipt of receipts) {
580
+ const candidate = [...batch, receipt];
581
+ if (candidate.length > MAX_STATUS_RECEIPTS || Buffer.byteLength(JSON.stringify({ receipts: candidate }), "utf8") > MAX_STATUS_BODY_BYTES) {
582
+ if (batch.length === 0) throw new Error("Work-pattern receipt metadata exceeds the local gateway limit.");
583
+ batches.push(batch);
584
+ batch = [receipt];
585
+ } else {
586
+ batch = candidate;
587
+ }
588
+ }
589
+ if (batch.length > 0) batches.push(batch);
590
+ return batches;
591
+ }
592
+
593
+ async function writePendingReceiptState({ statePath, lookbackDays, providers, receipts, now }) {
594
+ await writeSyncState(statePath, {
595
+ schema_version: STATE_SCHEMA_VERSION,
596
+ lookback_days: lookbackDays,
597
+ providers,
598
+ pending_receipts: receipts,
599
+ last_synced_at: new Date(now).toISOString(),
600
+ });
601
+ }
602
+
603
+ async function clearPendingReceiptState({ statePath, lookbackDays, providers, now }) {
604
+ await writeSyncState(statePath, {
605
+ schema_version: STATE_SCHEMA_VERSION,
606
+ lookback_days: lookbackDays,
607
+ providers,
608
+ last_synced_at: new Date(now).toISOString(),
409
609
  });
410
610
  }
411
611