@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 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
- ranked by last activity. Codex candidates come from CLI, VS Code and app-server
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.7.10",
3
+ "version": "0.7.11",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.10";
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,
@@ -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
 
@@ -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/lineage. Desktop files also contain private settings;
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)) { for (const id of seen) lineage.delete(id); break; }
130
- seen.add(cursor); cursor = lineage.get(cursor).parentSession.session;
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 desktopLineage = !session && cacheMetadata
190
- ? await observeClaudeDesktopLineage({ home, signal, sessions: new Set(descriptors.map(item => item.session)) }) : new Map();
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 && !candidate.parentSession)
194
- return { ...candidate, parentSession: origin.parentSession, parentSessionSource: origin.source };
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 : recentSessionWindow(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 : recentSessionWindow(sorted, { limit: maximum, priority: item => priorities.has(item.session) });
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
- observation: { source: 'codex_app_server', observedAt: Date.now() },
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 = Date.now(), signal, listCodexTitles = readCodexTitles, listCodexChildren = readCodexChildSessions, codexHome, connectedProviders = ['codex', 'claude'], includeArchived = false } = {}) {
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
- if (record.provider !== 'codex') continue;
178
- const isArchived = archiveStates.get(record.session) === true;
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 && (activeCodex.has(record.session) || archiveStates.get(record.session) === false))) continue;
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: item.observation, availability: item.availability,
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
- projected.sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0));
347
- return { sessions: projected, 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 };
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
+ }