@awak-app/simy-cli 0.7.10 → 0.7.11
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 +17 -1
- package/package.json +1 -1
- package/src/cli-contract.js +1 -1
- package/src/delivery-hooks/desktop.js +1 -1
- package/src/desktop-host.js +36 -7
- package/src/provider-stream.js +3 -0
- package/src/session-supervision/claude-desktop-lineage.js +19 -5
- package/src/session-supervision/claude-sessions.js +16 -10
- package/src/session-supervision/codex-app-server.js +8 -1
- package/src/session-supervision/index.js +25 -14
- package/src/session-supervision/pull-requests.js +52 -0
- package/src/session-supervision/work-state.js +84 -0
package/README.md
CHANGED
|
@@ -290,7 +290,23 @@ unavailable; a single observed activity event is a measured zero-duration sample
|
|
|
290
290
|
Missing usage records remain distinct from recorded zero tokens.
|
|
291
291
|
|
|
292
292
|
The desktop Sessions page loads a recent window of up to 200 conversations,
|
|
293
|
-
|
|
293
|
+
selected by last activity. The macOS work list then prioritizes verified running
|
|
294
|
+
conversations, including families with running children, followed by work needing
|
|
295
|
+
a human response and recent activity. Its All, Running, Needs attention, Completed
|
|
296
|
+
and Archived views combine with search. Tracking and auto-continue settings never
|
|
297
|
+
prove that an AI is running; observations older than 45 seconds or a failed
|
|
298
|
+
provider refresh display an unknown execution state.
|
|
299
|
+
|
|
300
|
+
List-only hiding supports multiple selection and restoration without changing
|
|
301
|
+
provider archive state or execution consent. Codex archive/restore is a separate
|
|
302
|
+
explicit operation; Claude archive/restore must be performed in Claude, and SIMY
|
|
303
|
+
reads its Desktop archive metadata on refresh. Related GitHub PR summaries are
|
|
304
|
+
read from structured provider metadata and refreshed through an installed,
|
|
305
|
+
authenticated `gh` CLI, with bounded reads, a one-minute cache, timestamps and
|
|
306
|
+
failure indicators. A merge alone never completes a conversation, and known
|
|
307
|
+
unfinished children or open PRs keep a completed task out of the Completed view.
|
|
308
|
+
|
|
309
|
+
Codex candidates come from CLI, VS Code and app-server
|
|
294
310
|
conversation sources; non-interactive exec, unknown sources and delegated agents
|
|
295
311
|
are excluded. A hook observation alone cannot restore a filtered conversation.
|
|
296
312
|
Explicitly tracked, monitored, auto-continue and unsettled supervision records
|
package/package.json
CHANGED
package/src/cli-contract.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "./durable-local-task-steps-contract.js";
|
|
14
14
|
import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
|
|
15
15
|
|
|
16
|
-
export const CLI_VERSION = "0.7.
|
|
16
|
+
export const CLI_VERSION = "0.7.11";
|
|
17
17
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
18
18
|
|
|
19
19
|
export function narrativeImageCapabilityMetadata() {
|
|
@@ -30,7 +30,7 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
|
|
|
30
30
|
})),
|
|
31
31
|
runs: records.filter(r => r.run && r.providerArchived !== true).sort((a, b) => b.run.updatedAt - a.run.updatedAt).slice(0, 30).map(r => ({
|
|
32
32
|
provider: r.provider, session: r.session, originalSessionTarget: originalSessionTarget(r.provider, r.session),
|
|
33
|
-
objective: r.run.objective, state: r.run.state, reason: r.run.reason, runId: r.run.id, revision: deliveryDecisionRevision(r.run), recoveryCause: r.run.recoveryCause, autoRecovery: r.run.managed?.autoRecovery === true,
|
|
33
|
+
objective: r.run.objective, state: r.run.state, reason: r.run.reason, runId: r.run.id, revision: deliveryDecisionRevision(r.run), recoveryCause: r.run.recoveryCause, stopReason: r.run.stopReason || null, autoRecovery: r.run.managed?.autoRecovery === true,
|
|
34
34
|
continuations: r.run.continuations,
|
|
35
35
|
continuationPolicy: r.run.managed?.continuationPolicy || 'bounded',
|
|
36
36
|
startedAt: r.run.startedAt,
|
package/src/desktop-host.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { sessionWorkState, compareSessionWork, reconcileFamilyCompletion, expireSessionWorkState } from './session-supervision/work-state.js';
|
|
2
|
+
import { createPullRequestObserver } from './session-supervision/pull-requests.js';
|
|
1
3
|
import { sessionDecisionPresentation } from './session-supervision/decision-presentation.js';
|
|
2
4
|
import { PROVIDER_LINKS, readProviderLinks, writeProviderLink, requireProviderLink } from "./provider-links.js";
|
|
3
5
|
import { randomUUID } from "node:crypto";
|
|
@@ -11,7 +13,7 @@ import { stopDesktopWorkForUpdate } from './desktop-update-stop.js';
|
|
|
11
13
|
import { desktopAccessReady, desktopActionAllowed, requireDesktopAccess, validDesktopAccessPath } from './desktop-access.js';
|
|
12
14
|
import { createActionDeadlineService } from './session-supervision/action-deadline.js';
|
|
13
15
|
import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
|
|
14
|
-
import { controlDelivery, verifyDelivery, createRecordReader, updateRecord, TERMINAL } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
|
|
16
|
+
import { controlDelivery, verifyDelivery, createRecordReader, updateRecord, recordProviderArchive, TERMINAL } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
|
|
15
17
|
import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
|
|
16
18
|
import { createActionAssociationHost } from "./session-supervision/action-association-host.js";
|
|
17
19
|
import { createStopNotifier } from "./session-supervision/stop-notifications.js";
|
|
@@ -101,7 +103,7 @@ function desktopActionError(action, error) {
|
|
|
101
103
|
if (action === "session_deadline_decide") return error?.code === "conflict"
|
|
102
104
|
? "Deadline changed; refresh before deciding." : "The deadline is unavailable. Refresh this conversation before deciding.";
|
|
103
105
|
if (typeof action === "string" && action.startsWith("session_")) {
|
|
104
|
-
return action.startsWith("session_action_") ? associationActionError(error) : sessionActionError(error);
|
|
106
|
+
return action.startsWith("session_action_") ? associationActionError(error) : sessionActionError(error, action);
|
|
105
107
|
}
|
|
106
108
|
if (["organization_select", "organization_cancel", "organizations_refresh"].includes(action)) {
|
|
107
109
|
return error?.code === "desktop_organization_busy"
|
|
@@ -411,6 +413,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
411
413
|
});
|
|
412
414
|
unfinishedDesktopWork = false;
|
|
413
415
|
})();
|
|
416
|
+
const observePullRequests = dependencies.observePullRequests || createPullRequestObserver();
|
|
414
417
|
const refreshSessions = (force = false) => {
|
|
415
418
|
if (discoveryStopped && !closed && !relay) return sessionStatus;
|
|
416
419
|
if (closed || relay || !desktopAccessReady(currentAccess)) return { sessions: [], providers: {}, refreshedAt: null };
|
|
@@ -428,7 +431,11 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
428
431
|
sessionRefreshAgain = desktopAccessReady(latestAccess);
|
|
429
432
|
return;
|
|
430
433
|
}
|
|
431
|
-
const sessions = linkedSessions(value.sessions);
|
|
434
|
+
const sessions = await observePullRequests(linkedSessions(value.sessions), { signal: controller.signal });
|
|
435
|
+
const afterObservation = await accessSnapshot();
|
|
436
|
+
if (controller.signal.aborted || !desktopAccessReady(afterObservation) || accessScope(afterObservation) !== requestedScope) {
|
|
437
|
+
await analysisManager.update([]); sessionRefreshAgain = desktopAccessReady(afterObservation); return;
|
|
438
|
+
}
|
|
432
439
|
await analysisManager.update(analysisSessions(sessions));
|
|
433
440
|
if (controller.signal.aborted) return;
|
|
434
441
|
sessionStatus = { ...value, sessions, initialLoading: false }; sessionRefreshedAt = Date.now(); })
|
|
@@ -626,7 +633,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
626
633
|
// Cached enrichment must follow the current connection preference even
|
|
627
634
|
// while a full projection is waiting on records or remote metadata.
|
|
628
635
|
status.deliveryHooks.runs = linkedSessions(status.deliveryHooks.runs || []);
|
|
629
|
-
status.sessionSupervision.sessions = linkedSessions(status.sessionSupervision.sessions || []);
|
|
636
|
+
status.sessionSupervision.sessions = linkedSessions(status.sessionSupervision.sessions || []).map(candidate => expireSessionWorkState(candidate)).sort(compareSessionWork);
|
|
630
637
|
status.sessionSupervision.providers ||= {};
|
|
631
638
|
for (const provider of PROVIDER_LINKS) {
|
|
632
639
|
if (!providerLinks[provider].connected) status.sessionSupervision.providers[provider] = { discovery: 'disconnected', reason: null };
|
|
@@ -705,6 +712,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
705
712
|
automaticContinuation?.tick(discovery.sessions);
|
|
706
713
|
const sessions = discovery.sessions.map(candidate => automaticContinuation.project(analysisManager.project(candidate)));
|
|
707
714
|
await actionAssociations.decorateSessions?.(sessions, projection);
|
|
715
|
+
const stateRunsBySession = new Map((projection?.records || records).filter(record => record.run).map(record => [`${record.provider}:${record.session}`, record.run]));
|
|
708
716
|
const runsBySession = new Map((status.deliveryHooks.runs || []).map(run => [`${run.provider}:${run.session}`, run]));
|
|
709
717
|
for (const session of sessions) {
|
|
710
718
|
const key = `${session.provider}:${session.session}`;
|
|
@@ -727,8 +735,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
727
735
|
const run = runsBySession.get(`${session.provider}:${session.session}`);
|
|
728
736
|
if (run) run.actionDeadline = session.actionDeadline;
|
|
729
737
|
}
|
|
730
|
-
status.sessionSupervision = { ...discovery, refreshing: Boolean(sessionRefresh), sessions: sessions.map(candidate => ({ ...candidate,
|
|
731
|
-
decisionRequest: sessionDecisionPresentation(candidate, runsBySession.get(`${candidate.provider}:${candidate.session}`))
|
|
738
|
+
status.sessionSupervision = { ...discovery, refreshing: Boolean(sessionRefresh), sessions: reconcileFamilyCompletion(sessions.map(candidate => ({ ...candidate,
|
|
739
|
+
decisionRequest: sessionDecisionPresentation(candidate, runsBySession.get(`${candidate.provider}:${candidate.session}`)),
|
|
740
|
+
workState: sessionWorkState(candidate, runsBySession.get(`${candidate.provider}:${candidate.session}`) || stateRunsBySession.get(`${candidate.provider}:${candidate.session}`), { provider: discovery.providers?.[candidate.provider] }) }))).sort(compareSessionWork) };
|
|
732
741
|
const latestAccess = await accessSnapshot();
|
|
733
742
|
if (JSON.stringify(await readProviderLinks(root)) !== requestedLinks ||
|
|
734
743
|
!desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope ||
|
|
@@ -1339,6 +1348,25 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
1339
1348
|
await authorizeWork(commandScope);
|
|
1340
1349
|
const result = await deadlines.decide(context, { ...command, requestId: command.requestId || command.decisionId });
|
|
1341
1350
|
if (result.status === 'conflict') throw Object.assign(new Error('Deadline changed; refresh before deciding.'), { code: 'conflict' });
|
|
1351
|
+
} else if (action === "session_archive" || action === "session_restore") {
|
|
1352
|
+
const candidate = await selectedSession(command, { preferObserved: true });
|
|
1353
|
+
await authorizeWork(commandScope);
|
|
1354
|
+
if (!['local', 'provider'].includes(command.scope)) throw new Error('Choose local or provider archive explicitly.');
|
|
1355
|
+
const archived = action === 'session_archive';
|
|
1356
|
+
if (command.scope === 'provider') {
|
|
1357
|
+
if (command.provider !== 'codex') throw new Error('Archive or restore this conversation in Claude, then refresh SIMY.');
|
|
1358
|
+
const client = await (dependencies.openCodexAppServer || openCodexAppServer)();
|
|
1359
|
+
try { await client.request(archived ? 'thread/archive' : 'thread/unarchive', { threadId: command.session });
|
|
1360
|
+
if (archived) await recordProviderArchive(deliveryRoot, command.provider, command.session, true, Date.now());
|
|
1361
|
+
}
|
|
1362
|
+
finally { await client.close(); }
|
|
1363
|
+
} else {
|
|
1364
|
+
await updateRecord(deliveryRoot, command.provider, command.session, previous => ({
|
|
1365
|
+
...(previous || { schemaVersion: 1, provider: command.provider, session: command.session, cwd: candidate.cwd, observedAt: Date.now() }),
|
|
1366
|
+
listHidden: archived,
|
|
1367
|
+
}));
|
|
1368
|
+
}
|
|
1369
|
+
refreshSessions(true);
|
|
1342
1370
|
} else if (action === "session_track" || action === "session_untrack") {
|
|
1343
1371
|
const candidate = await selectedSession(command);
|
|
1344
1372
|
if (candidate.capabilities?.canMonitor !== true) throw new Error('Tracking is unavailable for this conversation.');
|
|
@@ -1439,7 +1467,7 @@ function associationActionError(error) {
|
|
|
1439
1467
|
return 'The Action service is unavailable. Your conversation continues with its existing settings.';
|
|
1440
1468
|
}
|
|
1441
1469
|
|
|
1442
|
-
function sessionActionError(error) {
|
|
1470
|
+
function sessionActionError(error, action) {
|
|
1443
1471
|
if (error instanceof AutoContinueUnavailableError) return error.message.replace(/[\r\n\x00-\x1f]/g, ' ').slice(0, 600);
|
|
1444
1472
|
const message = String(error?.message || "");
|
|
1445
1473
|
if (/already has a Delivery Loop|budgets cannot be reset/i.test(message)) return "This conversation already has a task. Open its existing supervision instead.";
|
|
@@ -1447,6 +1475,7 @@ function sessionActionError(error) {
|
|
|
1447
1475
|
if (/workspace changed|metadata.*unavailable|session.*changed/i.test(message)) return "The conversation identity or workspace changed. Refresh and select it again.";
|
|
1448
1476
|
if (/objective|acceptance|autonomy policy|budget/i.test(message)) return message.replace(/[\r\n\x00-\x1f]/g, " ").slice(0, 240);
|
|
1449
1477
|
if (/question changed|answer is empty|session understanding|context/i.test(message)) return message.replace(/[\r\n\x00-\x1f]/g, " ").slice(0, 240);
|
|
1478
|
+
if (action === "session_answer") return "Answer acceptance is unconfirmed. Check the original conversation before sending again.";
|
|
1450
1479
|
return "This conversation could not be enrolled. Refresh its provider status and try again.";
|
|
1451
1480
|
}
|
|
1452
1481
|
|
package/src/provider-stream.js
CHANGED
|
@@ -206,6 +206,9 @@ function formatClaudeToolResults(prefix, content) {
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
function contentLines(prefix, label, value) {
|
|
209
|
+
// Native providers wrap machine results in assistant events too. Keep the
|
|
210
|
+
// result available to the parser without flooding the user transcript.
|
|
211
|
+
if (label === "assistant" && STRUCTURED_MARKERS.some(marker => String(value || "").trimStart().startsWith(marker))) return [];
|
|
209
212
|
return String(value || "")
|
|
210
213
|
.split(/\r?\n/)
|
|
211
214
|
.map((line) => cleanLine(line))
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizePullRequests } from './pull-requests.js';
|
|
1
2
|
import { constants } from 'node:fs';
|
|
2
3
|
import { lstat, open, readdir } from 'node:fs/promises';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
@@ -70,12 +71,14 @@ async function metadata(path, desktopId, sessions) {
|
|
|
70
71
|
let value = null;
|
|
71
72
|
if (item.sessionId === desktopId && UUID.test(item.cliSessionId || '') && typeof item.cwd === 'string' && isAbsolute(item.cwd)) {
|
|
72
73
|
value = { desktopId, session: item.cliSessionId, cwd: resolve(item.cwd),
|
|
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)) } : {}),
|
|
73
76
|
parentDesktopId: complete && LOCAL.test(item.spawnedFrom?.sessionId || '') ? item.spawnedFrom.sessionId : null };
|
|
74
77
|
}
|
|
75
78
|
const after = await lstat(path, { bigint: true });
|
|
76
79
|
if (fingerprint(after) !== key || fingerprint(await handle.stat({ bigint: true })) !== key || after.isSymbolicLink()) return null;
|
|
77
80
|
if (!complete && !value) return null;
|
|
78
|
-
// Cache only identity
|
|
81
|
+
// Cache only identity, lineage, archive and PR summaries. Desktop files also contain private settings;
|
|
79
82
|
// none of those fields or conversation text leave this bounded reader.
|
|
80
83
|
cache.delete(path); cache.set(path, { key, value, complete });
|
|
81
84
|
while (cache.size > MAX_FILES) cache.delete(cache.keys().next().value);
|
|
@@ -86,7 +89,7 @@ async function metadata(path, desktopId, sessions) {
|
|
|
86
89
|
|
|
87
90
|
// Desktop spawn parents use local_* IDs, whereas transcripts and SIMY use CLI
|
|
88
91
|
// UUIDs. Resolve them within the same account AND organization partition.
|
|
89
|
-
export async function readClaudeDesktopLineage({ home = homedir(), root = claudeDesktopSessionRoot(home), signal, sessions = null } = {}) {
|
|
92
|
+
export async function readClaudeDesktopLineage({ home = homedir(), root = claudeDesktopSessionRoot(home), signal, sessions = null, includeState = false } = {}) {
|
|
90
93
|
if (signal?.aborted) return new Map();
|
|
91
94
|
const files = [];
|
|
92
95
|
for (const account of await directories(root)) {
|
|
@@ -122,12 +125,23 @@ export async function readClaudeDesktopLineage({ home = homedir(), root = claude
|
|
|
122
125
|
parentSession: { provider: 'claude', session: parent.session },
|
|
123
126
|
source: 'claude_desktop_spawned_from' });
|
|
124
127
|
}
|
|
128
|
+
if (includeState) for (const partition of partitions.values()) for (const item of partition.values()) {
|
|
129
|
+
if (claims.get(item.session) !== 1 || sessions && !sessions.has(item.session)) continue;
|
|
130
|
+
lineage.set(item.session, { cwd: item.cwd, ...lineage.get(item.session),
|
|
131
|
+
providerArchived: item.providerArchived, pullRequests: item.pullRequests || [], observedAt: Date.now() });
|
|
132
|
+
}
|
|
125
133
|
// Reject cyclic Desktop metadata rather than inventing a root or parent.
|
|
126
134
|
for (const session of [...lineage.keys()]) {
|
|
127
135
|
const seen = new Set(); let cursor = session;
|
|
128
136
|
while (lineage.has(cursor)) {
|
|
129
|
-
if (seen.has(cursor)) {
|
|
130
|
-
|
|
137
|
+
if (seen.has(cursor)) {
|
|
138
|
+
for (const id of seen) {
|
|
139
|
+
if (!includeState) lineage.delete(id);
|
|
140
|
+
else { const { parentSession, parentCwd, source, ...state } = lineage.get(id); lineage.set(id, state); }
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
seen.add(cursor); cursor = lineage.get(cursor).parentSession?.session;
|
|
131
145
|
}
|
|
132
146
|
}
|
|
133
147
|
return lineage;
|
|
@@ -138,7 +152,7 @@ export async function readClaudeDesktopLineage({ home = homedir(), root = claude
|
|
|
138
152
|
// discovery and unrelated provider controls must not wait for the entire scan.
|
|
139
153
|
export async function observeClaudeDesktopLineage(options = {}, { read = readClaudeDesktopLineage, budgetMs = 1500 } = {}) {
|
|
140
154
|
if (options.signal?.aborted) return new Map();
|
|
141
|
-
const root = options.root || claudeDesktopSessionRoot(options.home);
|
|
155
|
+
const root = (options.root || claudeDesktopSessionRoot(options.home)) + (options.includeState ? ':state' : ':lineage');
|
|
142
156
|
let entry = observations.get(root);
|
|
143
157
|
if (!entry) {
|
|
144
158
|
entry = { value: new Map(), pending: null };
|
|
@@ -6,7 +6,7 @@ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
|
6
6
|
import { boundConversation, CONVERSATION_LIMITS, textContent } from './conversation-context.js';
|
|
7
7
|
import { sessionTitle } from './session-titles.js';
|
|
8
8
|
import { RECENT_SESSION_LIMIT, recentSessionWindow } from './discovery-window.js';
|
|
9
|
-
import { observeClaudeDesktopLineage } from './claude-desktop-lineage.js';
|
|
9
|
+
import { observeClaudeDesktopLineage, readClaudeDesktopLineage } from './claude-desktop-lineage.js';
|
|
10
10
|
|
|
11
11
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
12
12
|
const MAX_PROJECTS = 256;
|
|
@@ -186,17 +186,19 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
|
|
|
186
186
|
}));
|
|
187
187
|
}
|
|
188
188
|
if (signal?.aborted) return [];
|
|
189
|
-
const
|
|
190
|
-
|
|
189
|
+
const desktopOptions = { home, signal, includeState: true, sessions: new Set(descriptors.map(item => item.session)) };
|
|
190
|
+
const desktopLineage = await (session ? readClaudeDesktopLineage(desktopOptions) : observeClaudeDesktopLineage(desktopOptions));
|
|
191
191
|
const decorateLineage = candidate => {
|
|
192
192
|
const origin = desktopLineage.get(candidate.session);
|
|
193
|
-
if (origin?.cwd === candidate.cwd
|
|
194
|
-
return { ...candidate,
|
|
193
|
+
if (origin?.cwd === candidate.cwd)
|
|
194
|
+
return { ...candidate, ...(typeof origin.providerArchived === 'boolean' ? { archived: origin.providerArchived } : {}), pullRequests: origin.pullRequests || [],
|
|
195
|
+
...(!candidate.parentSession && origin.parentSession ? { parentSession: origin.parentSession, parentSessionSource: origin.source } : {}) };
|
|
195
196
|
return candidate;
|
|
196
197
|
};
|
|
197
|
-
const selected = session ? descriptors :
|
|
198
|
-
limit: maximum, priority: item => priorities.has(item.session),
|
|
199
|
-
|
|
198
|
+
const selected = session ? descriptors : [
|
|
199
|
+
...recentSessionWindow(descriptors.filter(item => desktopLineage.get(item.session)?.providerArchived !== true), { limit: maximum, priority: item => priorities.has(item.session) }),
|
|
200
|
+
...recentSessionWindow(descriptors.filter(item => desktopLineage.get(item.session)?.providerArchived === true), { limit: maximum, priority: item => priorities.has(item.session) }),
|
|
201
|
+
];
|
|
200
202
|
const filesToInspect = selected.slice(0, MAX_TRANSCRIPTS);
|
|
201
203
|
let nextFile = 0;
|
|
202
204
|
// Catalog prefixes are bounded independently of full transcript size. Use a
|
|
@@ -218,7 +220,7 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
|
|
|
218
220
|
const attempted = new Set(result.keys());
|
|
219
221
|
for (const child of result.values()) {
|
|
220
222
|
const origin = desktopLineage.get(child.session);
|
|
221
|
-
if (!origin || child.parentSessionSource !== origin.source || [...result.values()].some(item => item.session === origin.parentSession.session && item.cwd === origin.parentCwd)) continue;
|
|
223
|
+
if (!origin?.parentSession || child.parentSessionSource !== origin.source || [...result.values()].some(item => item.session === origin.parentSession.session && item.cwd === origin.parentCwd)) continue;
|
|
222
224
|
for (const file of descriptors.filter(item => item.session === origin.parentSession.session)) {
|
|
223
225
|
const key = `${file.session}\0${origin.parentCwd}`;
|
|
224
226
|
if (attempted.has(key) || attempted.size >= MAX_TRANSCRIPTS || signal?.aborted) break;
|
|
@@ -243,7 +245,10 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
|
|
|
243
245
|
}
|
|
244
246
|
}
|
|
245
247
|
const sorted = [...result.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt || a.session.localeCompare(b.session));
|
|
246
|
-
return session ? sorted :
|
|
248
|
+
return session ? sorted : [
|
|
249
|
+
...recentSessionWindow(sorted.filter(item => item.archived !== true), { limit: maximum, priority: item => priorities.has(item.session) }),
|
|
250
|
+
...recentSessionWindow(sorted.filter(item => item.archived === true), { limit: maximum, priority: item => priorities.has(item.session) }),
|
|
251
|
+
];
|
|
247
252
|
}
|
|
248
253
|
|
|
249
254
|
export async function listClaudeSessions({ home, limit, prioritySessions, signal } = {}) {
|
|
@@ -256,6 +261,7 @@ export async function readClaudeSession(session, { home } = {}) {
|
|
|
256
261
|
const matches = await collect({ home, limit: RECENT_SESSION_LIMIT, session: id });
|
|
257
262
|
const selected = typeof session === 'object' && typeof session.cwd === 'string'
|
|
258
263
|
? matches.filter(item => item.cwd === session.cwd) : matches;
|
|
264
|
+
if (selected.length === 1 && selected[0].archived === true) throw Object.assign(new Error('Restore the original provider conversation before continuing.'), { code: 'session_archived' });
|
|
259
265
|
return selected.length === 1 ? selected[0] : null;
|
|
260
266
|
}
|
|
261
267
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizePullRequests } from './pull-requests.js';
|
|
1
2
|
import { delegatedNativeSandbox, canWriteWorkspace, authorizedWorktreeGitRoots } from './native-delegation.js';
|
|
2
3
|
import { flexibleReasoning } from '../codex-plugins/simy/hooks/purpose-cycle.mjs';
|
|
3
4
|
import { collectPurposeObservations, refreshPurposeObservations, repositoryIdentity } from './purpose-observer.js';
|
|
@@ -202,13 +203,19 @@ export function codexThreadMetadata(thread) {
|
|
|
202
203
|
if (thread.parentThreadId || thread.source?.subAgent || thread.source?.subagent) return null;
|
|
203
204
|
return {
|
|
204
205
|
provider: 'codex', session: thread.id, cwd: thread.cwd,
|
|
206
|
+
pullRequests: normalizePullRequests(thread.pullRequests || thread.artifacts?.filter?.(item => item.type === 'pull_request')),
|
|
205
207
|
...(typeof thread.forkedFromId === 'string' && /^[0-9a-f-]{36}$/i.test(thread.forkedFromId) && thread.forkedFromId !== thread.id
|
|
206
208
|
? { parentSession: { provider: 'codex', session: thread.forkedFromId } } : {}),
|
|
207
209
|
title: typeof thread.name === 'string' && thread.name.trim() ? thread.name.slice(0, 200) : '',
|
|
208
210
|
source: typeof thread.source === 'string' ? thread.source : thread.source?.type || 'unknown',
|
|
209
211
|
lastActivityAt: Number.isFinite(thread.updatedAt) ? thread.updatedAt * 1000 : null,
|
|
210
212
|
model: typeof thread.model === 'string' ? thread.model : null,
|
|
211
|
-
|
|
213
|
+
// Preserve native wait flags for presentation without changing isRunning,
|
|
214
|
+
// which also protects the provider writer lock. Unknown flags fail closed.
|
|
215
|
+
observation: { source: 'codex_app_server', observedAt: Date.now(),
|
|
216
|
+
...(thread.status?.type === 'active' ? { activeFlags: Array.isArray(thread.status.activeFlags)
|
|
217
|
+
&& thread.status.activeFlags.every(flag => ['waitingOnApproval', 'waitingOnUserInput'].includes(flag))
|
|
218
|
+
? [...new Set(thread.status.activeFlags)] : null } : {}) },
|
|
212
219
|
...(thread.status?.type === 'active' || thread.status?.type === 'idle' ? { isRunning: thread.status.type === 'active' } : {}),
|
|
213
220
|
availability: 'unknown',
|
|
214
221
|
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { sessionWorkState, compareSessionWork } from './work-state.js';
|
|
2
|
+
import { normalizePullRequests } from './pull-requests.js';
|
|
1
3
|
import { completionClaimUnbound } from '../codex-plugins/simy/hooks/completion-binding.mjs';
|
|
2
4
|
import { pauseOrigin } from './autonomous-upgrade.js';
|
|
3
5
|
import { effectiveAcceptanceCriteria } from '../codex-plugins/simy/hooks/acceptance-policy.mjs';
|
|
@@ -123,7 +125,8 @@ function navigationParent(item, record) {
|
|
|
123
125
|
}
|
|
124
126
|
|
|
125
127
|
export async function discoverSessions({ root, openClient = openCodexAppServer, listClaude = listClaudeSessions,
|
|
126
|
-
inspectProcess = processIdentity, now
|
|
128
|
+
inspectProcess = processIdentity, now: requestedNow, signal, listCodexTitles = readCodexTitles, listCodexChildren = readCodexChildSessions, codexHome, connectedProviders = ['codex', 'claude'], includeArchived = false } = {}) {
|
|
129
|
+
const now = requestedNow ?? Date.now();
|
|
127
130
|
const assertActive = () => { if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation discovery was cancelled.'); };
|
|
128
131
|
assertActive();
|
|
129
132
|
const connected = new Set(connectedProviders);
|
|
@@ -174,11 +177,13 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
174
177
|
for (let index = 0; index < records.length; index++) {
|
|
175
178
|
assertActive();
|
|
176
179
|
const record = records[index];
|
|
177
|
-
|
|
178
|
-
const
|
|
180
|
+
const claudeItem = record.provider === 'claude' ? claude.find(item => item.session === record.session && item.cwd === record.cwd) : null;
|
|
181
|
+
const archiveState = record.provider === 'codex' ? archiveStates.get(record.session) ?? (activeCodex.has(record.session) ? false : undefined) : claudeItem?.archived;
|
|
182
|
+
if (typeof archiveState !== 'boolean') continue;
|
|
183
|
+
const isArchived = archiveState;
|
|
179
184
|
if (isArchived) await recordProviderArchive(root, record.provider, record.session, true, now);
|
|
180
185
|
if (isArchived && record.providerArchived === true) continue;
|
|
181
|
-
if (!isArchived && !(record.providerArchived === true &&
|
|
186
|
+
if (!isArchived && !(record.providerArchived === true && archiveState === false)) continue;
|
|
182
187
|
try { records[index] = await updateRecord(root, record.provider, record.session, current => {
|
|
183
188
|
if (!current) return current;
|
|
184
189
|
current.providerArchived = isArchived;
|
|
@@ -204,7 +209,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
204
209
|
const recordsByKey = new Map(records.map(record => [`${record.provider}:${record.session}`, record]));
|
|
205
210
|
for (const item of [...discovered, ...claude, ...archivedCodex]) {
|
|
206
211
|
if (includeArchived && item.archived === true) { byKey.set(`${item.provider}:${item.session}`, item); continue; }
|
|
207
|
-
if ((item.provider !== 'codex' || archiveStates.get(item.session) !== true) && recordsByKey.get(`${item.provider}:${item.session}`)?.providerArchived !== true) byKey.set(`${item.provider}:${item.session}`, item);
|
|
212
|
+
if (item.archived !== true && (item.provider !== 'codex' || archiveStates.get(item.session) !== true) && recordsByKey.get(`${item.provider}:${item.session}`)?.providerArchived !== true) byKey.set(`${item.provider}:${item.session}`, item);
|
|
208
213
|
}
|
|
209
214
|
const requiredAncestors = new Set();
|
|
210
215
|
for (const item of byKey.values()) {
|
|
@@ -224,7 +229,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
224
229
|
const key = `${record.provider}:${record.session}`;
|
|
225
230
|
// A hook observation can belong to an AI-created worker. Only explicit
|
|
226
231
|
// supervision may restore a conversation absent from provider discovery.
|
|
227
|
-
if (!byKey.has(key) && (retainedSession(record) || requiredAncestors.has(key) && (record.observedAt || record.admission?.validatedAt))) byKey.set(key, {
|
|
232
|
+
if (!byKey.has(key) && (retainedSession(record) || includeArchived && record.listHidden === true || requiredAncestors.has(key) && (record.observedAt || record.admission?.validatedAt))) byKey.set(key, {
|
|
228
233
|
provider: record.provider, session: record.session, cwd: record.cwd, title: '', archived, source: record.admission?.source || 'hook',
|
|
229
234
|
lastActivityAt: record.observedAt || record.admission?.validatedAt || record.run?.updatedAt || null,
|
|
230
235
|
observation: { source: record.admission?.source || `${record.provider}_hook`, observedAt: record.observedAt || record.admission?.validatedAt || null },
|
|
@@ -236,11 +241,11 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
236
241
|
const catalog = [...byKey.values()].map(item => {
|
|
237
242
|
const record = recordsByKey.get(`${item.provider}:${item.session}`);
|
|
238
243
|
const parentSession = navigationParent(item, record);
|
|
239
|
-
return { ...item, ...(parentSession ? { parentSession } : {}) };
|
|
244
|
+
return { ...item, localHidden: record?.listHidden === true, ...(parentSession ? { parentSession } : {}) };
|
|
240
245
|
});
|
|
241
|
-
const items = [...recentSessionWindow(catalog.filter(item => item.archived !== true), {
|
|
246
|
+
const items = [...recentSessionWindow(catalog.filter(item => item.archived !== true && !item.localHidden), {
|
|
242
247
|
priority: item => retainedSession(recordsByKey.get(`${item.provider}:${item.session}`)),
|
|
243
|
-
}), ...recentSessionWindow(catalog.filter(item => item.archived === true))];
|
|
248
|
+
}), ...recentSessionWindow(catalog.filter(item => item.archived === true || item.localHidden), { priority: item => item.localHidden })];
|
|
244
249
|
// Include archived ancestors of active rows even when archive visibility is off.
|
|
245
250
|
const catalogByKey = new Map(catalog.map(item => [`${item.provider}:${item.session}`, item]));
|
|
246
251
|
const includedKeys = new Set(items.map(item => `${item.provider}:${item.session}`));
|
|
@@ -295,14 +300,14 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
295
300
|
const continuationIssue = liveHook && run ? !run.managed && !upgradeable
|
|
296
301
|
? { code: 'existing_loop', reason: 'This conversation already has a Delivery Loop. Use its existing controls.' }
|
|
297
302
|
: TERMINAL_RUNS.has(run.state) ? { code: 'task_settled', reason: 'This supervised task has settled. Review its existing status before continuing.' } : null : null;
|
|
298
|
-
let isRunning = item.isRunning;
|
|
303
|
+
let isRunning = item.isRunning, observation = item.observation;
|
|
299
304
|
const hookIsNewer = Number.isFinite(record?.runtime?.observedAt) && Number.isFinite(item.lastActivityAt) && record.runtime.observedAt > item.lastActivityAt;
|
|
300
305
|
if ((isRunning === undefined || isRunning === false && hookIsNewer) && record?.runtime?.process?.identity && record.cwd === item.cwd) {
|
|
301
306
|
try {
|
|
302
307
|
// A hook newer than the persisted provider activity may describe a
|
|
303
308
|
// new turn. Require its same live process; older hooks cannot override
|
|
304
309
|
// a more recent completed provider turn.
|
|
305
|
-
if (record.runtime.isRunning && await inspectProcess(record.runtime.process.pid) === record.runtime.process.identity) isRunning = true;
|
|
310
|
+
if (record.runtime.isRunning && await inspectProcess(record.runtime.process.pid) === record.runtime.process.identity) { isRunning = true; observation = { ...item.observation, observedAt: record.runtime.observedAt }; }
|
|
306
311
|
else if (isRunning === undefined && !record.runtime.isRunning) isRunning = false;
|
|
307
312
|
} catch { /* Unverified process leaves runtime status unknown. */ }
|
|
308
313
|
}
|
|
@@ -314,12 +319,14 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
314
319
|
...(item.providerGoal !== undefined ? { providerGoal: item.providerGoal } : {}),
|
|
315
320
|
...(item.goalReadState ? { goalReadState: item.goalReadState } : {}),
|
|
316
321
|
tracked: typeof record?.tracked === 'boolean' ? record.tracked : record?.monitor === true,
|
|
322
|
+
providerArchived: archived, localHidden: record?.listHidden === true,
|
|
317
323
|
archived,
|
|
324
|
+
pullRequests: normalizePullRequests(item.pullRequests),
|
|
318
325
|
autoContinue: !archived && record?.autoContinue === true,
|
|
319
326
|
autoContinueEpoch: record?.autoContinue === true ? record.autoContinueEpoch : null,
|
|
320
327
|
autoContinueAvailability: availability,
|
|
321
328
|
...autoContinueState(record, isRunning, availability),
|
|
322
|
-
lastActivityAt: item.lastActivityAt, observation
|
|
329
|
+
lastActivityAt: item.lastActivityAt, observation, availability: item.availability,
|
|
323
330
|
taskId: run?.managed?.taskId || record?.associationTaskId || null,
|
|
324
331
|
binding: { mode, taskId: run?.managed?.taskId || record?.associationTaskId || null },
|
|
325
332
|
originalSessionTarget: originalSessionTarget(item.provider, item.session),
|
|
@@ -343,8 +350,12 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
343
350
|
}
|
|
344
351
|
}));
|
|
345
352
|
assertActive();
|
|
346
|
-
|
|
347
|
-
|
|
353
|
+
// Provider reads stamp observations after discovery starts. Compare them at
|
|
354
|
+
// projection time, while preserving an explicitly supplied verification clock.
|
|
355
|
+
const stateNow = requestedNow ?? Date.now();
|
|
356
|
+
for (const candidate of projected) candidate.workState = sessionWorkState(candidate, recordsByKey.get(`${candidate.provider}:${candidate.session}`)?.run, { now: stateNow });
|
|
357
|
+
projected.sort(compareSessionWork);
|
|
358
|
+
return { sessions: includeArchived ? projected : projected.filter(item => !item.archived && !item.localHidden), providers: { codex: { discovery: !connected.has('codex') ? 'disconnected' : codexError ? 'unavailable' : 'ready', reason: codexError }, claude: { discovery: !connected.has('claude') ? 'disconnected' : claudeError ? 'unavailable' : 'local_index', reason: claudeError } }, refreshedAt: now };
|
|
348
359
|
}
|
|
349
360
|
|
|
350
361
|
// Saving consent needs an exact admitted identity, not another provider RPC.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const execute = promisify(execFile);
|
|
4
|
+
const PR_URL = /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)\/?$/;
|
|
5
|
+
export function normalizePullRequests(value) {
|
|
6
|
+
const rows = Array.isArray(value) ? value : value ? [value] : [];
|
|
7
|
+
const unique = new Map();
|
|
8
|
+
for (const row of rows.slice(0, 32)) {
|
|
9
|
+
const url = typeof row === 'string' ? row : row?.url;
|
|
10
|
+
if (!PR_URL.test(url || '')) continue;
|
|
11
|
+
const state = String(row.state || row.status || '').toUpperCase();
|
|
12
|
+
unique.set(url, { url, state: row.merged === true || row.mergedAt ? 'MERGED' : ['OPEN', 'CLOSED', 'MERGED'].includes(state) ? state : 'UNKNOWN',
|
|
13
|
+
reviewDecision: ['CHANGES_REQUESTED', 'APPROVED', 'REVIEW_REQUIRED'].includes(row.reviewDecision) ? row.reviewDecision : null,
|
|
14
|
+
checkedAt: Number.isFinite(row.checkedAt) ? row.checkedAt : null });
|
|
15
|
+
}
|
|
16
|
+
return [...unique.values()];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// One cache per host, bounded concurrency, no network work on the 3s status tick.
|
|
20
|
+
export function createPullRequestObserver({ read = async url => {
|
|
21
|
+
const match = PR_URL.exec(url);
|
|
22
|
+
const { stdout } = await execute('gh', ['pr', 'view', match[3], '--repo', `${match[1]}/${match[2]}`, '--json', 'url,state,reviewDecision,mergedAt'], { timeout: 2000, maxBuffer: 64 * 1024, windowsHide: true });
|
|
23
|
+
return JSON.parse(stdout);
|
|
24
|
+
}, now = Date.now, ttlMs = 60_000, budgetMs = 3500 } = {}) {
|
|
25
|
+
const cache = new Map();
|
|
26
|
+
let cursor = 0;
|
|
27
|
+
return async (sessions, { signal } = {}) => {
|
|
28
|
+
const catalog = [...new Set(sessions.flatMap(item => (item.pullRequests || []).map(pr => pr.url)))];
|
|
29
|
+
// Continue after the last attempted URL, including when slow reads consume
|
|
30
|
+
// the budget. Restarting at row zero can starve the tail indefinitely.
|
|
31
|
+
const start = catalog.length ? cursor % catalog.length : 0;
|
|
32
|
+
const urls = [...catalog.slice(start), ...catalog.slice(0, start)].slice(0, 128);
|
|
33
|
+
let next = 0; const started = now();
|
|
34
|
+
await Promise.all(Array.from({ length: Math.min(4, urls.length) }, async () => {
|
|
35
|
+
while (next < urls.length && !signal?.aborted && now() - started < budgetMs) {
|
|
36
|
+
const url = urls[next++], previous = cache.get(url);
|
|
37
|
+
if (previous && now() - previous.at < ttlMs) continue;
|
|
38
|
+
try { const rows = normalizePullRequests(await read(url));
|
|
39
|
+
if (rows[0]?.url !== url) throw new Error('PR identity mismatch');
|
|
40
|
+
cache.set(url, { at: now(), value: { ...rows[0], checkedAt: now(), fetchFailed: false } });
|
|
41
|
+
} catch { cache.set(url, { at: now(), value: { ...(previous?.value || { url, state: 'UNKNOWN' }), fetchFailed: true } }); }
|
|
42
|
+
}
|
|
43
|
+
}));
|
|
44
|
+
cursor = catalog.length ? (start + next) % catalog.length : 0;
|
|
45
|
+
while (cache.size > 512) cache.delete(cache.keys().next().value);
|
|
46
|
+
return sessions.map(item => ({ ...item, pullRequests: (item.pullRequests || []).map(pr => {
|
|
47
|
+
const observed = cache.get(pr.url)?.value;
|
|
48
|
+
const value = observed ? { ...pr, ...observed, ...(observed.state === 'UNKNOWN' ? { state: pr.state } : {}) } : pr;
|
|
49
|
+
return { ...value, stale: !Number.isFinite(value.checkedAt) || now() - value.checkedAt > ttlMs };
|
|
50
|
+
}) }));
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Display-only state. Tracking/auto-continue consent is never evidence of execution.
|
|
2
|
+
export const WORK_STATE_TTL_MS = 45_000;
|
|
3
|
+
const freshObservation = (checkedAt, now) => Number.isFinite(checkedAt) && checkedAt <= now && now - checkedAt <= WORK_STATE_TTL_MS;
|
|
4
|
+
export function sessionWorkState(candidate, run, { now = Date.now(), provider } = {}) {
|
|
5
|
+
const checkedAt = candidate.observation?.observedAt ?? null;
|
|
6
|
+
const stale = !freshObservation(checkedAt, now);
|
|
7
|
+
const fetchFailed = provider?.discovery === 'unavailable';
|
|
8
|
+
const freshness = { checkedAt, stale, fetchFailed };
|
|
9
|
+
const result = (state, label, reason, nextAction = null, needsAttention = false) =>
|
|
10
|
+
({ state, label, reason, nextAction, needsAttention, ...freshness });
|
|
11
|
+
if (candidate.archived || candidate.localHidden) return result('archived', 'アーカイブ済み', candidate.localHidden ? 'この一覧だけで非表示にしています。' : '元の会話がアーカイブされています。', 'restore');
|
|
12
|
+
let nativeRuntimeUnknown = false;
|
|
13
|
+
if (Object.hasOwn(candidate.observation || {}, 'activeFlags')) {
|
|
14
|
+
const flags = candidate.observation.activeFlags;
|
|
15
|
+
nativeRuntimeUnknown = stale || fetchFailed || !Array.isArray(flags) || flags.some(flag => !['waitingOnApproval', 'waitingOnUserInput'].includes(flag));
|
|
16
|
+
if (!nativeRuntimeUnknown && flags.includes('waitingOnApproval')) return result('approval', '承認待ち', '元のAIが操作の承認を待っています。', 'review_approval', true);
|
|
17
|
+
if (!nativeRuntimeUnknown && flags.includes('waitingOnUserInput')) return result('question', '回答待ち', '元のAIが回答を待っています。', 'answer', true);
|
|
18
|
+
}
|
|
19
|
+
// Never display a cached active flag as a live observation after a sleep/outage.
|
|
20
|
+
if (!nativeRuntimeUnknown && !stale && !fetchFailed && candidate.isRunning === true) return result('running', 'AIが対応中', '元のAIが実行中です。');
|
|
21
|
+
const approval = run?.pendingApproval || run?.managed?.pendingApproval;
|
|
22
|
+
const question = run?.understandingQuestion || run?.managed?.understandingQuestion || run?.managed?.userQuestion;
|
|
23
|
+
const phase = run?.phase || run?.managed?.phase;
|
|
24
|
+
const stopped = run?.state === 'stopped' || run?.stopReason?.actor === 'user' ||
|
|
25
|
+
run?.state === 'paused' && /^(Paused by user\.|Automatic continuation paused by user\.|User interrupted the provider\.|Stopped by user\.)$/.test(run.reason || '');
|
|
26
|
+
if (stopped) return result('stopped', '自分で停止', '停止の指示を維持しています。', 'resume');
|
|
27
|
+
if (approval && !approval.denied || candidate.decisionRequest?.kind === 'approval')
|
|
28
|
+
return result('approval', '承認待ち', candidate.decisionRequest?.context || '実行する操作の確認が必要です。', 'review_approval', true);
|
|
29
|
+
if (question || phase === 'needs_clarification' || candidate.analysis?.state === 'needs_user' || candidate.decisionRequest?.kind === 'question')
|
|
30
|
+
return result('question', '回答待ち', candidate.decisionRequest?.question || '続けるために回答が必要です。', 'answer', true);
|
|
31
|
+
if ((run?.autoRecovery || run?.managed?.autoRecovery) && ['recovery_required', 'auto_resuming'].includes(run.state)) return result('checking', 'AIが状況確認中', '自動復旧の結果を待っています。');
|
|
32
|
+
if (['failed', 'recovery_required', 'budget_exhausted'].includes(run?.state) || candidate.analysis?.state === 'failed' || candidate.autoContinueStatus === 'error')
|
|
33
|
+
return result('error', '対応が必要', run?.reason || '作業が中断されています。', run?.canResume || run?.canReplan ? 'resume' : 'open_original', true);
|
|
34
|
+
if (run?.dependencyWait || run?.managed?.dependencyWait) return result('waiting', '依存条件待ち', '条件が変わるまで待機します。');
|
|
35
|
+
if (['blocked', 'verification_pending', 'reported_done', 'paused'].includes(run?.state) || candidate.analysis?.state === 'continue' && candidate.autoContinue !== true || candidate.autoContinueStatus === 'needs_user')
|
|
36
|
+
return result('unfinished', '残りの作業あり', run?.reason || candidate.analysis?.reason || '未完了の作業を再開できます。', 'resume', true);
|
|
37
|
+
if (nativeRuntimeUnknown) return result('unknown', '実行状態を確認できません', '元のAIの最新の実行状態を確認できません。', 'refresh');
|
|
38
|
+
const prs = candidate.pullRequests || [];
|
|
39
|
+
if (prs.some(pr => pr.state === 'OPEN' && pr.reviewDecision === 'CHANGES_REQUESTED')) return result('changes_requested', 'PRの修正要求', '関連PRに修正要求があります。', 'open_pr', true);
|
|
40
|
+
if (prs.some(pr => pr.state === 'OPEN')) return result('review', 'PRレビュー待ち', '関連PRのレビュー・統合を待っています。', 'open_pr');
|
|
41
|
+
if (prs.some(pr => pr.state === 'UNKNOWN' || pr.fetchFailed || pr.stale)) return result('pr_unknown', 'PRの状態確認待ち', '関連PRの最新状態を確認できません。', 'refresh');
|
|
42
|
+
// A merge or no-follow-up recommendation is not task completion. no_action
|
|
43
|
+
// also covers casual chat, cancellation and optional future improvements.
|
|
44
|
+
if (run?.state === 'completed' || !run && candidate.providerGoal?.status === 'complete')
|
|
45
|
+
return result('completed', '完了', '会話の作業完了を確認しました。');
|
|
46
|
+
if (candidate.isRunning === true && (stale || fetchFailed)) return result('unknown', '実行状態を確認できません', '最終確認後の実行状態は不明です。', 'refresh');
|
|
47
|
+
if (run && ['active', 'auto_resuming'].includes(run.state) || ['queued', 'analyzing'].includes(candidate.analysis?.state)) return result('checking', 'AIの対応待ち', '次の処理・状態確認を待っています。');
|
|
48
|
+
return result('idle', '次の依頼待ち', '現在の実行や対応依頼は確認されていません。');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function compareSessionWork(a, b) {
|
|
52
|
+
const priority = item => item.workState?.state === 'running' ? 0 : item.workState?.needsAttention ? 1 : 2;
|
|
53
|
+
return priority(a) - priority(b) || (b.lastActivityAt || 0) - (a.lastActivityAt || 0) || `${a.provider}:${a.session}`.localeCompare(`${b.provider}:${b.session}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Completion belongs to the whole known family. Idle history alone does not
|
|
57
|
+
// create a human task; positively unfinished descendants do prevent completion.
|
|
58
|
+
export function reconcileFamilyCompletion(sessions) {
|
|
59
|
+
const byKey = new Map(sessions.map(item => [`${item.provider}:${item.session}`, item]));
|
|
60
|
+
for (const child of sessions) {
|
|
61
|
+
if (['idle', 'completed', 'archived'].includes(child.workState?.state)) continue;
|
|
62
|
+
const seen = new Set(); let parent = child.parentSession;
|
|
63
|
+
while (parent) {
|
|
64
|
+
const key = `${parent.provider}:${parent.session}`;
|
|
65
|
+
if (seen.has(key)) break;
|
|
66
|
+
seen.add(key); const ancestor = byKey.get(key);
|
|
67
|
+
if (!ancestor) break;
|
|
68
|
+
if (ancestor.workState?.state === 'completed') ancestor.workState = { ...ancestor.workState,
|
|
69
|
+
state: 'family_unfinished', label: '子の作業が未完了', reason: '関連する子の作業が残っています。', nextAction: 'review_children', needsAttention: false };
|
|
70
|
+
parent = ancestor.parentSession;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return sessions;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Fast connection heartbeats may carry the previous full projection while a
|
|
77
|
+
// reader is blocked. Expire live labels there too, without losing pending work.
|
|
78
|
+
export function expireSessionWorkState(candidate, now = Date.now()) {
|
|
79
|
+
const previous = candidate.workState;
|
|
80
|
+
if (!previous || freshObservation(previous.checkedAt, now)) return candidate;
|
|
81
|
+
if (previous.state !== 'running') return previous.stale ? candidate : { ...candidate, workState: { ...previous, stale: true } };
|
|
82
|
+
return { ...candidate, workState: { ...previous, state: 'unknown', label: '実行状態を確認できません',
|
|
83
|
+
reason: '最終確認後の実行状態は不明です。', nextAction: 'refresh', needsAttention: false, stale: true } };
|
|
84
|
+
}
|