@awak-app/simy-cli 0.7.31 → 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.
- package/README.md +22 -3
- package/package.json +3 -2
- package/src/agent.js +7 -2
- package/src/auto-update.js +10 -1
- package/src/cli-contract.js +1 -1
- package/src/cli-observability.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +27 -3
- package/src/codex-plugins/simy/hooks/purpose-workspace.mjs +13 -4
- package/src/delivery-hooks/desktop.js +1 -1
- package/src/desktop-access.js +1 -1
- package/src/desktop-frame-writer.js +29 -0
- package/src/desktop-host.js +176 -74
- package/src/orchestrator/loop.js +2 -0
- package/src/session-supervision/analysis-manager.js +83 -18
- package/src/session-supervision/claude-desktop-lineage.js +1 -1
- package/src/session-supervision/claude-sessions.js +16 -4
- package/src/session-supervision/codex-app-server.js +39 -30
- package/src/session-supervision/continuation-readiness.js +58 -0
- package/src/session-supervision/decision-presentation.js +11 -3
- package/src/session-supervision/index.js +101 -24
- package/src/session-supervision/owner-transport.js +23 -8
- package/src/session-supervision/progressive-discovery.js +66 -0
- package/src/session-supervision/remote-entry.js +7 -4
- package/src/session-supervision/remote-host-recovery.js +21 -0
- package/src/session-supervision/remote-hosts.js +28 -8
- package/src/session-supervision/remote-owner-entry.js +20 -0
- package/src/session-supervision/remote-service.js +36 -0
- package/src/session-supervision/statistics.js +19 -3
- package/src/session-supervision/work-state.js +7 -1
- package/src/team-threads.js +3 -6
- package/src/update-connection.js +16 -0
- package/src/web-api.js +9 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
282
|
-
//
|
|
283
|
-
const
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.'),
|