@awak-app/simy-cli 0.7.2 → 0.7.4

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
@@ -15,7 +15,7 @@ cached in memory, so restarting the host may retrieve live contexts again.
15
15
 
16
16
  `simy --version` prints the installed CLI version without starting an agent or
17
17
  checking for updates. `simy doctor` performs read-only local connection and
18
- provider login checks and prints a JSON diagnostic report: installed/running
18
+ provider login and SIMY provider-link checks and prints a JSON diagnostic report: installed/running
19
19
  versions, stale discovery records, session expiry, provider readiness, and recent
20
20
  structured SQM failures, plus `host_exit` with the latest native desktop host's
21
21
  abnormal exit time and status. `failures.total_failures` counts scoped SQM
@@ -1095,3 +1095,11 @@ Diagnosis now returns an issue-first worksheet in `analysis.hypothesisSheet.requ
1095
1095
  Default issue selection is the first eight original acceptance criteria (or `objective` when absent); `omittedIssueCount` and `issueDescriptionsTruncated` disclose bounded input. Use an explicit issue list to focus on other original issues, including a prior `rule:*` or `objective` row. The latest retained runtime worksheet is supplied as context; this read-only command neither writes nor replaces the runtime ledger.
1096
1096
 
1097
1097
  Manual diagnosis and delivery reasoning share the scoped SQM knowledge loader. Partial environment overrides cannot mix a token, account, organization or endpoint with saved credentials; unavailable knowledge is reported without blocking the diagnosis. The brief response retains `knowledge.status`, `reason`, `suppliedCount`, and `bundleDigest`, separately from `knowledgeReferenceCount` and test execution (always zero for diagnosis). Truncated evidence is disclosed to both the analyst and the caller.
1098
+
1099
+ Provider diagnostics distinguish `authentication`, `connection`, and `connection_ready`.
1100
+ If both login and SIMY connection are missing, `actions` lists both remedies in one
1101
+ report. Connection readiness does not certify model access or task completion.
1102
+ Diagnostics do not reconnect providers or enable existing tasks. Explicit refresh
1103
+ waits for a fresh probe when a pre-login check is already running. macOS login
1104
+ terminals preserve an explicitly configured `CODEX_HOME` or `CLAUDE_CONFIG_DIR`,
1105
+ without copying credentials.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
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.2";
16
+ export const CLI_VERSION = "0.7.4";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function narrativeImageCapabilityMetadata() {
@@ -39,7 +39,7 @@ import { createCliAutoUpdater, readCliPackageInfo } from "./auto-update.js";
39
39
  import { createAgentShutdown } from "./shutdown.js";
40
40
  import { resolveWebOrigin } from "./web-origin.js";
41
41
  import { openAuthorizationUrl } from "./browser.js";
42
- import { inspectBackendExecutable } from "./backend-executable.js";
42
+ import { inspectBackendExecutable, CODEX_WRITER_LOCK_MINIMUM_VERSION } from "./backend-executable.js";
43
43
  import { inspectSqmAgentSkill } from "./sqm/agent-skill.js";
44
44
  import { startWorkflowUpdates, syncInstalledWorkflows } from "./sqm/workflow-update.js";
45
45
  import { createNativeDevelopmentReportsApi, normalizeNativeDevelopmentReportSave } from "./native-development-reports.js";
@@ -120,7 +120,7 @@ function desktopActionError(action, error) {
120
120
  }
121
121
 
122
122
  export async function startDesktopHost({ input = process.stdin, output = process.stdout, env = process.env,
123
- dependencies = {}, exit = (code) => process.exit(code) } = {}) {
123
+ updatePackageRoot, dependencies = {}, exit = (code) => process.exit(code) } = {}) {
124
124
  const emit = (event) => output.write(`${JSON.stringify(event)}\n`);
125
125
  // A heartbeat remains a complete native frame. Only its observation time is
126
126
  // volatile; reuse the encoded body after comparing freshly authorized data.
@@ -758,7 +758,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
758
758
  updater = await (dependencies.createUpdater || (process.platform === "win32" ? (() => createWindowsDesktopUpdater({ version: packageInfo.version, disabled: env.SIMY_AUTO_UPDATE === "0" })) : createCliAutoUpdater))({
759
759
  daemon: true, interactive: true, webOrigin,
760
760
  disabled: env.SIMY_AUTO_UPDATE === "0",
761
- dependencies: { packageInfo, logger: message => diagnostics.write(`update: ${message}`) },
761
+ ...(updatePackageRoot ? { packageRoot: updatePackageRoot } : {}),
762
+ dependencies: { packageInfo: updatePackageRoot ? await readCliPackageInfo(updatePackageRoot) : packageInfo,
763
+ logger: message => diagnostics.write(`update: ${message}`) },
762
764
  });
763
765
  agent = await (dependencies.startAgent || startAgent)({
764
766
  daemon: true, webOrigin, sessionRoot: root, quiet: true, updateManager: updater,
@@ -1440,7 +1442,7 @@ export async function openProviderLogin(provider, root, {
1440
1442
  execFileImpl = promisify(execFile), platform = process.platform, env = process.env,
1441
1443
  } = {}) {
1442
1444
  if (provider !== "codex" && provider !== "claude") throw new Error("Unsupported provider.");
1443
- const executable = await inspectExecutable(provider);
1445
+ const executable = await inspectExecutable(provider, provider === "codex" ? { minimumVersion: CODEX_WRITER_LOCK_MINIMUM_VERSION } : {});
1444
1446
  if (!executable.available || !executable.executable) throw Object.assign(new Error("Provider is not installed."), { code: "provider_not_installed" });
1445
1447
  const label = provider === "claude" ? "Claude Code" : "Codex";
1446
1448
  const args = provider === "claude" ? ["auth", "login"] : ["login"];
@@ -1454,7 +1456,11 @@ export async function openProviderLogin(provider, root, {
1454
1456
  const directory = await mkdtemp(join(root, `${provider}-login-`));
1455
1457
  const script = join(directory, `Sign in to ${label}.command`);
1456
1458
  const quote = (s) => `'${s.replaceAll("'", "'\\''")}'`;
1457
- await writeFile(script, `#!/bin/bash\nexport PATH=${quote(env.PATH || "/usr/bin:/bin")}\n${quote(executable.executable)} ${args.join(" ")}\nresult=$?\nif [ "$result" -ne 0 ]; then\n echo "Sign-in failed. Check the error above, then press Return to close."\n read -r _\nfi\nrm -f -- ${quote(script)}\nrmdir -- ${quote(directory)}\nexit "$result"\n`, { mode: 0o700 });
1459
+ // Terminal does not inherit custom provider homes from the desktop host.
1460
+ // Preserve only path settings, never copy tokens or credential contents.
1461
+ const homeKey = provider === "codex" ? "CODEX_HOME" : "CLAUDE_CONFIG_DIR";
1462
+ const providerHome = typeof env[homeKey] === "string" && env[homeKey] ? `export ${homeKey}=${quote(env[homeKey])}\n` : "";
1463
+ await writeFile(script, `#!/bin/bash\n${providerHome}export PATH=${quote(env.PATH || "/usr/bin:/bin")}\n${quote(executable.executable)} ${args.join(" ")}\nresult=$?\nif [ "$result" -ne 0 ]; then\n echo "Sign-in failed. Check the error above, then press Return to close."\n read -r _\nfi\nrm -f -- ${quote(script)}\nrmdir -- ${quote(directory)}\nexit "$result"\n`, { mode: 0o700 });
1458
1464
  await chmod(script, 0o700);
1459
1465
  try { await execFileImpl("/usr/bin/open", ["-a", "Terminal", script], { timeout: 10_000 }); }
1460
1466
  catch (error) { await rm(directory, { recursive: true, force: true }); throw error; }
@@ -1474,7 +1480,10 @@ function desktopStartupError(error, fallbackMessage = "SIMY could not start. Che
1474
1480
  };
1475
1481
  }
1476
1482
 
1477
- export function startDesktopEntry() {
1483
+ // Immutable pins predate this option; their launcher must keep the old update fence.
1484
+ export const supportsDesktopUpdatePackageRoot = true;
1485
+
1486
+ export function startDesktopEntry(options = {}) {
1478
1487
  let host;
1479
1488
  const stop = () => { void host?.close().finally(() => process.exit(0)); };
1480
1489
  process.once("SIGTERM", stop); process.once("SIGINT", stop);
@@ -1482,7 +1491,7 @@ export function startDesktopEntry() {
1482
1491
  // stdout is the native JSONL channel; stderr is the only unstructured output
1483
1492
  // and the native window does not display it, so keep it for support.
1484
1493
  captureRuntimeOutput(diagnostics, { stderr: process.stderr });
1485
- applyLoginShellPath({ diagnostics }).catch(() => {}).then(() => startDesktopHost()).then((value) => { host = value; }).catch((error) => {
1494
+ applyLoginShellPath({ diagnostics }).catch(() => {}).then(() => startDesktopHost(options)).then((value) => { host = value; }).catch((error) => {
1486
1495
  diagnostics.write(`startup failed: ${error?.stack || error}`);
1487
1496
  process.stdout.write(`${JSON.stringify(desktopStartupError(error))}\n`);
1488
1497
  process.exitCode = 1;
@@ -1500,9 +1509,11 @@ if (isDesktopHostEntry()) {
1500
1509
  }
1501
1510
  const selected = await pinnedWorkflowSource({ root });
1502
1511
  if (selected) {
1503
- process.env.SIMY_AUTO_UPDATE = '0';
1504
1512
  const selectedHost = await import(pathToFileURL(join(selected, 'src/desktop-host.js')).href);
1505
- selectedHost.startDesktopEntry();
1513
+ // A frozen workflow source has no installer marker. Keep update ownership
1514
+ // with the invoking app bundle, without disabling updates for the host.
1515
+ if (selectedHost.supportsDesktopUpdatePackageRoot !== true) process.env.SIMY_AUTO_UPDATE = '0';
1516
+ selectedHost.startDesktopEntry({ updatePackageRoot: fileURLToPath(new URL('../', import.meta.url)) });
1506
1517
  } else startDesktopEntry();
1507
1518
  } catch (error) {
1508
1519
  process.stdout.write(JSON.stringify(desktopStartupError(error, 'Local desktop runtime integrity could not be verified. Inspect delivery runtime status; the previous task state was retained.')) + '\n');
@@ -137,9 +137,19 @@ export function createProviderStatusInspector(provider, {
137
137
  let cached = null;
138
138
  let cachedAt = 0;
139
139
  let inFlight = null;
140
- return async ({ force = false } = {}) => {
140
+ const refreshAfterFlight = new WeakMap();
141
+ return async function readStatus({ force = false } = {}) {
141
142
  const current = now();
142
143
  if (!force && cached && current - cachedAt < cacheMs) return structuredClone(cached);
144
+ if (inFlight && force) {
145
+ // A login can finish while a previous signed-out probe is in flight.
146
+ // Explicit refresh must observe after that probe, not return its old result.
147
+ const previous = inFlight;
148
+ if (!refreshAfterFlight.has(previous)) {
149
+ refreshAfterFlight.set(previous, previous.catch(() => {}).then(() => readStatus({ force: true })));
150
+ }
151
+ return structuredClone(await refreshAfterFlight.get(previous));
152
+ }
143
153
  if (inFlight) return structuredClone(await inFlight);
144
154
  inFlight = Promise.resolve(inspect({ ...inspectOptions, now: () => new Date(now()) }))
145
155
  .then((result) => {
package/src/doctor.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { readProviderLinks } from "./provider-links.js";
1
2
  import { purposeSigningKeyPath, purposeVerificationCapability } from "./purpose-local-proof.js";
2
3
  import { readFile } from "node:fs/promises";
3
4
  import { connectionDiscoveryPath } from "./connection-discovery.js";
@@ -7,7 +8,7 @@ import { readDiagnosticLog } from "./diagnostic-log.js";
7
8
  import { readHostExitDiagnostics } from "./host-exit-diagnostics.js";
8
9
 
9
10
  export async function collectDiagnostics({ webOrigin, cliVersion, root = defaultSessionRoot(),
10
- readSessionImpl = readSession, inspectProvider = inspectProviderStatus, fetchImpl = fetch,
11
+ readSessionImpl = readSession, inspectProvider = inspectProviderStatus, readLinks = readProviderLinks, fetchImpl = fetch,
11
12
  readFileImpl = readFile, alive = pid => { try { process.kill(pid, 0); return true; } catch { return false; } }, now = Date.now() } = {}) {
12
13
  const session = await readSessionImpl(webOrigin, root).catch(() => null);
13
14
  const valid = isSessionValid(session, now, webOrigin);
@@ -27,10 +28,14 @@ export async function collectDiagnostics({ webOrigin, cliVersion, root = default
27
28
  background: health.daemon === true };
28
29
  }
29
30
  } catch (error) { if (error.code !== "ENOENT") daemon.state = "unverified"; }
31
+ const links = await readLinks(root).catch(() => ({}));
30
32
  const providers = Object.fromEntries(await Promise.all(["codex", "claude"].map(async provider => {
31
33
  const result = await inspectProvider(provider).catch(() => ({ state: "unknown" }));
32
34
  return [provider, { state: ["ready", "signed_out", "missing", "outdated", "unknown"].includes(result?.state) ? result.state : "unknown",
33
35
  version: typeof result?.version === "string" && /^\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?$/i.test(result.version) ? result.version : null,
36
+ connection: links[provider]?.settingsError || !links[provider] ? "unknown" : links[provider].connected ? "connected" : "disconnected",
37
+ authentication: result?.state === "ready" ? "signed_in" : result?.state === "signed_out" ? "signed_out" : "unknown",
38
+ connection_ready: result?.state === "ready" && links[provider]?.connected === true && !links[provider]?.settingsError,
34
39
  model_access: "unknown" }];
35
40
  })));
36
41
  const scope = session?.account_id && session?.organization_id ? { webOrigin, accountId: session.account_id, organizationId: session.organization_id } : null;
@@ -41,6 +46,11 @@ export async function collectDiagnostics({ webOrigin, cliVersion, root = default
41
46
  providers, failures, host_exit: await readHostExitDiagnostics(root),
42
47
  purpose_verification: await purposeVerificationCapability(purposeSigningKeyPath(root)).then(value => ({ state: value ? "available" : "not_initialized", key_id: value?.key_id || null, execution: "explicit_frozen_recipes_only" })).catch(() => ({ state: "unavailable", key_id: null, execution: "explicit_frozen_recipes_only" })),
43
48
  actions: [
49
+ ...Object.entries(providers).filter(([, provider]) => provider.connection === "disconnected")
50
+ .map(([provider]) => ({ id: `connect_${provider}`, action: "provider_connect", provider,
51
+ instruction: `Open SIMY settings and connect ${provider}. Provider login and SIMY connection are separate; existing tasks remain stopped.` })),
52
+ ...Object.entries(providers).filter(([, provider]) => provider.connection === "unknown")
53
+ .map(([provider]) => ({ id: `check_${provider}_connection`, instruction: "SIMY connection settings could not be read. Refresh connections; do not reset saved consent." })),
44
54
  ...(!valid ? [{ id: "reconnect_cli", command: "simy" }] : []),
45
55
  ...(["stale_record", "not_found", "unverified"].includes(daemon.state) ? [{ id: "inspect_daemon", command: "simy doctor" }] : []),
46
56
  ...(daemon.version_matches === false ? [{ id: "update_when_idle", command: "simy update --check" }] : []),
@@ -0,0 +1,161 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, open, readdir } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { isAbsolute, join, resolve } from 'node:path';
5
+
6
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7
+ const LOCAL = /^local_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
+ const MAX_FILES = 4096, MAX_BYTES = 256 * 1024;
9
+ const cache = new Map();
10
+ const observations = new Map();
11
+ const fingerprint = info => [info.dev, info.ino, info.size, info.mtimeNs, info.ctimeNs].join(':');
12
+
13
+ export function claudeDesktopSessionRoot(home = homedir()) {
14
+ return process.platform === 'win32' ? join(home, 'AppData', 'Roaming', 'Claude', 'claude-code-sessions')
15
+ : process.platform === 'darwin' ? join(home, 'Library', 'Application Support', 'Claude', 'claude-code-sessions')
16
+ : join(home, '.config', 'Claude', 'claude-code-sessions');
17
+ }
18
+
19
+ async function directories(path) {
20
+ try {
21
+ const info = await lstat(path);
22
+ if (!info.isDirectory() || info.isSymbolicLink()) return [];
23
+ return (await readdir(path, { withFileTypes: true })).filter(item => item.isDirectory() && !item.isSymbolicLink() && UUID.test(item.name)).slice(0, 32);
24
+ } catch { return []; }
25
+ }
26
+
27
+ // Complete top-level fields from a small JSON prefix. Strings and nested
28
+ // objects may contain commas, so only JSON structure defines a safe boundary.
29
+ function prefixObject(text) {
30
+ if (!text.trimStart().startsWith('{')) return null;
31
+ let depth = 0, quoted = false, escaped = false, boundary = -1;
32
+ for (let index = 0; index < text.length; index++) {
33
+ const char = text[index];
34
+ if (quoted) {
35
+ if (escaped) escaped = false;
36
+ else if (char === '\\') escaped = true;
37
+ else if (char === '"') quoted = false;
38
+ } else if (char === '"') quoted = true;
39
+ else if (char === '{' || char === '[') depth++;
40
+ else if (char === '}' || char === ']') {
41
+ if (--depth === 0) return JSON.parse(text.slice(0, index + 1));
42
+ } else if (char === ',' && depth === 1) boundary = index;
43
+ }
44
+ return boundary < 0 ? null : JSON.parse(text.slice(0, boundary) + '}');
45
+ }
46
+
47
+ async function metadata(path, desktopId, sessions) {
48
+ let handle;
49
+ try {
50
+ const before = await lstat(path, { bigint: true });
51
+ if (!before.isFile() || before.isSymbolicLink() || before.size > BigInt(MAX_BYTES)) return null;
52
+ const key = fingerprint(before);
53
+ const cached = cache.get(path);
54
+ if (cached?.key === key && (cached.complete || sessions && !sessions.has(cached.value?.session))) return cached.value;
55
+ handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
56
+ if (fingerprint(await handle.stat({ bigint: true })) !== key) return null;
57
+ let complete = true, item;
58
+ if (sessions) {
59
+ const prefix = Buffer.alloc(Math.min(512, Number(before.size)));
60
+ const { bytesRead } = await handle.read(prefix, 0, prefix.length, 0);
61
+ complete = Number(before.size) <= bytesRead;
62
+ const text = prefix.subarray(0, bytesRead).toString('utf8');
63
+ item = complete ? JSON.parse(text) : prefixObject(text);
64
+ }
65
+ if (!item || !sessions || sessions.has(item.cliSessionId)) {
66
+ const bytes = Buffer.alloc(Number(before.size));
67
+ const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
68
+ item = JSON.parse(bytes.subarray(0, bytesRead).toString('utf8')); complete = true;
69
+ }
70
+ let value = null;
71
+ if (item.sessionId === desktopId && UUID.test(item.cliSessionId || '') && typeof item.cwd === 'string' && isAbsolute(item.cwd)) {
72
+ value = { desktopId, session: item.cliSessionId, cwd: resolve(item.cwd),
73
+ parentDesktopId: complete && LOCAL.test(item.spawnedFrom?.sessionId || '') ? item.spawnedFrom.sessionId : null };
74
+ }
75
+ const after = await lstat(path, { bigint: true });
76
+ if (fingerprint(after) !== key || fingerprint(await handle.stat({ bigint: true })) !== key || after.isSymbolicLink()) return null;
77
+ if (!complete && !value) return null;
78
+ // Cache only identity/lineage. Desktop files also contain private settings;
79
+ // none of those fields or conversation text leave this bounded reader.
80
+ cache.delete(path); cache.set(path, { key, value, complete });
81
+ while (cache.size > MAX_FILES) cache.delete(cache.keys().next().value);
82
+ return value;
83
+ } catch { cache.delete(path); return null; }
84
+ finally { await handle?.close(); }
85
+ }
86
+
87
+ // Desktop spawn parents use local_* IDs, whereas transcripts and SIMY use CLI
88
+ // UUIDs. Resolve them within the same account AND organization partition.
89
+ export async function readClaudeDesktopLineage({ home = homedir(), root = claudeDesktopSessionRoot(home), signal, sessions = null } = {}) {
90
+ if (signal?.aborted) return new Map();
91
+ const files = [];
92
+ for (const account of await directories(root)) {
93
+ for (const org of await directories(join(root, account.name))) {
94
+ const partition = join(root, account.name, org.name);
95
+ let entries;
96
+ try { entries = await readdir(partition, { withFileTypes: true }); } catch { continue; }
97
+ for (const file of entries) {
98
+ if (files.length >= MAX_FILES) break;
99
+ const desktopId = file.name.slice(0, -5);
100
+ if (file.isFile() && !file.isSymbolicLink() && file.name.endsWith('.json') && LOCAL.test(desktopId))
101
+ files.push({ path: join(partition, file.name), partition, desktopId });
102
+ }
103
+ }
104
+ }
105
+ const partitions = new Map(), claims = new Map();
106
+ let next = 0;
107
+ await Promise.all(Array.from({ length: Math.min(8, files.length) }, async () => {
108
+ while (next < files.length && !signal?.aborted) {
109
+ const file = files[next++], item = await metadata(file.path, file.desktopId, sessions);
110
+ if (!item) continue;
111
+ if (!partitions.has(file.partition)) partitions.set(file.partition, new Map());
112
+ partitions.get(file.partition).set(item.desktopId, item);
113
+ claims.set(item.session, (claims.get(item.session) || 0) + 1);
114
+ }
115
+ }));
116
+ if (signal?.aborted) return new Map();
117
+ const lineage = new Map();
118
+ for (const partition of partitions.values()) for (const item of partition.values()) {
119
+ const parent = partition.get(item.parentDesktopId);
120
+ if (!parent || claims.get(item.session) !== 1 || claims.get(parent.session) !== 1 || parent.session === item.session) continue;
121
+ lineage.set(item.session, { cwd: item.cwd, parentCwd: parent.cwd,
122
+ parentSession: { provider: 'claude', session: parent.session },
123
+ source: 'claude_desktop_spawned_from' });
124
+ }
125
+ // Reject cyclic Desktop metadata rather than inventing a root or parent.
126
+ for (const session of [...lineage.keys()]) {
127
+ const seen = new Set(); let cursor = session;
128
+ 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;
131
+ }
132
+ }
133
+ return lineage;
134
+ }
135
+
136
+ // A cold Desktop directory can contain years of session settings. Let the
137
+ // sidebar retain its last complete lineage while that read-only scan finishes;
138
+ // discovery and unrelated provider controls must not wait for the entire scan.
139
+ export async function observeClaudeDesktopLineage(options = {}, { read = readClaudeDesktopLineage, budgetMs = 1500 } = {}) {
140
+ if (options.signal?.aborted) return new Map();
141
+ const root = options.root || claudeDesktopSessionRoot(options.home);
142
+ let entry = observations.get(root);
143
+ if (!entry) {
144
+ entry = { value: new Map(), pending: null };
145
+ observations.set(root, entry);
146
+ while (observations.size > 16) observations.delete(observations.keys().next().value);
147
+ }
148
+ if (!entry.pending) {
149
+ entry.pending = read(options).then(value => {
150
+ // An aborted shared scan is incomplete, so every joiner keeps the last tree.
151
+ if (!options.signal?.aborted) entry.value = value;
152
+ return entry.value;
153
+ }, () => entry.value)
154
+ .finally(() => { entry.pending = null; });
155
+ }
156
+ let timer;
157
+ try {
158
+ return await Promise.race([entry.pending,
159
+ new Promise(resolve => { timer = setTimeout(() => resolve(entry.value), budgetMs); })]);
160
+ } finally { clearTimeout(timer); }
161
+ }
@@ -6,6 +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
10
 
10
11
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
12
  const MAX_PROJECTS = 256;
@@ -185,6 +186,14 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
185
186
  }));
186
187
  }
187
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();
191
+ const decorateLineage = candidate => {
192
+ const origin = desktopLineage.get(candidate.session);
193
+ if (origin?.cwd === candidate.cwd && !candidate.parentSession)
194
+ return { ...candidate, parentSession: origin.parentSession, parentSessionSource: origin.source };
195
+ return candidate;
196
+ };
188
197
  const selected = session ? descriptors : recentSessionWindow(descriptors, {
189
198
  limit: maximum, priority: item => priorities.has(item.session),
190
199
  });
@@ -195,13 +204,44 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
195
204
  await Promise.all(Array.from({ length: Math.min(includeContext || includeStatistics ? 1 : 8, filesToInspect.length) }, async () => {
196
205
  while (nextFile < filesToInspect.length && !signal?.aborted) {
197
206
  const file = filesToInspect[nextFile++];
198
- const candidate = await inspectTranscript(root, file.projectName, file.fileName, observedAt, includeContext, cacheMetadata, includeStatistics, signal);
207
+ let candidate = await inspectTranscript(root, file.projectName, file.fileName, observedAt, includeContext, cacheMetadata, includeStatistics, signal);
199
208
  if (!candidate) continue;
209
+ candidate = decorateLineage(candidate);
200
210
  const key = `${candidate.session}\0${candidate.cwd}${includeContext || includeStatistics ? `\0${file.projectName}\0${file.fileName}` : ''}`;
201
211
  if (!result.has(key) || candidate.lastActivityAt > result.get(key).lastActivityAt) result.set(key, candidate);
202
212
  }
203
213
  }));
204
214
  if (signal?.aborted) return [];
215
+ // Resolve old parents outside the recent window through their own validated
216
+ // transcript. Never manufacture selectable conversations from Desktop JSON.
217
+ if (!session) {
218
+ const attempted = new Set(result.keys());
219
+ for (const child of result.values()) {
220
+ 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;
222
+ for (const file of descriptors.filter(item => item.session === origin.parentSession.session)) {
223
+ const key = `${file.session}\0${origin.parentCwd}`;
224
+ if (attempted.has(key) || attempted.size >= MAX_TRANSCRIPTS || signal?.aborted) break;
225
+ attempted.add(key);
226
+ const parent = await inspectTranscript(root, file.projectName, file.fileName, observedAt, false, cacheMetadata, false, signal);
227
+ if (parent?.cwd === origin.parentCwd) result.set(key, decorateLineage(parent));
228
+ }
229
+ }
230
+ }
231
+ // Both endpoints must be unambiguous validated transcript identities. A
232
+ // Desktop file alone cannot attach a child to a missing or different cwd.
233
+ const identities = new Map();
234
+ for (const item of result.values()) {
235
+ if (!identities.has(item.session)) identities.set(item.session, []);
236
+ identities.get(item.session).push(item);
237
+ }
238
+ for (const [key, item] of result) if (item.parentSessionSource === 'claude_desktop_spawned_from') {
239
+ const origin = desktopLineage.get(item.session), parents = identities.get(origin.parentSession.session) || [];
240
+ if (identities.get(item.session).length !== 1 || parents.length !== 1 || parents[0].cwd !== origin.parentCwd) {
241
+ const { parentSession, parentSessionSource, ...independent } = item;
242
+ result.set(key, independent);
243
+ }
244
+ }
205
245
  const sorted = [...result.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt || a.session.localeCompare(b.session));
206
246
  return session ? sorted : recentSessionWindow(sorted, { limit: maximum, priority: item => priorities.has(item.session) });
207
247
  }
@@ -194,7 +194,8 @@ export async function openCodexAppServer({ inspectExecutable = inspectCodexAppSe
194
194
 
195
195
  export function codexThreadMetadata(thread) {
196
196
  if (!thread || typeof thread.id !== 'string' || !/^[0-9a-f-]{36}$/i.test(thread.id) || typeof thread.cwd !== 'string' || !thread.cwd || thread.ephemeral) return null;
197
- if (thread.parentThreadId) return null;
197
+ // Internal workers are display-only lineage, never independent admission.
198
+ if (thread.parentThreadId || thread.source?.subAgent || thread.source?.subagent) return null;
198
199
  return {
199
200
  provider: 'codex', session: thread.id, cwd: thread.cwd,
200
201
  ...(typeof thread.forkedFromId === 'string' && /^[0-9a-f-]{36}$/i.test(thread.forkedFromId) && thread.forkedFromId !== thread.id
@@ -0,0 +1,59 @@
1
+ import { lstat } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { recentSessionWindow } from './discovery-window.js';
5
+ import { sessionTitle } from './session-titles.js';
6
+
7
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
+
9
+ // The provider list intentionally omits internal workers. Show their explicit
10
+ // spawn lineage as read-only rows, without turning them into standalone tasks.
11
+ // Never scan transcripts, infer relationships from cwd, or modify Codex state.
12
+ export async function readCodexChildSessions({ parents = [], includeArchived = false,
13
+ codexHome = process.env.CODEX_HOME || join(homedir(), '.codex'), limit = 200 } = {}) {
14
+ const roots = [...new Set(parents.filter(item => item.provider === 'codex' && UUID.test(item.session)).map(item => item.session))];
15
+ if (!roots.length) return [];
16
+ const path = join(codexHome, 'state_5.sqlite');
17
+ let db;
18
+ try {
19
+ const info = await lstat(path);
20
+ if (!info.isFile() || info.isSymbolicLink()) return [];
21
+ // Optional on older Node installations; the bundled desktop uses Node 22.
22
+ const { DatabaseSync } = await import('node:sqlite');
23
+ db = new DatabaseSync(path, { readOnly: true });
24
+ db.exec('PRAGMA query_only = ON; PRAGMA busy_timeout = 50');
25
+ const rows = db.prepare(`WITH RECURSIVE family(id, depth) AS (
26
+ SELECT id, 0 FROM threads WHERE id IN (${roots.map(() => '?').join(',')})
27
+ UNION SELECT e.child_thread_id, f.depth + 1 FROM thread_spawn_edges e
28
+ JOIN family f ON f.id = e.parent_thread_id WHERE f.depth < 32
29
+ ) SELECT t.id, t.cwd, t.name, t.source, t.model, t.updated_at, t.archived,
30
+ e.parent_thread_id, MIN(f.depth) AS depth
31
+ FROM family f JOIN threads t ON t.id = f.id
32
+ JOIN thread_spawn_edges e ON e.child_thread_id = t.id
33
+ WHERE f.depth > 0 GROUP BY t.id ORDER BY depth, t.updated_at DESC LIMIT 2000`).all(...roots);
34
+ const candidates = [];
35
+ for (const row of rows) {
36
+ let source;
37
+ try { source = JSON.parse(row.source); } catch { continue; }
38
+ const spawn = source?.subagent?.thread_spawn || source?.subAgent?.thread_spawn;
39
+ if (!UUID.test(row.id) || !UUID.test(row.parent_thread_id) || row.id === row.parent_thread_id ||
40
+ spawn?.parent_thread_id !== row.parent_thread_id || typeof row.cwd !== 'string' || !row.cwd) continue;
41
+ candidates.push({ provider: 'codex', session: row.id, cwd: row.cwd,
42
+ parentSession: { provider: 'codex', session: row.parent_thread_id }, delegated: true,
43
+ title: sessionTitle(row.name) || sessionTitle(sessionTitle(spawn.agent_path).split('/').at(-1)) || sessionTitle(spawn.agent_nickname),
44
+ source: 'subAgentThreadSpawn', model: row.model, archived: row.archived === 1,
45
+ lastActivityAt: row.updated_at * 1000,
46
+ observation: { source: 'codex_local_index', observedAt: Date.now() }, availability: 'read_only' });
47
+ }
48
+ const selected = recentSessionWindow(candidates.filter(item => includeArchived || !item.archived), { limit });
49
+ // An active grandchild must retain an archived connecting parent.
50
+ const byId = new Map(candidates.map(item => [item.session, item]));
51
+ const included = new Set(selected.map(item => item.session));
52
+ for (const item of selected) {
53
+ const parent = byId.get(item.parentSession.session);
54
+ if (parent && !included.has(parent.session)) { included.add(parent.session); selected.push(parent); }
55
+ }
56
+ return selected;
57
+ } catch { return []; }
58
+ finally { db?.close(); }
59
+ }
@@ -9,6 +9,7 @@ import { listCodexSessions, codexThreadMetadata, openCodexAppServer, readCodexSe
9
9
  import { listClaudeSessions, readClaudeSession } from './claude-sessions.js';
10
10
  import { loadPurposeRuleSet } from './purpose-instruction.js';
11
11
  import { originalSessionTarget } from './original-session-target.js';
12
+ import { readCodexChildSessions } from './codex-children.js';
12
13
  import { readCodexTitles, sessionTitle } from './session-titles.js';
13
14
  import { RECENT_SESSION_LIMIT, recentSessionWindow, retainedSession } from './discovery-window.js';
14
15
  import { requireProviderLink } from '../provider-links.js';
@@ -113,8 +114,16 @@ async function requireClaudeChannel(record, metadata, inspectProcess) {
113
114
  if (!channel.ready) throw Object.assign(new Error(channel.reason), { code: channel.code });
114
115
  }
115
116
 
117
+ function navigationParent(item, record) {
118
+ // A user's explicit link remains authoritative. Desktop's verified direct
119
+ // parent is stronger than an inherited launch environment in an old hook.
120
+ return record?.parentSessionSource === 'explicit' ? record.parentSession
121
+ : item.parentSessionSource === 'claude_desktop_spawned_from' ? item.parentSession
122
+ : record?.parentSession || item.parentSession;
123
+ }
124
+
116
125
  export async function discoverSessions({ root, openClient = openCodexAppServer, listClaude = listClaudeSessions,
117
- inspectProcess = processIdentity, now = Date.now(), signal, listCodexTitles = readCodexTitles, codexHome, connectedProviders = ['codex', 'claude'], includeArchived = false } = {}) {
126
+ inspectProcess = processIdentity, now = Date.now(), signal, listCodexTitles = readCodexTitles, listCodexChildren = readCodexChildSessions, codexHome, connectedProviders = ['codex', 'claude'], includeArchived = false } = {}) {
118
127
  const assertActive = () => { if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation discovery was cancelled.'); };
119
128
  assertActive();
120
129
  const connected = new Set(connectedProviders);
@@ -199,7 +208,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
199
208
  }
200
209
  const requiredAncestors = new Set();
201
210
  for (const item of byKey.values()) {
202
- let parent = recordsByKey.get(`${item.provider}:${item.session}`)?.parentSession || item.parentSession;
211
+ let parent = navigationParent(item, recordsByKey.get(`${item.provider}:${item.session}`));
203
212
  for (let depth = 0; parent && depth < 256; depth++) {
204
213
  const key = `${parent.provider}:${parent.session}`;
205
214
  if (requiredAncestors.has(key)) break;
@@ -226,7 +235,8 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
226
235
  // visible even during a truncated discovery or provider outage.
227
236
  const catalog = [...byKey.values()].map(item => {
228
237
  const record = recordsByKey.get(`${item.provider}:${item.session}`);
229
- return { ...item, ...((record?.parentSession || item.parentSession) ? { parentSession: record?.parentSession || item.parentSession } : {}) };
238
+ const parentSession = navigationParent(item, record);
239
+ return { ...item, ...(parentSession ? { parentSession } : {}) };
230
240
  });
231
241
  const items = [...recentSessionWindow(catalog.filter(item => item.archived !== true), {
232
242
  priority: item => retainedSession(recordsByKey.get(`${item.provider}:${item.session}`)),
@@ -242,11 +252,25 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
242
252
  const ancestor = catalogByKey.get(key); includedKeys.add(key); items.push(ancestor); parent = ancestor.parentSession;
243
253
  }
244
254
  }
255
+ // Keep the human conversation window intact; workers are supplementary rows.
256
+ if (connected.has('codex')) {
257
+ const children = await listCodexChildren({ parents: items, codexHome, includeArchived });
258
+ for (const child of children) {
259
+ const key = `${child.provider}:${child.session}`;
260
+ if (!includedKeys.has(key)) { includedKeys.add(key); items.push(child); }
261
+ }
262
+ }
245
263
  const projected = new Array(items.length);
246
264
  const codexTitles = items.some(item => item.provider === 'codex' && !sessionTitle(item.title)) ? await listCodexTitles() : new Map();
247
265
  assertActive();
248
266
  let nextItem = 0;
249
267
  const project = async item => {
268
+ if (item.delegated === true) return { ...item,
269
+ tracked: false, autoContinue: false, binding: { mode: 'unmanaged', taskId: null },
270
+ continuationMode: 'manual', continuationReasonCode: 'parent_controlled',
271
+ autoContinueAvailability: { canEnable: false, reasonCode: 'parent_controlled', reason: 'This delegated conversation is controlled by its parent.' },
272
+ capabilities: { canMonitor: false, canPurposeContinue: false, canAnalyze: false, canEnroll: false, canContinue: false, canAutoContinue: false },
273
+ originalSessionTarget: originalSessionTarget(item.provider, item.session) };
250
274
  const record = recordsByKey.get(`${item.provider}:${item.session}`);
251
275
  const run = record?.run;
252
276
  const archived = item.archived === true || record?.providerArchived === true;
@@ -283,7 +307,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
283
307
  } catch { /* Unverified process leaves runtime status unknown. */ }
284
308
  }
285
309
  return { provider: item.provider, session: item.session, cwd: item.cwd,
286
- ...((item.parentSession || record?.parentSession) ? { parentSession: record?.parentSession || item.parentSession } : {}),
310
+ ...(navigationParent(item, record) ? { parentSession: navigationParent(item, record) } : {}),
287
311
  title: sessionTitle(item.title) || (item.provider === 'codex' ? codexTitles.get(item.session) || '' : ''), source: item.source,
288
312
  ...(typeof isRunning === 'boolean' ? { isRunning } : {}),
289
313
  ...(item.latestTurnId ? { latestTurnId: item.latestTurnId } : {}),