@awak-app/simy-cli 0.7.32 → 0.7.33

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.
@@ -1,3 +1,4 @@
1
+ import { continuationReadinessRevision, retryableAnalysis, automaticAnalysisRetryAllowed, observeContinuationWait, projectContinuationWait, ANALYSIS_RETRY_DELAYS_MS, CONTINUATION_WAIT_NOTICE_MS } from './continuation-readiness.js';
1
2
  import { createHash, randomUUID } from 'node:crypto';
2
3
  import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
4
  import { join } from 'node:path';
@@ -33,9 +34,11 @@ const canDispatchRecommendation = candidate => eligible(candidate) &&
33
34
  // Recommendations are metadata only. This worker never enrolls a conversation
34
35
  // or dispatches its task; the normal managed bootstrap still checks context.
35
36
  export async function createContinuationAnalysisManager({ root, analyze = analyzeContinuation,
36
- now = Date.now, onChange = () => {}, timeoutMs = 120_000, concurrency = 3 } = {}) {
37
+ now = Date.now, onChange = () => {}, timeoutMs = 120_000, concurrency = 3,
38
+ retryDelaysMs = ANALYSIS_RETRY_DELAYS_MS, waitNoticeAfterMs = CONTINUATION_WAIT_NOTICE_MS } = {}) {
37
39
  if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 4) throw new TypeError('Analysis concurrency must be an integer from 1 to 4.');
38
40
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new TypeError('Analysis timeout must be positive.');
41
+ if (!Array.isArray(retryDelaysMs) || retryDelaysMs.length > 4 || retryDelaysMs.some(value => !Number.isFinite(value) || value <= 0) || !Number.isFinite(waitNoticeAfterMs) || waitNoticeAfterMs <= 0) throw new TypeError('Invalid continuation retry policy.');
39
42
  await mkdir(root, { recursive: true, mode: 0o700 });
40
43
  if ((await lstat(root)).isSymbolicLink()) throw new Error('Invalid session analysis cache.');
41
44
  const path = join(root, 'cache.json');
@@ -105,13 +108,14 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
105
108
  } finally { await rm(temporary, { force: true }); }
106
109
  };
107
110
  const changed = () => { void Promise.resolve().then(onChange).catch(() => {}); };
108
- const enqueue = (candidate, force = false, forceAnalysis = false) => {
111
+ const enqueue = (candidate, force = false, forceAnalysis = false, resetRetry = false) => {
109
112
  const key = keyOf(candidate), revision = continuationAnalysisRevision(candidate);
110
113
  const entry = entries.get(key);
111
114
  if (entry?.revision === revision && (['queued', 'analyzing'].includes(entry.result?.state) ||
112
115
  !force && !['failed', 'uncertain'].includes(entry.result?.state))) return false;
113
116
  entries.delete(key);
114
117
  entries.set(key, { revision, analysisVersion: CONTINUATION_ANALYSIS_VERSION, cwd: candidate.cwd,
118
+ retry: !resetRetry && entry?.revision === revision ? entry.retry : undefined,
115
119
  forceAnalysis: forceAnalysis || entry?.forceAnalysis === true && ['queued', 'analyzing'].includes(entry.result?.state),
116
120
  settledResult: entry?.cwd === candidate.cwd ? entry.settledResult : null,
117
121
  requestId: entry?.cwd === candidate.cwd && ['queued', 'analyzing'].includes(entry.result?.state) ? entry.requestId : undefined,
@@ -153,7 +157,8 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
153
157
  };
154
158
  const runJob = async selected => {
155
159
  const signal = selected.controller.signal;
156
- const timeout = setTimeout(() => selected.controller.abort(), timeoutMs);
160
+ let timedOut = false;
161
+ const timeout = setTimeout(() => { timedOut = true; selected.controller.abort(); }, timeoutMs);
157
162
  timeout.unref?.();
158
163
  let aborted;
159
164
  const cancellation = new Promise((_, reject) => {
@@ -169,10 +174,14 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
169
174
  result.providerGoal?.status === 'active' && result.providerGoal.textTruncated !== true);
170
175
  if (!RESULT_STATES.has(result?.state) || typeof result.reason !== 'string' ||
171
176
  (result.state === 'continue' && (!result.objective || !result.remaining?.length || !result.nextAction || !grounded))) {
172
- throw new Error('Invalid session analysis result.');
177
+ throw Object.assign(new Error('Invalid session analysis result.'), { code: 'analysis_invalid_result' });
173
178
  }
174
- } catch {
175
- result = { state: 'failed', reason: 'The conversation could not be analyzed. Retry when its provider is available.' };
179
+ } catch (error) {
180
+ // Keep structured nontechnical failures (identity, authority, incomplete
181
+ // context) out of the technical retry budget, including thrown failures.
182
+ const reasonCode = timedOut ? 'analysis_timeout' : signal.aborted ? 'analysis_cancelled'
183
+ : typeof error?.code === 'string' && /^[A-Za-z0-9_]{1,80}$/.test(error.code) ? error.code : 'analysis_failed';
184
+ result = { state: 'failed', reasonCode, reason: 'The conversation could not be analyzed. Retry when its provider is available.' };
176
185
  } finally {
177
186
  // Every job owns its cancellation and waits for the provider's process
178
187
  // receipt/cleanup before releasing either its slot or its session key.
@@ -196,8 +205,14 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
196
205
  return;
197
206
  }
198
207
  const settledResult = finishedAnalysis(result, selected.previousAnalysis || entry.settledResult);
208
+ let retry;
209
+ if (retryableAnalysis(result) && automaticAnalysisRetryAllowed(current)) {
210
+ const failures = (selected.entry.retry?.failures || 0) + 1;
211
+ const delay = retryDelaysMs[failures - 1];
212
+ retry = { failures, ...(delay !== undefined ? { nextAt: now() + delay } : {}), exhausted: delay === undefined };
213
+ }
199
214
  entries.set(selected.key, { revision: selected.revision, analysisVersion: CONTINUATION_ANALYSIS_VERSION, cwd: current.cwd, settledResult,
200
- completedAt: now(), authRecoveryAttemptedAt: selected.entry.authRecoveryAttemptedAt, authRecoverySignedOutAt: selected.entry.authRecoverySignedOutAt,
215
+ completedAt: now(), retry, authRecoveryAttemptedAt: selected.entry.authRecoveryAttemptedAt, authRecoverySignedOutAt: selected.entry.authRecoverySignedOutAt,
201
216
  requestId: selected.entry.requestId, result: { ...result, analyzedAt: settledResult?.analyzedAt || now() } });
202
217
  await persist(); changed();
203
218
  });
@@ -278,11 +293,9 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
278
293
  const goalObserved = candidate.goalReadState !== 'unavailable';
279
294
  const goalChanged = previous && previous.goalEvidenceVersion === 1 && Object.hasOwn(previous, 'goalRevision') && previous.goalObserved !== false && goalObserved && previous.goalRevision !== goalRevision;
280
295
  const trackingEnabled = candidate.tracked === true && (!previous || Object.hasOwn(previous, 'tracked') && previous.tracked !== true);
281
- // A known turn boundary is stronger than updated_at: tool/status
282
- // events in that same turn must not enqueue or cancel paid inference.
283
- const newActivity = document.initialized && (previous ?
284
- (turnOf(candidate) && previous.latestTurnId ? turnOf(candidate) !== previous.latestTurnId : active > previous.activity) ||
285
- (candidate.isRunning === true) !== (previous.isRunning === true) : active > document.baselineAt);
296
+ // An ON preference can predate a usable provider/workspace. Analyze
297
+ // once when readiness returns, even without a new turn or activity.
298
+ const readinessRevision = continuationReadinessRevision(candidate);
286
299
  const entry = entries.get(key);
287
300
  // Rebase unchanged old cache keys once without rerunning historical
288
301
  // inference when semantic goal/turn keys become available.
@@ -296,18 +309,32 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
296
309
  entry.settledResult = entry.result?.contextDigest ? entry.result : null;
297
310
  dirty = true;
298
311
  }
312
+ const dispatchReady = eligible(candidate) && candidate.capabilities?.canAutoContinue !== false &&
313
+ candidate.autoContinueAvailability?.canEnable !== false && candidate.dispatchReadiness?.state !== 'blocked';
314
+ const readinessRecovered = candidate.autoContinue === true && dispatchReady &&
315
+ (previous?.canAnalyze === false || previous?.dispatchReady === false) &&
316
+ (!entry || entry.revision !== revision || retryableAnalysis(entry.result) && !entry.retry?.exhausted &&
317
+ (!Number.isFinite(entry.retry?.nextAt) || now() >= entry.retry.nextAt));
318
+ const consentChanged = candidate.autoContinue === true && previous && Object.hasOwn(previous, 'autoContinueEpoch') && previous.autoContinueEpoch !== candidate.autoContinueEpoch;
319
+ // A known turn boundary is stronger than updated_at: tool/status
320
+ // events in that same turn must not enqueue or cancel paid inference.
321
+ const newActivity = document.initialized && (previous ?
322
+ (turnOf(candidate) && previous.latestTurnId ? turnOf(candidate) !== previous.latestTurnId : active > previous.activity) ||
323
+ (candidate.isRunning === true) !== (previous.isRunning === true) : active > document.baselineAt);
299
324
  // A reader that raced a terminal boundary (or a provider without live
300
325
  // status) is retried at discovery cadence, never a busy inference loop.
301
326
  const schemaRefresh = entry && !['queued', 'analyzing', 'failed'].includes(entry.result?.state) &&
302
327
  entry.result?.analysisVersion !== CONTINUATION_ANALYSIS_VERSION && entry.analysisVersion !== CONTINUATION_ANALYSIS_VERSION;
328
+ const automaticRetry = automaticAnalysisRetryAllowed(candidate) && retryableAnalysis(entry?.result) &&
329
+ entry?.revision === revision && Number.isFinite(entry?.retry?.nextAt) && now() >= entry.retry.nextAt;
303
330
  const recheck = entry?.result?.reasonCode === 'turn_settling' || entry?.result?.awaitingTurn === true && candidate.isRunning !== true;
304
- if (eligible(candidate) && (backfill.has(key) || newActivity || recheck || trackingEnabled || goalChanged || schemaRefresh)) {
305
- dirty = enqueue(candidate, recheck || trackingEnabled || goalChanged || schemaRefresh, trackingEnabled || goalChanged || schemaRefresh) || dirty;
331
+ if (eligible(candidate) && (backfill.has(key) || newActivity || recheck || trackingEnabled || readinessRecovered || consentChanged || automaticRetry || goalChanged || schemaRefresh)) {
332
+ dirty = enqueue(candidate, recheck || trackingEnabled || readinessRecovered || consentChanged || automaticRetry || goalChanged || schemaRefresh, trackingEnabled || readinessRecovered || consentChanged || automaticRetry || goalChanged || schemaRefresh, consentChanged) || dirty;
306
333
  }
307
- if (previous?.revision !== revision || previous?.activity !== active || previous?.goalEvidenceVersion !== 1 || previous?.goalObserved !== goalObserved || (previous?.tracked === true) !== (candidate.tracked === true) ||
334
+ if (previous?.readinessRevision !== readinessRevision || previous?.autoContinueEpoch !== candidate.autoContinueEpoch || previous?.canAnalyze !== eligible(candidate) || previous?.revision !== revision || previous?.activity !== active || previous?.goalEvidenceVersion !== 1 || previous?.goalObserved !== goalObserved || (previous?.tracked === true) !== (candidate.tracked === true) ||
308
335
  previous && (!Object.hasOwn(previous, 'goalRevision') || !Object.hasOwn(previous, 'tracked'))) {
309
336
  known.delete(key); known.set(key, { revision, cwd: candidate.cwd, activity: active, isRunning: candidate.isRunning, latestTurnId: turnOf(candidate),
310
- tracked: candidate.tracked === true, goalRevision, goalObserved, goalEvidenceVersion: 1 }); dirty = true;
337
+ tracked: candidate.tracked === true, canAnalyze: eligible(candidate), readinessRevision, dispatchReady, autoContinueEpoch: candidate.autoContinueEpoch, wait: previous?.wait, goalRevision, goalObserved, goalEvidenceVersion: 1 }); dirty = true;
311
338
  }
312
339
  }
313
340
  // Cancelling owned analysis is separate from stopping the original
@@ -335,7 +362,45 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
335
362
  } else if (previous && ['queued', 'analyzing'].includes(analysis.state)) {
336
363
  analysis.previousAnalysis = previous;
337
364
  }
338
- return { ...candidate, analysis };
365
+ return { ...candidate, analysis: { ...analysis, ...(entry?.revision === revision && entry.retry ? { retry: { ...entry.retry } } : {}) } };
366
+ },
367
+ async observeContinuationWaits(sessions) {
368
+ await exclusive(async () => {
369
+ if (closed || suspended) return;
370
+ let dirty = false;
371
+ for (const candidate of sessions) {
372
+ const key = keyOf(candidate), previous = known.get(key);
373
+ if (!previous || previous.cwd !== candidate.cwd) continue;
374
+ const wait = observeContinuationWait(candidate, previous.wait, now());
375
+ if (JSON.stringify(wait) !== JSON.stringify(previous.wait || null)) { known.set(key, { ...previous, wait }); dirty = true; }
376
+ }
377
+ if (dirty) await persist();
378
+ });
379
+ },
380
+ projectContinuationWait(candidate) {
381
+ const previous = known.get(keyOf(candidate));
382
+ return previous && previous.cwd === candidate.cwd ? projectContinuationWait(candidate, previous.wait, now(), waitNoticeAfterMs) : candidate;
383
+ },
384
+ async takeContinuationWaitNotifications(sessions) {
385
+ return exclusive(async () => {
386
+ if (closed || suspended) return [];
387
+ const events = [];
388
+ for (const candidate of sessions) {
389
+ const key = keyOf(candidate), previous = known.get(key), wait = previous?.wait;
390
+ if (previous?.cwd !== candidate.cwd || !wait || wait.notified) continue;
391
+ // The final UI projection has needs_user; reconstruct the pre-notice
392
+ // status for the same known runtime condition without granting work.
393
+ const current = { ...candidate, autoContinueStatus: candidate.continuationWait?.id === wait.id ? wait.status : candidate.autoContinueStatus };
394
+ const projected = projectContinuationWait(current, wait, now(), waitNoticeAfterMs);
395
+ if (!projected.continuationWait?.needsAttention) continue;
396
+ known.set(key, { ...previous, wait: { ...wait, notified: true } });
397
+ events.push({ type: 'notify', id: wait.id, kind: 'needs_decision', continuationWaitId: wait.id,
398
+ sessionIdentity: `${candidate.provider}|${candidate.session}`, provider: candidate.provider, session: candidate.session,
399
+ objective: String(candidate.title || '').slice(0, 200) });
400
+ }
401
+ if (events.length) await persist();
402
+ return events;
403
+ });
339
404
  },
340
405
  async request(candidate, { requestId } = {}) {
341
406
  if (requestId !== undefined && (typeof requestId !== 'string' || !requestId || requestId.length > 128)) throw new Error('Invalid analysis request identity.');
@@ -344,7 +409,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
344
409
  await exclusive(async () => {
345
410
  if (closed || suspended) throw new Error('Session analysis is unavailable.');
346
411
  candidates.set(keyOf(candidate), candidate);
347
- let dirty = enqueue(candidate, true, true);
412
+ let dirty = enqueue(candidate, true, true, true);
348
413
  const entry = entries.get(keyOf(candidate));
349
414
  if (requestId && entry.requestId !== requestId) { entry.requestId = requestId; dirty = true; }
350
415
  if (dirty) { await persist(); changed(); }
@@ -72,7 +72,7 @@ async function metadata(path, desktopId, sessions) {
72
72
  if (item.sessionId === desktopId && UUID.test(item.cliSessionId || '') && typeof item.cwd === 'string' && isAbsolute(item.cwd)) {
73
73
  value = { desktopId, session: item.cliSessionId, cwd: resolve(item.cwd),
74
74
  ...(complete ? { providerArchived: typeof item.isArchived === 'boolean' ? item.isArchived : typeof item.archived === 'boolean' ? item.archived : undefined,
75
- pullRequests: normalizePullRequests(item.pullRequests || item.gitHubPullRequests || item.githubPullRequests || item.githubPRs || item.gitHubPRs || item.gitHubPR || item.pullRequest || (item.prUrl ? { url: item.prUrl, state: item.prStatus } : null)) } : {}),
75
+ pullRequests: normalizePullRequests(item.pullRequests || item.prs || item.gitHubPullRequests || item.githubPullRequests || item.githubPRs || item.gitHubPRs || item.gitHubPR || item.pullRequest || (item.prUrl ? { url: item.prUrl, state: item.prStatus } : null)) } : {}),
76
76
  parentDesktopId: complete && LOCAL.test(item.spawnedFrom?.sessionId || '') ? item.spawnedFrom.sessionId : null };
77
77
  }
78
78
  const after = await lstat(path, { bigint: true });
@@ -165,7 +165,7 @@ async function inspectTranscript(root, projectName, fileName, observedAt, includ
165
165
  finally { await handle?.close(); }
166
166
  }
167
167
 
168
- async function collect({ home = homedir(), all = false, limit = 100, session = null, prioritySessions = [], includeContext = false, cacheMetadata = false, includeStatistics = false, includeBoundary = false, humanRequest = null, signal } = {}) {
168
+ async function collect({ home = homedir(), all = false, limit = 100, session = null, prioritySessions = [], includeContext = false, cacheMetadata = false, includeStatistics = false, includeBoundary = false, humanRequest = null, signal, onProgress } = {}) {
169
169
  if (signal?.aborted) return [];
170
170
  const root = await projectsRoot(home);
171
171
  if (!root) return [];
@@ -212,7 +212,8 @@ async function collect({ home = homedir(), all = false, limit = 100, session = n
212
212
  ...recentSessionWindow(descriptors.filter(item => desktopLineage.get(item.session)?.providerArchived !== true), { limit: maximum, priority: item => priorities.has(item.session) }),
213
213
  ...recentSessionWindow(descriptors.filter(item => desktopLineage.get(item.session)?.providerArchived === true), { limit: maximum, priority: item => priorities.has(item.session) }),
214
214
  ];
215
- const filesToInspect = all ? selected : selected.slice(0, MAX_TRANSCRIPTS);
215
+ const filesToInspect = (all ? selected : selected.slice(0, MAX_TRANSCRIPTS)).sort((a, b) => b.lastActivityAt - a.lastActivityAt);
216
+ let nextProgress = 25, progress = Promise.resolve();
216
217
  let nextFile = 0;
217
218
  // Catalog prefixes are bounded independently of full transcript size. Use a
218
219
  // small I/O pool; exact-context/statistics reads stay serial and identity-safe.
@@ -224,8 +225,19 @@ async function collect({ home = homedir(), all = false, limit = 100, session = n
224
225
  candidate = decorateLineage(candidate);
225
226
  const key = `${candidate.session}\0${candidate.cwd}${includeContext || includeStatistics || includeBoundary || humanRequest ? `\0${file.projectName}\0${file.fileName}` : ''}`;
226
227
  if (!result.has(key) || candidate.lastActivityAt > result.get(key).lastActivityAt) result.set(key, candidate);
228
+ if (onProgress && result.size >= nextProgress) {
229
+ nextProgress = Math.max(100, result.size * 2);
230
+ // Desktop parent links require the final cross-identity validation below.
231
+ const snapshot = [...result.values()].map(item => {
232
+ if (item.parentSessionSource !== 'claude_desktop_spawned_from') return item;
233
+ const { parentSession, parentSessionSource, ...independent } = item; return independent;
234
+ });
235
+ progress = progress.then(() => { signal?.throwIfAborted(); return onProgress(snapshot); });
236
+ await progress;
237
+ }
227
238
  }
228
239
  }));
240
+ await progress;
229
241
  if (signal?.aborted) return [];
230
242
  // Resolve old parents outside the recent window through their own validated
231
243
  // transcript. Never manufacture selectable conversations from Desktop JSON.
@@ -264,8 +276,8 @@ async function collect({ home = homedir(), all = false, limit = 100, session = n
264
276
  ];
265
277
  }
266
278
 
267
- export async function listClaudeSessions({ home, all = false, limit, prioritySessions, signal } = {}) {
268
- return collect({ home, all, limit, prioritySessions, signal, cacheMetadata: true });
279
+ export async function listClaudeSessions({ home, all = false, limit, prioritySessions, signal, onProgress } = {}) {
280
+ return collect({ home, all, limit, prioritySessions, signal, onProgress, cacheMetadata: true });
269
281
  }
270
282
 
271
283
  export async function readClaudeSession(session, { home } = {}) {
@@ -235,40 +235,45 @@ export function codexThreadMetadata(thread) {
235
235
  };
236
236
  }
237
237
 
238
- export async function listCodexSessions({ client, all = false, signal, maxPages = 40, pageSize = 25, maxSessions = RECENT_SESSION_LIMIT, archived = false, inspectState = false, prioritySessions = [], observationBudgetMs = 3000, codexHome } = {}) {
238
+ export async function listCodexSessions({ client, all = false, signal, onPage, maxPages = 40, pageSize = 25, maxSessions = RECENT_SESSION_LIMIT, archived = false, inspectState = false, prioritySessions = [], observationBudgetMs = 3000, codexHome } = {}) {
239
239
  const sessions = [];
240
240
  const windowSize = all ? Infinity : Math.min(maxSessions, maxPages * pageSize);
241
241
  const cursors = new Set(), ids = new Set();
242
- let cursor = null, listed = 0, pages = 0, batchSize = pageSize;
243
- while (pages < Math.max(maxPages, Math.ceil(windowSize / batchSize)) && listed < windowSize) {
244
- signal?.throwIfAborted();
245
- const limit = Math.min(batchSize, windowSize - listed);
246
- let response;
247
- try { response = await client.request('thread/list', { cursor, limit, sourceKinds: SOURCE_KINDS,
248
- archived, sortKey: 'updated_at', sortDirection: 'desc', useStateDbOnly: true }); }
249
- catch (error) {
250
- // Keep the transport's byte limit. Retry the same cursor in smaller
251
- // batches rather than dropping discovery for large metadata envelopes.
252
- if (error?.code !== 'response_too_large' || limit <= 1) throw error;
253
- batchSize = Math.max(1, Math.floor(limit / 2));
254
- continue;
255
- }
256
- if (!Array.isArray(response?.data) || response.data.length > limit)
257
- throw new CodexAppServerError('invalid_page', 'Codex returned an invalid conversation page.');
258
- const threads = response.data;
259
- listed += threads.length; pages++;
260
- for (const thread of threads) {
261
- const metadata = codexThreadMetadata(thread);
262
- if (metadata && SOURCE_KINDS.includes(metadata.source) && !ids.has(metadata.session)) {
263
- ids.add(metadata.session); sessions.push(metadata);
242
+ let cursor = null, listed = 0, pages = 0, batchSize = pageSize, nextProgress = 0;
243
+ try {
244
+ while (pages < Math.max(maxPages, Math.ceil(windowSize / batchSize)) && listed < windowSize) {
245
+ signal?.throwIfAborted();
246
+ const limit = Math.min(batchSize, windowSize - listed);
247
+ let response;
248
+ try { response = await client.request('thread/list', { cursor, limit, sourceKinds: SOURCE_KINDS,
249
+ archived, sortKey: 'updated_at', sortDirection: 'desc', useStateDbOnly: true }); }
250
+ catch (error) {
251
+ // Keep the transport's byte limit. Retry the same cursor in smaller
252
+ // batches rather than dropping discovery for large metadata envelopes.
253
+ if (error?.code !== 'response_too_large' || limit <= 1) throw error;
254
+ batchSize = Math.max(1, Math.floor(limit / 2));
255
+ continue;
264
256
  }
257
+ if (!Array.isArray(response?.data) || response.data.length > limit)
258
+ throw new CodexAppServerError('invalid_page', 'Codex returned an invalid conversation page.');
259
+ const threads = response.data;
260
+ listed += threads.length; pages++;
261
+ for (const thread of threads) {
262
+ const metadata = codexThreadMetadata(thread);
263
+ if (metadata && SOURCE_KINDS.includes(metadata.source) && !ids.has(metadata.session)) {
264
+ ids.add(metadata.session); sessions.push(metadata);
265
+ }
266
+ }
267
+ if (onPage && (pages === 1 || sessions.length >= nextProgress)) {
268
+ await onPage(sessions); nextProgress = Math.max(100, sessions.length * 2);
269
+ }
270
+ cursor = response?.nextCursor || null;
271
+ if (!cursor) break;
272
+ if (typeof cursor !== 'string' || cursors.has(cursor))
273
+ throw new CodexAppServerError('invalid_page', 'Codex repeated a conversation page cursor.');
274
+ cursors.add(cursor);
265
275
  }
266
- cursor = response?.nextCursor || null;
267
- if (!cursor) break;
268
- if (typeof cursor !== 'string' || cursors.has(cursor))
269
- throw new CodexAppServerError('invalid_page', 'Codex repeated a conversation page cursor.');
270
- cursors.add(cursor);
271
- }
276
+ } finally { if (onPage && sessions.length && !signal?.aborted) await onPage(sessions); }
272
277
  const deadline = Date.now() + Math.max(1, Math.min(observationBudgetMs, 5000));
273
278
  // A recent page can omit an explicitly supervised conversation, including
274
279
  // a newly created desktop chat. Read its exact metadata without loading or
@@ -802,7 +807,11 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
802
807
  const captureWorkspace = async () => {
803
808
  const files = [];
804
809
  const repository = await repositoryIdentity(taskDirectory(record), { signal }).catch(() => null);
805
- try { const digest = await workspaceIdentity(taskDirectory(record), root, files, { signal }); return { digest, files, repository, potentiallyIncomplete: files.length === 256, at: Date.now() }; }
810
+ // Exploratory work must not spend minutes hashing build outputs before
811
+ // an AI can inspect the actual obstacle. Only optional preparation is
812
+ // bounded; outcome-review manifests below keep their complete checks.
813
+ const budget = !reviewing ? { maxEntries: 1024, maxBytes: 16 * 1024 * 1024, deadline: Date.now() + 1500 } : {};
814
+ try { const digest = await workspaceIdentity(taskDirectory(record), root, files, { signal, ...budget }); return { digest, files, repository, potentiallyIncomplete: files.length === 256, at: Date.now() }; }
806
815
  catch { return { unavailable: true, repository, reason: 'Workspace inventory unavailable; continue discovery and use available evidence.' }; }
807
816
  };
808
817
  const beforeEvidence = searchEnabled(run.managed) && !reviewing ? await captureWorkspace() : null;
@@ -0,0 +1,58 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
4
+ export const ANALYSIS_RETRY_DELAYS_MS = [30_000, 120_000];
5
+ export const CONTINUATION_WAIT_NOTICE_MS = 15 * 60_000;
6
+
7
+ // Observation timestamps and translated prose are intentionally excluded.
8
+ export const continuationReadinessRevision = candidate => digest([
9
+ candidate.cwd, candidate.capabilities?.canAnalyze === true,
10
+ candidate.capabilities?.canContinue === true, candidate.capabilities?.canAutoContinue === true,
11
+ candidate.autoContinueAvailability?.canEnable, candidate.autoContinueAvailability?.reasonCode,
12
+ candidate.continuationReasonCode, candidate.continuationAdmission?.source,
13
+ candidate.continuationAdmission?.model, Boolean(candidate.continuationAdmission),
14
+ candidate.dispatchReadiness?.state, candidate.dispatchReadiness?.code,
15
+ ]);
16
+
17
+ const retryable = new Set(['analysis_failed', 'analysis_invalid_result', 'analysis_unavailable',
18
+ 'analysis_timeout', 'timeout', 'unavailable', 'context_unavailable', 'ETIMEDOUT',
19
+ 'ECONNRESET', 'ECONNREFUSED', 'EAI_AGAIN']);
20
+ export const retryableAnalysis = result => ['failed', 'uncertain'].includes(result?.state) &&
21
+ retryable.has(result.reasonCode || (result.state === 'failed' ? 'analysis_failed' : ''));
22
+
23
+ export function automaticAnalysisRetryAllowed(candidate) {
24
+ return candidate.autoContinue === true && !candidate.archived && !candidate.localHidden &&
25
+ candidate.isRunning !== true && !['stopped', 'completed', 'approval', 'question'].includes(candidate.workState?.state) &&
26
+ candidate.autoContinueStatus !== 'needs_user';
27
+ }
28
+
29
+ export function continuationWaitReason(candidate) {
30
+ if (!automaticAnalysisRetryAllowed(candidate)) return null;
31
+ if (candidate.analysis?.state === 'needs_user' || candidate.analysis?.state === 'no_action' ||
32
+ candidate.analysis?.state === 'waiting' && candidate.analysis?.blocker?.type === 'external_dependency') return null;
33
+ if (candidate.analysis?.retry?.exhausted) return 'analysis_retry_exhausted';
34
+ if (['queued', 'analyzing'].includes(candidate.analysis?.state)) return 'analysis_pending';
35
+ if (candidate.autoContinueAvailability?.canEnable === false || candidate.capabilities?.canAutoContinue === false ||
36
+ candidate.capabilities?.canAnalyze === false || candidate.dispatchReadiness?.state === 'blocked') {
37
+ return candidate.autoContinueAvailability?.reasonCode || candidate.dispatchReadiness?.code || 'runtime_unavailable';
38
+ }
39
+ if (['error', 'recovering', 'starting', 'unavailable'].includes(candidate.autoContinueStatus)) return 'continuation_unavailable';
40
+ if (retryableAnalysis(candidate.analysis)) return candidate.analysis.reasonCode || 'analysis_failed';
41
+ return null;
42
+ }
43
+
44
+ export function observeContinuationWait(candidate, previous, now) {
45
+ const reasonCode = continuationWaitReason(candidate);
46
+ if (!reasonCode || !candidate.autoContinueEpoch) return null;
47
+ if (previous?.epoch === candidate.autoContinueEpoch && Number.isFinite(previous.since)) return { ...previous, reasonCode, status: candidate.autoContinueStatus };
48
+ return { id: digest([candidate.provider, candidate.session, candidate.autoContinueEpoch, now]),
49
+ epoch: candidate.autoContinueEpoch, since: now, reasonCode, status: candidate.autoContinueStatus, notified: false };
50
+ }
51
+
52
+ export function projectContinuationWait(candidate, wait, now, noticeAfterMs = CONTINUATION_WAIT_NOTICE_MS) {
53
+ if (!wait || wait.epoch !== candidate.autoContinueEpoch || !continuationWaitReason(candidate)) return candidate;
54
+ const needsAttention = wait.reasonCode === 'analysis_retry_exhausted' || now - wait.since >= noticeAfterMs;
55
+ return { ...candidate, continuationWait: { id: wait.id, since: wait.since, reasonCode: wait.reasonCode, needsAttention },
56
+ ...(needsAttention ? { autoContinueStatus: 'needs_user',
57
+ autoContinueReason: 'Auto Continue is saved, but work has been waiting for execution conditions. Check the original conversation or refresh its connection.' } : {}) };
58
+ }
@@ -72,9 +72,9 @@ export function sessionDecisionPresentation(candidate, run) {
72
72
  title: pair('続けるために、この点を教えてください', 'Answer to continue'), question: pair(clean(question.text), clean(question.text)),
73
73
  context: pair('回答を受け取ってから目的を確認し、許可された作業を続けます。', 'After your answer, AI checks the goal and continues within the existing permissions.'),
74
74
  }, { answerFields: { ...control, taskId: run.taskId, questionId: question.id }, choices: [] });
75
- if (candidate.isRunning || run?.dependencyWait || run?.autoRecovery && ['recovery_required', 'auto_resuming'].includes(run.state) ||
75
+ if (candidate.isRunning && candidate.workState?.stale !== true || run?.dependencyWait || run?.autoRecovery && ['recovery_required', 'auto_resuming'].includes(run.state) ||
76
76
  ['supervising', 'watching', 'waiting', 'starting', 'recovering'].includes(candidate.autoContinueStatus)) return null;
77
- if (run && (['recovery_required', 'paused', 'failed', 'blocked'].includes(run.state) || run.state === 'verification_pending' && run.canResume)) {
77
+ if (run && (['recovery_required', 'paused', 'failed', 'blocked', 'stopped'].includes(run.state) || run.state === 'verification_pending' && run.canResume)) {
78
78
  const acceptancePending = run.state === 'verification_pending';
79
79
  const providerName = candidate.provider === 'claude' ? 'Claude' : candidate.provider === 'codex' ? 'Codex' : 'AI';
80
80
  const reasons = {
@@ -85,11 +85,13 @@ export function sessionDecisionPresentation(candidate, run) {
85
85
  hook_owner_mismatch: pair('元のAI会話の接続が変わりました。', 'The connection to the original AI session changed.'),
86
86
  };
87
87
  const reason = acceptancePending ? pair('最終確認は未完了ですが、許可済みの残作業を続けられます。再開しても検証済みにはなりません。', 'Acceptance is still pending. Authorized remaining work can continue without marking the result verified.')
88
+ : run.state === 'stopped' ? pair('中断・停止が記録されています。再開を選ぶまで作業は続けません。', 'An interruption or stop is recorded. Work waits for an explicit resume choice.')
88
89
  : run.state === 'paused' ? pair('この作業は停止中です。停止の指示は自動で解除しません。', 'This work is paused. A stop instruction is never cleared automatically.')
89
90
  : reasons[run.recoveryCause] || pair('作業を続けるための条件を確認できていません。', 'The conditions for continuing have not been established.');
90
91
  const choices = [];
91
92
  if (run.canReplan && frozen) choices.push(choice(pair('AIに状況確認を任せる', 'Ask AI to investigate'), pair('目的・履歴・制限を維持して原因と安全な次の手順を調べます。操作の許可を追加することはありません。', 'Investigate the cause and next steps while keeping the goal, history and limits. No new permission is granted.'), 'delivery_replan', {}, 'primary'));
92
- if (run.canResume && frozen) choices.push(choice(pair(acceptancePending ? '未完了の作業を続ける' : '再開を依頼する', acceptancePending ? 'Continue remaining work' : 'Request resume'), pair(acceptancePending ? '元の目的・履歴・制限を維持して残作業を確認します。最終確認を済ませたことにはしません。' : '保存した目的と履歴を引き継ぎます。接続や権限が不足する場合は、その内容を案内します。', acceptancePending ? 'Keep the goal, history and limits while checking what remains. This does not accept the result.' : 'Keep the saved goal and history. Any missing connection or permission will be shown.'), 'delivery_resume', {}, choices.length ? 'secondary' : 'primary'));
93
+ const enablesAutoContinue = candidate.provider === 'codex' && run.managed !== true && run.state === 'stopped' && candidate.autoContinue !== true;
94
+ if (run.canResume && frozen) choices.push(choice(pair(enablesAutoContinue ? '自動継続をオンにして再開' : acceptancePending ? '未完了の作業を続ける' : '再開を依頼する', enablesAutoContinue ? 'Resume with Auto Continue' : acceptancePending ? 'Continue remaining work' : 'Request resume'), pair(enablesAutoContinue ? '自動継続をオンにして元の会話の読み取り専用の状況確認から再開します。元の目的・権限・停止履歴・予算を維持します。' : acceptancePending ? '元の目的・履歴・制限を維持して残作業を確認します。最終確認を済ませたことにはしません。' : '保存した目的と履歴を引き継ぎます。接続や権限が不足する場合は、その内容を案内します。', enablesAutoContinue ? 'Turn on Auto Continue and resume with read-only diagnosis in the original conversation. Preserve its goal, permissions, stop history and budgets.' : acceptancePending ? 'Keep the goal, history and limits while checking what remains. This does not accept the result.' : 'Keep the saved goal and history. Any missing connection or permission will be shown.'), 'delivery_resume', {}, choices.length ? 'secondary' : 'primary'));
93
95
  choices.push(choice(pair(`元の${providerName}会話を開く`, 'Open original conversation ↗'), pair('接続や権限の確認が必要な場合、元のAI会話で確認できます。会話を開くだけでは作業を再開しません。', 'Check the original conversation. Opening it does not resume work.'), 'open_original', {}, 'link'));
94
96
  if (candidate.autoContinue === true) choices.push(choice(pair('自動継続をオフにする', 'Turn off Auto Continue'), pair('今後の自動継続をオフにします。作業の履歴は残ります。', 'Turn off future automatic continuation and retain the history.'), 'session_set_auto_continue', { ...identity, enabled: false }));
95
97
  const actionable = frozen && (run.canReplan || run.canResume);
@@ -106,6 +108,12 @@ export function sessionDecisionPresentation(candidate, run) {
106
108
  context: pair('自動継続はオフです。オンにすると、元の目的と権限の範囲で残作業を確認します。結果を受け入れる操作ではありません。', 'Auto Continue is off. Turning it on checks remaining work within the original goal and permissions. It does not accept the result.'),
107
109
  }, { choices: [choice(pair('自動継続をオンにする', 'Turn on Auto Continue'), pair('自動継続の設定を保存します。作業の開始や完了を証明するものではありません。', 'Save consent for Auto Continue. This does not confirm that work started or finished.'), 'session_set_auto_continue', { ...identity, enabled: true }, 'primary'),
108
110
  choice(pair('元の会話を開く', 'Open original conversation ↗'), pair('会話を開くだけでは作業を再開しません。', 'Opening the conversation does not resume work.'), 'open_original', {}, 'link')] });
111
+ if (candidate.continuationWait?.needsAttention === true && candidate.autoContinue === true) return model('continuation_wait', {
112
+ title: pair('自動継続の条件を確認してください', 'Check Auto Continue conditions'),
113
+ question: pair('自動継続はオンのまま、実行条件の確認を待っています。元の会話や接続状態を確認してください。', 'Auto Continue is still on, waiting for execution conditions. Check the original conversation or its connection.'),
114
+ context: pair('設定・目的・停止指示・承認待ちは維持しています。この通知で作業を再開したり、権限を追加したりしません。', 'The setting, goal, stops and pending approvals are preserved. This notice does not resume work or grant permission.'),
115
+ }, { choices: [choice(pair('状況を再確認する', 'Refresh status'), pair('条件を再確認します。', 'Recheck conditions.'), 'refresh', identity),
116
+ choice(pair('元の会話を開く', 'Open original conversation ↗'), pair('元の会話を確認します。', 'Check the original conversation.'), 'open_original', {}, 'link')] }, 'helper');
109
117
  if (candidate.autoContinueStatus === 'needs_user' || candidate.analysis?.state === 'needs_user') return model('unresolved', {
110
118
  title: pair('必要な判断内容を確認します', 'Check what needs a decision'),
111
119
  question: pair('判断していただく内容をまだ特定できていません。具体的な内容が分かるまで、操作の許可は求めません。', 'The required decision has not been identified. No approval is requested until it is clear.'),