@awak-app/simy-cli 0.7.20 → 0.7.22

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
@@ -60,6 +60,22 @@ Provider login is separate from model access; unverified model access remains
60
60
  files scoped to the Web/account/organization, rotate each writer's four 64 KiB
61
61
  segments, and keep lifetime failure counts after event rotation.
62
62
 
63
+ Each AI app card separates **SIMY connection: on / stopped** from CLI, sign-in
64
+ and plugin health. Disconnecting collapses those details and explains that SIMY
65
+ monitoring and automatic continuation are stopped. Details can still be opened;
66
+ reconnecting restores the item statuses. Plugin inspection failure never changes
67
+ the connection setting or hides its disconnect action.
68
+
69
+ Connection settings show **Repair automatically** beside a failed, missing or
70
+ disabled SIMY plugin. Clicking it rechecks only that provider with a longer,
71
+ bounded timeout; a missing or disabled managed plugin is installed or enabled,
72
+ then its actual inventory is checked again. Success is shown only after that
73
+ inventory confirms the plugin is enabled. Repair progress and failure reasons
74
+ are displayed in the card; safe reason codes and elapsed times are saved in
75
+ `$SIMY_HOME/logs/desktop-host.log`. Repair preserves SIMY connection consent,
76
+ provider sign-in and native hook permissions. Project/managed settings and
77
+ conflicting plugin sources require review instead of being overwritten.
78
+
63
79
  The desktop host continues sampling connection and access while optional
64
80
  conversation metadata is pending. It retains the last completed conversation
65
81
  metadata only within the same verified scope, clears it after access or account
@@ -341,8 +357,10 @@ seven days. Usage counters alone leave work time, idle time and utilization
341
357
  unavailable; a single observed activity event is a measured zero-duration sample.
342
358
  Missing usage records remain distinct from recorded zero tokens.
343
359
 
344
- The desktop Sessions page loads a recent window of up to 200 conversations,
345
- selected by last activity. The macOS work list then prioritizes verified running
360
+ The desktop Sessions page loads the full available Codex and Claude Code
361
+ conversation catalog without a 200-conversation display limit. Live process
362
+ probes remain limited to recent conversations and explicitly retained work;
363
+ older rows remain visible with unverified execution state. The macOS work list then prioritizes verified running
346
364
  conversations, including families with running children, followed by work needing
347
365
  a human response and recent activity. Its All, Running, Needs attention, Completed
348
366
  and Archived views combine with search. Tracking and auto-continue settings never
@@ -362,13 +380,15 @@ Codex candidates come from CLI, VS Code and app-server
362
380
  conversation sources; non-interactive exec, unknown sources and delegated agents
363
381
  are excluded. A hook observation alone cannot restore a filtered conversation.
364
382
  Explicitly tracked, monitored, auto-continue and unsettled supervision records
365
- remain visible even outside that window. When a retained Codex conversation is
383
+ remain visible even during a provider discovery failure. When a retained Codex conversation is
366
384
  omitted from the recent provider page, SIMY reads its exact metadata without
367
385
  loading turns or taking writer ownership. The same interactive source filter and
368
386
  a matching active rollout path are required before restoring provider capabilities
369
387
  and probing its latest turn. Exact reads and turn probes share a bounded
370
388
  observation deadline. Claude discovery
371
- ranks file metadata before opening selected transcripts. A conflicting saved
389
+ reads metadata with a bounded I/O pool and bounded transcript prefixes. Full
390
+ catalog reads include every project and transcript, while exact conversation
391
+ content and statistics remain separate reads. A conflicting saved
372
392
  workspace leaves only that conversation's Action association unavailable; it
373
393
  does not stop discovery or change the saved identity. The page lets users
374
394
  choose monitoring or supported session supervision with a goal, acceptance
@@ -1304,9 +1324,9 @@ original machine. See [setup, access requirements and recovery](docs/remote-code
1304
1324
 
1305
1325
  ### Local conversation list on NVIDIA / Linux
1306
1326
 
1307
- Run `simy sessions` directly on the machine where Codex is running. It reads
1308
- that machine's actual Codex conversation metadata without starting the agent,
1309
- updater, analysis worker or a Delivery Loop. No Mac or SSH bridge is needed.
1327
+ Run `simy sessions` to list local and explicitly registered SSH hosts. It reads
1328
+ both providers' actual conversation metadata without starting the agent,
1329
+ updater, analysis worker or a Delivery Loop. Remote reads use SSH with the existing user identity and verified host key.
1310
1330
 
1311
1331
  ```sh
1312
1332
  simy sessions --query NVIDIA
@@ -1315,12 +1335,28 @@ simy sessions --json
1315
1335
  ```
1316
1336
 
1317
1337
  In the interactive list, use Up/Down to select a conversation, `/` to search its
1318
- title, folder or ID, Enter to finish searching, Esc to clear the filter, `r` to
1338
+ title, folder, provider or ID, Enter to finish searching, Esc to clear the filter, `r` to
1319
1339
  refresh and `q` to quit. The selected conversation shows its exact ID and folder.
1320
- Search covers the current recent window (up to 200 active conversations and,
1321
- with `--include-archived`, up to 200 archived conversations); older entries outside
1322
- that window are not searched. This view respects the saved
1323
- Codex provider connection setting and never enables automatic continuation.
1340
+ Search covers the full available catalog, including older conversations. Use
1341
+ `--include-archived` to include archived conversations from both providers.
1342
+ Each row identifies its provider; identical IDs in Codex and Claude remain
1343
+ separate rows. A provider failure preserves the other provider's results with
1344
+ an explicit incomplete warning (`complete: false` and `warnings` in JSON).
1345
+ This view respects both saved provider connection settings and never enables
1346
+ automatic continuation. Disconnected providers are not read.
1347
+
1348
+ The terminal command reads this host and hosts explicitly registered in
1349
+ `~/.simy/remote-hosts.json`. Use `--local-only` to skip SSH, and
1350
+ `--provider codex` or `--provider claude` to select a provider. Remote SIMY must
1351
+ also include these options; older hosts produce an incomplete warning instead
1352
+ of silently disappearing. Each row retains its host, provider and conversation
1353
+ ID; search also matches the host label. SSH reads run `simy sessions --local-only`
1354
+ and do not start a desktop host, supervisor, or provider turn. Both the local
1355
+ and remote provider connection settings apply.
1356
+
1357
+ The desktop bridge also includes both providers. Neither interface imports
1358
+ Codex desktop connection settings automatically or claims to discover
1359
+ unregistered hosts, another OS user's history, or malformed/unsupported records.
1324
1360
 
1325
1361
  The existing per-provider heartbeat now includes `device_io` with
1326
1362
  `schema_version: local_device_io.v1`. It distinguishes Codex verified native-cwd
@@ -1350,3 +1386,28 @@ When supervision is already live, install the candidate, request
1350
1386
  `runtime supervisor-handover --release ABSOLUTE_RELEASE`, and wait for activation.
1351
1387
  The watcher preserves the live supervisor while service registration is updated.
1352
1388
  No command here enrolls an unrelated conversation or renews an existing budget.
1389
+
1390
+ ### Resume after sleep, logout or reboot
1391
+
1392
+ The OS watchdog retains activation in `supervisor-runtime/control.json`. Sleep
1393
+ re-observes the existing process before replacing it. After logout/login or a
1394
+ shutdown/reboot, the registered OS service loads the same runtime and saved task
1395
+ state. Linux uses a systemd user service (user lingering permits running after
1396
+ logout); macOS uses a login LaunchAgent; Windows uses the interactive user's
1397
+ scheduled task. macOS/Windows recovery therefore starts after that user logs in.
1398
+ No work runs while the computer is powered off or asleep.
1399
+
1400
+ To enable durable recovery for one existing opted-in conversation:
1401
+
1402
+ ```sh
1403
+ ~/.simy/bin/simy delivery runtime supervisor-enable --provider codex --session SESSION_ID
1404
+ ~/.simy/bin/simy delivery runtime supervisor-watchdog-install
1405
+ ```
1406
+
1407
+ This selection binds its existing run and consent epoch, excluding other tasks.
1408
+ The recovered host starts by reading saved results and current artifacts. It
1409
+ retains the goal, budgets and unresolved effects; it does not replay an
1410
+ interrupted write or approve a pending external action. Explicit task
1411
+ stop/pause, provider permission prompts and unanswered user questions remain
1412
+ boundaries. `runtime supervisor-disable` is a durable explicit stop and is never
1413
+ 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.20",
3
+ "version": "0.7.22",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,7 +42,7 @@
42
42
  "check:session-supervision-syntax": "node --check ./src/session-supervision/session-list.js && node --check ./src/console/sessions.js && node --check ./src/session-supervision/remote-hosts.js && node --check ./src/session-supervision/remote-entry.js && node --check ./src/session-supervision/index.js && node --check ./src/session-supervision/discovery-window.js && node --check ./src/session-supervision/codex-app-server.js && node --check ./src/session-supervision/claude-sessions.js && node --check ./src/session-supervision/original-session-target.js && node --check ./src/session-supervision/action-association.js && node --check ./src/session-supervision/action-association-host.js && node --check ./src/session-supervision/action-association-api.js && node --check ./src/session-supervision/association-store.js && node --check ./src/session-supervision/action-deadline.js && node --check ./src/session-supervision/action-deadline-store.js && node --check ./src/codex-plugins/simy/hooks/action-association.mjs && node --check ./src/session-supervision/purpose-instruction.js && node --check ./src/session-supervision/purpose-recovery.js && node --check ./src/session-supervision/purpose-candidate.js && node --check ./src/codex-plugins/simy/hooks/scene-reasoning.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-cycle.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-rule-review.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-search.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-evidence.mjs && node --check ./src/session-supervision/purpose-observer.js && node --check ./src/session-supervision/purpose-verification.js && node --check ./src/codex-plugins/simy/hooks/purpose-workspace.mjs && node --check ./scripts/purpose-search-live-e2e.js",
43
43
  "check:auto-update": "node --check ./src/auto-update.js && node --check ./scripts/auto-update-e2e.js && npm run check:sqm",
44
44
  "check:workflow-observation-sender": "node --check ./src/workflow-observation-sender.js",
45
- "check:sqm": "node --check ./src/sqm/advisory-guidance.js && node --check ./src/sqm/canonical.js && node --check ./src/sqm/validation.js && node --check ./src/sqm/reliability.js && node --check ./src/sqm/repository.js && node --check ./src/sqm/checkers.js && node --check ./src/sqm/bundle-store.js && node --check ./src/sqm/proof.js && node --check ./src/sqm/evidence-client.js && node --check ./src/sqm/session.js && node --check ./src/sqm/index.js && node --check ./src/sqm/command.js && node --check ./src/sqm/agent-skill.js && node --check ./src/sqm/workflow-onboarding.js && node --check ./src/sqm/workflow-update.js && node --check ./src/sqm/history-store.js && node --check ./src/sqm/review-worker.js && node --check ./scripts/sqm-complete-loop-e2e.js && node --check ./scripts/sqm-local-review-e2e.js",
45
+ "check:sqm": "node --check ./src/sqm/provider-plugin-repair.js && node --check ./src/sqm/provider-plugin-status.js && node --check ./src/sqm/advisory-guidance.js && node --check ./src/sqm/canonical.js && node --check ./src/sqm/validation.js && node --check ./src/sqm/reliability.js && node --check ./src/sqm/repository.js && node --check ./src/sqm/checkers.js && node --check ./src/sqm/bundle-store.js && node --check ./src/sqm/proof.js && node --check ./src/sqm/evidence-client.js && node --check ./src/sqm/session.js && node --check ./src/sqm/index.js && node --check ./src/sqm/command.js && node --check ./src/sqm/agent-skill.js && node --check ./src/sqm/workflow-onboarding.js && node --check ./src/sqm/workflow-update.js && node --check ./src/sqm/history-store.js && node --check ./src/sqm/review-worker.js && node --check ./scripts/sqm-complete-loop-e2e.js && node --check ./scripts/sqm-local-review-e2e.js",
46
46
  "check:real-provider-syntax": "node --check ./scripts/real-codex-probe.js && node --check ./scripts/real-claude-probe.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/claude-parity-runtime-e2e.js && node --check ./scripts/claude-plugin-runtime-e2e.js && node --check ./scripts/codex-plugin-runtime-e2e.js",
47
47
  "check:syntax": "node --check ./src/purpose-local-proof.js && node --check ./src/device-signing-identity.js && node --check ./src/index.js && node --check ./src/install-update.js && node --check ./src/agent.js && node --check ./src/connection-discovery.js && node --check ./src/connection-diagnostics.js && node --check ./src/launch-authorization.js && node --check ./src/codex-plugins/simy/hooks/connection-reminder.mjs && node --check ./src/codex-plugins/simy/hooks/dev-work.mjs && node --check ./src/dev-work-events.js && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/durable-local-task-worker.js && node --check ./src/durable-local-task-steps-contract.js && node --check ./src/bounded-local-task-subtasks-contract.js && node --check ./src/bounded-local-task-subtask-pool.js && node --check ./src/local-task-artifact-contract.js && node --check ./src/execution-capability-contract.js && node --check ./src/shutdown.js && node --check ./src/run-registry.js && node --check ./src/direct-executor.js && node --check ./src/execution-guardrail.js && node --check ./src/backend-executable.js && node --check ./src/login-shell-path.js && node --check ./src/hook-runtime.js && node --check ./src/provider-stream.js && node --check ./src/console/index.js && node --check ./src/console/app.js && node --check ./src/console/commands.js && node --check ./src/local-attachments.js && node --check ./src/repository-inventory.js && node --check ./src/session-store.js && node --check ./src/web-origin.js && node --check ./src/workspace-context.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/recovery.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/completion-contract.js && node --check ./src/orchestrator/pr-preparation.js && node --check ./src/orchestrator/minimum-completion.js && node --check ./src/orchestrator/delivery/scripts/check-minimum-completion.mjs && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/problem-solving.js && node --check ./src/orchestrator/budget.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/presentation.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/retry.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./src/coding-run-store.js && node --check ./scripts/fixtures/completion-contract-fixture.js && node --check ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-e2e.js && node --check ./scripts/completion-contract-e2e.js && node --check ./scripts/retry-circuit-e2e.js && node --check ./scripts/follow-up-charter-e2e.js && node --check ./scripts/task-routing-e2e.js && node --check ./scripts/executor-version-preflight-e2e.js && node --check ./scripts/blocked-recovery-e2e.js && node --check ./scripts/process-localization-e2e.js && node --check ./scripts/hypothesis-retries-e2e.js && node --check ./scripts/budget-enforcement-e2e.js && node --check ./scripts/local-task-default-workspace-e2e.js && node --check ./scripts/local-task-idempotency-e2e.js && node --check ./scripts/console-e2e-fixture.js && node --check ./scripts/console-e2e.js && node --check ./scripts/console-recovery-e2e-fixture.js && node --check ./scripts/console-recovery-e2e.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/fullstack-cli-smoke.js && node --check ./scripts/check-package-contents.js && node --check ./src/codex-plugins/simy/hooks/delivery-loop.mjs && node --check ./src/delivery-hooks/command.js && node --check ./src/delivery-hooks/desktop.js && node --check ./src/delivery-hooks/supervisor.js && node --check ./src/delivery-hooks/recovery.js && node --check ./src/delivery-hooks/supervisor-owner.js && node --check ./src/delivery-hooks/supervisor-watchdog.js && node --check ./src/delivery-hooks/supervisor-service.js && node --check ./scripts/supervisor-watchdog-live-e2e.js && node --check ./src/codex-plugins/simy/hooks/recovery-incident.mjs && node --check ./src/session-supervision/automatic-continuation.js && node --check ./src/session-supervision/stop-notifications.js",
48
48
  "check:package": "node ./scripts/check-package-contents.js",
@@ -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.20";
17
+ export const CLI_VERSION = "0.7.22";
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
 
@@ -7,7 +7,7 @@ export function SessionListConsole({ catalog: initial, query: initialQuery = '',
7
7
  const { exit } = useApp(), { stdout } = useStdout();
8
8
  const [catalog, setCatalog] = useState(initial), [query, setQuery] = useState(initialQuery);
9
9
  const [searching, setSearching] = useState(false), [index, setIndex] = useState(0);
10
- const [busy, setBusy] = useState(false), [notice, setNotice] = useState('');
10
+ const [busy, setBusy] = useState(false), [notice, setNotice] = useState((initial.warnings || []).join('; '));
11
11
  const [height, setHeight] = useState(stdout.rows || 24);
12
12
  useEffect(() => { const resize = () => setHeight(stdout.rows || 24); stdout.on('resize', resize); return () => stdout.off('resize', resize); }, [stdout]);
13
13
  const rows = filterSessionList(catalog.sessions, query), selected = Math.max(0, Math.min(index, Math.max(0, rows.length - 1)));
@@ -26,7 +26,7 @@ export function SessionListConsole({ catalog: initial, query: initialQuery = '',
26
26
  if (key.downArrow) setIndex(Math.max(0, Math.min(rows.length - 1, selected + 1)));
27
27
  if (text === 'r' && !busy) {
28
28
  setBusy(true); setNotice('');
29
- void refresh().then(next => { setCatalog(next); setIndex(0); }, error => {
29
+ void refresh().then(next => { setCatalog(next); setNotice((next.warnings || []).join('; ')); setIndex(0); }, error => {
30
30
  if (['provider_link_disconnected', 'provider_link_changed'].includes(error.code)) {
31
31
  setCatalog(current => ({ ...current, sessions: [] })); setIndex(0);
32
32
  }
@@ -36,15 +36,15 @@ export function SessionListConsole({ catalog: initial, query: initialQuery = '',
36
36
  });
37
37
  const current = rows[selected];
38
38
  return jsxs(Box, { flexDirection: 'column', children: [
39
- jsx(Text, { bold: true, color: 'cyan', children: `SIMY · Codex会話一覧 · ${terminalText(catalog.host)}` }),
40
- jsx(Text, { children: `表示 ${rows.length} / ${catalog.sessions.length} 件 · この端末の実会話` }),
39
+ jsx(Text, { bold: true, color: 'cyan', children: `SIMY · Codex / Claude Code 会話一覧 · ${terminalText(catalog.host)}` }),
40
+ jsx(Text, { children: `表示 ${rows.length} / ${catalog.sessions.length} 件 · 接続先の実会話` }),
41
41
  jsx(Text, { color: searching ? 'yellow' : undefined, children: `検索: ${query}${searching ? '▏' : ''}` }),
42
42
  ...rows.slice(offset, offset + pageSize).map((row, i) => jsx(Text, {
43
43
  color: offset + i === selected ? 'green' : undefined, wrap: 'truncate-end',
44
44
  children: `${offset + i === selected ? '›' : ' '} ${terminalText(row.title)}`,
45
- }, row.session)),
45
+ }, `${row.hostId || 'local'}:${row.provider}:${row.session}`)),
46
46
  ...(!rows.length ? [jsx(Text, { children: '一致する会話はありません。' }, 'empty')] : []),
47
- jsx(Text, { dimColor: true, children: current ? `${terminalText(current.workState?.label)} · ${current.session}` : '' }),
47
+ jsx(Text, { dimColor: true, children: current ? `${terminalText(current.hostLabel || catalog.host)} · ${current.provider === 'claude' ? 'Claude Code' : 'Codex'} · ${terminalText(current.workState?.label)} · ${current.session}` : '' }),
48
48
  jsx(Text, { dimColor: true, wrap: 'truncate-end', children: terminalText(current?.cwd) }),
49
49
  jsx(Text, { color: notice ? 'red' : undefined, children: notice || (busy ? '更新中…' : '↑↓ 選択 / 検索 Esc 解除 r 更新 q 終了') }),
50
50
  ] });