@awak-app/simy-cli 0.7.37 → 0.7.38

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.
Files changed (29) hide show
  1. package/package.json +1 -1
  2. package/src/auto-update.js +25 -13
  3. package/src/cli-contract.js +1 -1
  4. package/src/cli-observability.js +2 -2
  5. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +240 -24
  6. package/src/codex-plugins/simy/hooks/purpose-workspace.mjs +4 -1
  7. package/src/codex-plugins/simy/hooks/registered-requirement-checker.mjs +201 -0
  8. package/src/codex-plugins/simy/hooks/requirement-acceptance.mjs +69 -0
  9. package/src/delivery-hooks/supervisor-watchdog.js +3 -2
  10. package/src/desktop-host.js +48 -18
  11. package/src/session-supervision/acceptance-reads.js +11 -2
  12. package/src/session-supervision/analysis-manager.js +9 -2
  13. package/src/session-supervision/catalog-refresh.js +16 -0
  14. package/src/session-supervision/claude-native-agent-contract.js +62 -0
  15. package/src/session-supervision/claude-outcome-review.js +7 -6
  16. package/src/session-supervision/claude-sessions.js +137 -76
  17. package/src/session-supervision/codex-app-server.js +3 -3
  18. package/src/session-supervision/codex-children.js +2 -2
  19. package/src/session-supervision/conversation-context.js +58 -26
  20. package/src/session-supervision/discovery-window.js +8 -2
  21. package/src/session-supervision/index.js +25 -12
  22. package/src/session-supervision/purpose-verification.js +12 -3
  23. package/src/sqm/advisory-guidance.js +1 -0
  24. package/src/sqm/checkers.js +35 -1
  25. package/src/sqm/claude-plugin.js +7 -1
  26. package/src/sqm/hypothesis-knowledge.js +3 -3
  27. package/src/sqm/knowledge-recall.js +34 -12
  28. package/src/sqm/reasoned-assessment.js +22 -9
  29. package/src/update-detachment/runtime.js +2 -1
@@ -7,7 +7,7 @@ import { basename, relative, resolve } from 'node:path';
7
7
  import { runStructuredProviderReview } from '../sqm/review-worker.js';
8
8
  import { captureAcceptanceReads } from './acceptance-reads.js';
9
9
  import { readClaudeConversation, readClaudeNativeReview } from './claude-sessions.js';
10
- import { independentRequestContext, workspaceIdentity } from './purpose-verification.js';
10
+ import { independentReviewRequestContext, workspaceIdentity } from './purpose-verification.js';
11
11
  import { effectiveAcceptanceCriteria } from '../codex-plugins/simy/hooks/acceptance-policy.mjs';
12
12
  import { liveOutcomeReviewEligible, settleLiveOutcomeReview, readRecord, taskDirectory, hasPendingUserStop, providerLinkState, processIdentity } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
13
13
 
@@ -52,7 +52,7 @@ export async function claudeReviewBudgetBoundary(record, { platform = process.pl
52
52
  // original Claude process waits for its CLI child and retains its writer lease.
53
53
  // No second controller/session resumes or writes the task workspace.
54
54
  export async function reviewClaudeCheckpoint(record, { root, review = runStructuredProviderReview,
55
- readContext = readClaudeConversation, readNativeReview = readClaudeNativeReview, onFailure = () => {}, captureReads = captureAcceptanceReads, inspectBudget = claudeReviewBudgetBoundary, signal, executionTimeoutMs = 120000 } = {}) {
55
+ readContext = readClaudeConversation, readNativeReview = readClaudeNativeReview, onFailure = () => {}, captureReads = captureAcceptanceReads, fingerprintWorkspace = workspaceIdentity, inspectBudget = claudeReviewBudgetBoundary, signal, executionTimeoutMs = 120000 } = {}) {
56
56
  if (!liveOutcomeReviewEligible(record)) return record;
57
57
  const snapshot = structuredClone(record), cwd = taskDirectory(record), criteria = effectiveAcceptanceCriteria(record.run.managed);
58
58
  const abort = new AbortController();
@@ -77,17 +77,18 @@ export async function reviewClaudeCheckpoint(record, { root, review = runStructu
77
77
  summary: 'Independent review was not dispatched: the original Claude process budget arguments could not be verified. Acceptance remains unverified; inspect the original launch configuration without changing its budget.',
78
78
  findings: [], source: 'controller_review_unavailable', checkedAt: Date.now() } });
79
79
  const inventory = [];
80
- const before = await workspaceIdentity(cwd, undefined, inventory, { signal: combined, inventoryLimit: 1000 });
80
+ const before = await fingerprintWorkspace(cwd, undefined, inventory, { signal: combined, inventoryLimit: 1000 });
81
81
  const reads = await captureReads(cwd, criteria, { signal: combined });
82
82
  // This executor cannot independently inspect unnamed artifacts or services.
83
83
  // Never turn absent/truncated input into a successful semantic judgment.
84
84
  if (!reads.length || reads.some(row => row.observation.truncated || row.observation.redacted)) return await unavailable('Current outcome observations are absent or incomplete.');
85
85
  if (reads.some(row => !inventory.some(file => file.type === 'file' && file.path === relative(cwd, resolve(cwd, row.recipe.path)) && file.digest === row.digest)))
86
86
  return await unavailable('Controller observations do not match the reviewed workspace revision; reread the actual outcome.');
87
- const context = await readContext(record, { signal: combined, userOnly: true });
87
+ const context = await readContext(record, { signal: combined, userOnly: true, reviewUserContext: true });
88
88
  if (!context?.messages?.some(row => row.role === 'user') || context.incomplete || context.truncated) return await unavailable('Original user context could not be read completely; inspect the original request before retrying review.');
89
89
  if (!await current()) return await readRecord(root, record.provider, record.session);
90
- const requests = independentRequestContext(context);
90
+ const requests = independentReviewRequestContext(context);
91
+ if (requests.incomplete || requests.truncated || requests.redacted) return await unavailable('Original user requests exceed complete review coverage or contain redacted context; acceptance remains unverified.');
91
92
  if (budget === 'worker_cap' || budget === 'native_only') {
92
93
  const contextDigest = createHash('sha256').update(JSON.stringify(requests.messages.filter(row => row.text?.trim()))).digest('hex');
93
94
  const pending = record.run.managed.nativeOutcomeReview;
@@ -111,7 +112,7 @@ export async function reviewClaudeCheckpoint(record, { root, review = runStructu
111
112
  }
112
113
  const result = await review('claude', {
113
114
  system: CLAUDE_REVIEW_SYSTEM,
114
- prompt: JSON.stringify({ objective: record.run.objective, requests: independentRequestContext(context), criteria, workspaceInventory: { paths: inventory.map(row => row.path), truncated: inventory.truncated === true }, observations: reads }),
115
+ prompt: JSON.stringify({ objective: record.run.objective, requests, criteria, workspaceInventory: { paths: inventory.map(row => row.path), truncated: inventory.truncated === true }, observations: reads }),
115
116
  output_schema: CLAUDE_REVIEW_SCHEMA
116
117
  }, { signal: combined, executionTimeoutMs, subscriptionOnly: true, beforeDispatch: async () => {
117
118
  if (!await current()) throw new Error('Review target changed');
@@ -5,7 +5,8 @@ import { lstat, open, readdir, realpath } from 'node:fs/promises';
5
5
  import { homedir } from 'node:os';
6
6
  import { createHash } from 'node:crypto';
7
7
  import { isAbsolute, join, relative, resolve, sep } from 'node:path';
8
- import { boundConversation, CONVERSATION_LIMITS, textContent } from './conversation-context.js';
8
+ import { boundConversation, CONVERSATION_LIMITS, createConversationCollector, textContent } from './conversation-context.js';
9
+ import { createForegroundAgentContractCollector, isForegroundNativeReviewInput } from './claude-native-agent-contract.js';
9
10
  import { sessionTitle } from './session-titles.js';
10
11
  import { RECENT_SESSION_LIMIT, recentSessionWindow } from './discovery-window.js';
11
12
  import { observeClaudeDesktopLineage, readClaudeDesktopLineage } from './claude-desktop-lineage.js';
@@ -152,7 +153,8 @@ async function inspectTranscript(root, projectName, fileName, observedAt, includ
152
153
  const candidate = candidateMetadata(id, metadata, opened.mtimeMs, observedAt);
153
154
  signal?.throwIfAborted();
154
155
  if (includeStatistics) candidate.statistics = await statisticsFromHandle(handle, opened, 'claude', id, { signal });
155
- if (includeContext) candidate.context = await conversationFromHandle(handle, opened, id, metadata.cwd, includeContext === 'user');
156
+ if (includeContext) candidate.context = await conversationFromHandle(handle, opened, id, metadata.cwd, {
157
+ userOnly: ['user', 'review_user'].includes(includeContext), reviewUserContext: includeContext === 'review_user', signal });
156
158
  if (humanRequest) candidate.humanBoundary = await humanBoundaryFromHandle(handle, opened, path, id, metadata.cwd, humanRequest, signal);
157
159
  if (includeBoundary) {
158
160
  candidate.transcriptPath = path;
@@ -309,29 +311,47 @@ const humanFileFingerprint = info => humanFileIdentity(info) === null ? null : c
309
311
  // cooperative cancellation, exact byte offsets and a digest of every read byte.
310
312
  // Whole-history and human-cursor readers retain their separate semantics.
311
313
  async function scanTranscriptLines(handle, opened, { maxBytes, maxLineBytes = 4 * 1024 * 1024,
312
- maxRows = 250000, signal, onLine, onChunk = () => {} }) {
313
- if (!opened.size || opened.size > maxBytes) return null;
314
+ maxRows = 250000, start = 0, allowIncomplete = false, signal, onLine, onChunk = () => {} }) {
315
+ if (!opened.size || opened.size - start > maxBytes) return null;
314
316
  const hash = createHash('sha256'), buffer = Buffer.alloc(64 * 1024);
315
- let offset = 0, pending = Buffer.alloc(0), rows = 0, lineOffset = 0;
317
+ let offset = start, fragments = [], pendingBytes = 0, rows = 0, lineOffset = start, discard = start > 0, incomplete = false;
316
318
  while (offset < opened.size) {
317
319
  signal?.throwIfAborted();
318
320
  const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, opened.size - offset), offset);
319
321
  if (!bytesRead) return null;
320
- const chunk = buffer.subarray(0, bytesRead);
322
+ let chunk = buffer.subarray(0, bytesRead);
321
323
  hash.update(chunk); onChunk(chunk, offset); offset += bytesRead;
322
- pending = Buffer.concat([pending, chunk]);
323
- let newline;
324
- while ((newline = pending.indexOf(10)) !== -1) {
324
+ if (discard) {
325
+ const end = chunk.indexOf(10);
326
+ if (end === -1) continue;
327
+ chunk = chunk.subarray(end + 1); lineOffset = offset - chunk.length; discard = false;
328
+ }
329
+ let position = 0;
330
+ while (position < chunk.length) {
325
331
  signal?.throwIfAborted();
326
- if (newline > maxLineBytes || ++rows > maxRows) return null;
327
- const line = pending.subarray(0, newline), start = lineOffset;
328
- pending = pending.subarray(newline + 1); lineOffset += newline + 1;
329
- if (line.length && onLine(line, start) === false) return null;
332
+ const newline = chunk.indexOf(10, position);
333
+ const part = chunk.subarray(position, newline === -1 ? chunk.length : newline);
334
+ const length = pendingBytes + part.length;
335
+ if (length > maxLineBytes) {
336
+ if (!allowIncomplete) return null;
337
+ incomplete = true; fragments = []; pendingBytes = 0;
338
+ if (newline === -1) { discard = true; break; }
339
+ } else if (newline === -1) {
340
+ // Copy only fragments crossing reads; concatenate once at the newline.
341
+ // Repeatedly concatenating a multi-MiB line made scanning quadratic.
342
+ fragments.push(Buffer.from(part)); pendingBytes = length;
343
+ break;
344
+ } else {
345
+ if (++rows > maxRows) return null;
346
+ const line = fragments.length ? Buffer.concat([...fragments, part], length) : part;
347
+ fragments = []; pendingBytes = 0;
348
+ if (line.length && onLine(line, lineOffset, rows - 1) === false) return null;
349
+ }
350
+ position = newline + 1; lineOffset = offset - chunk.length + position;
330
351
  }
331
- if (pending.length > maxLineBytes) return null;
332
352
  }
333
- if (pending.length) return null;
334
- return { digest: hash.digest('hex'), byteLength: offset, rows };
353
+ if (pendingBytes) { if (!allowIncomplete) return null; incomplete = true; }
354
+ return { digest: hash.digest('hex'), byteLength: offset, rows, incomplete };
335
355
  }
336
356
 
337
357
  async function humanBoundaryFromHandle(handle, opened, path, session, cwd, request, signal) {
@@ -410,42 +430,42 @@ export function unchangedClaudeHumanBoundary(proof) {
410
430
  } catch { return false; }
411
431
  }
412
432
 
413
- async function conversationFromHandle(handle, opened, session, cwd, userOnly = false) {
433
+ async function conversationFromHandle(handle, opened, session, cwd, { userOnly = false, reviewUserContext = false, signal } = {}) {
414
434
  // Native review receipts can make a short user conversation exceed the usual
415
435
  // display tail. Bound the file separately and filter before bounding prose.
416
436
  const bytes = userOnly ? 64 * 1024 * 1024 : CONVERSATION_LIMITS.bytes;
417
437
  const offset = Math.max(0, opened.size - bytes);
418
- const buffer = Buffer.alloc(Math.min(opened.size, bytes));
419
- const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
420
- let text = buffer.subarray(0, bytesRead).toString('utf8');
421
- if (offset) text = text.slice(text.indexOf('\n') + 1);
422
- const messages = [];
423
- let incomplete = !text.endsWith('\n');
424
- const lines = text.split('\n');
425
- for (const [index, line] of lines.entries()) {
426
- if (!line.trim()) continue;
427
- let item;
428
- try { item = JSON.parse(line); } catch { incomplete = true; continue; }
429
- if (unscopedFileHistory(item)) continue;
430
- if (item.sessionId !== session || item.isSidechain === true) { incomplete = true; continue; }
431
- if (!['user', 'assistant'].includes(item.type) || item.isMeta === true) continue;
432
- if (typeof item.cwd !== 'string' || !isAbsolute(item.cwd) || !within(cwd, resolve(item.cwd))) { incomplete = true; continue; }
433
- const text = textContent(item.message?.content);
434
- // User tool_result envelopes are not a new human request. A missing
435
- // human message/attachment-only request cannot silently reuse an old goal.
436
- if (item.type === 'user' && !text.trim() && !item.message?.content?.every?.(part => part?.type === 'tool_result')) incomplete = true;
437
- if (userOnly && item.type !== 'user') continue;
438
- messages.push({ role: item.type, turnRef: typeof item.uuid === 'string' ? item.uuid : `line:${offset}:${index}`, text });
439
- }
438
+ const collector = createConversationCollector({ reviewUserContext });
439
+ let incomplete = false;
440
+ const scanned = await scanTranscriptLines(handle, opened, { start: offset, maxBytes: bytes, maxLineBytes: bytes, maxRows: Infinity, allowIncomplete: true, signal,
441
+ onLine(line, start, index) {
442
+ const encoded = line.toString('utf8');
443
+ if (!encoded.trim()) return;
444
+ let item;
445
+ try { item = JSON.parse(encoded); } catch { incomplete = true; return; }
446
+ if (unscopedFileHistory(item)) return;
447
+ if (item.sessionId !== session || item.isSidechain === true) { incomplete = true; return; }
448
+ if (!['user', 'assistant'].includes(item.type) || item.isMeta === true) return;
449
+ if (typeof item.cwd !== 'string' || !isAbsolute(item.cwd) || !within(cwd, resolve(item.cwd))) { incomplete = true; return; }
450
+ const text = textContent(item.message?.content);
451
+ // User tool_result envelopes are not a new human request. A missing
452
+ // human message/attachment-only request cannot silently reuse an old goal.
453
+ if (item.type === 'user' && !text.trim() && !item.message?.content?.every?.(part => part?.type === 'tool_result')) incomplete = true;
454
+ if (reviewUserContext && item.type === 'user' && Array.isArray(item.message?.content) &&
455
+ item.message.content.some(part => !['text', 'input_text', 'output_text', 'tool_result'].includes(part?.type))) incomplete = true;
456
+ if (userOnly && item.type !== 'user') return;
457
+ collector.add({ role: item.type, turnRef: typeof item.uuid === 'string' ? item.uuid : `line:${offset}:${index}`, text });
458
+ } });
440
459
  const after = await handle.stat();
441
- if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs) incomplete = true;
442
- return boundConversation(messages, { truncated: offset > 0, incomplete });
460
+ if (!scanned || scanned.incomplete || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) incomplete = true;
461
+ return collector.finish({ truncated: offset > 0, incomplete });
443
462
  }
444
463
 
445
- export async function readClaudeConversation(candidate, { home, signal, userOnly = false } = {}) {
464
+ export async function readClaudeConversation(candidate, { home, signal, userOnly = false, reviewUserContext = false } = {}) {
465
+ if (reviewUserContext && !userOnly) throw new Error('Review context must contain only original user requests.');
446
466
  if (signal?.aborted) return boundConversation(null);
447
467
  if (!UUID.test(candidate?.session || '') || typeof candidate?.cwd !== 'string') return boundConversation(null);
448
- const matches = await collect({ home, session: candidate.session, includeContext: userOnly ? 'user' : true, signal });
468
+ const matches = await collect({ home, session: candidate.session, includeContext: reviewUserContext ? 'review_user' : userOnly ? 'user' : true, signal });
449
469
  const selected = matches.filter(item => item.cwd === candidate.cwd);
450
470
  if (signal?.aborted || selected.length !== 1) return boundConversation(null);
451
471
  if (Number.isFinite(candidate.lastActivityAt) && selected[0].lastActivityAt !== candidate.lastActivityAt) {
@@ -497,49 +517,90 @@ export async function readClaudeStatistics(candidate, { home, signal } = {}) {
497
517
  // Consume only an exact native Agent invocation in the original session. A
498
518
  // worker checkpoint, forwarded quote, resumed child or tool-using child is not
499
519
  // an independent verdict. Native Claude accounts this child in its own budget.
500
- export async function readClaudeNativeReview(candidate, request, { home, signal } = {}) {
520
+ async function scanNativeRows(path, visit, { signal, expectedDigest } = {}) {
521
+ signal?.throwIfAborted();
522
+ if (await realpath(path) !== path) throw new Error('Native transcript link changed');
523
+ const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
524
+ try {
525
+ const before = await handle.stat();
526
+ if (!before.isFile()) throw new Error('Native transcript unavailable');
527
+ const decoder = new TextDecoder('utf-8', { fatal: true });
528
+ const scanned = await scanTranscriptLines(handle, before, { maxBytes: MAX_COMPLETE_TRANSCRIPT_BYTES, signal,
529
+ onLine: line => visit(JSON.parse(decoder.decode(line))) });
530
+ const after = await handle.stat();
531
+ const pathAfter = await lstat(path);
532
+ if (await realpath(path) !== path || pathAfter.isSymbolicLink() || pathAfter.ino !== before.ino || pathAfter.dev !== before.dev ||
533
+ before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs ||
534
+ pathAfter.size !== before.size || pathAfter.mtimeMs !== before.mtimeMs || pathAfter.ctimeMs !== before.ctimeMs || !scanned ||
535
+ expectedDigest && scanned.digest !== expectedDigest) throw new Error('Native transcript changed');
536
+ return { snapshot: [before.dev, before.ino, before.size, before.mtimeMs, before.ctimeMs].join(':'), digest: scanned.digest };
537
+ } finally { await handle.close(); }
538
+ }
539
+
540
+ async function nativeParentTranscript(candidate, { home, signal }, visit) {
501
541
  const matches = (await collect({ home, session: candidate.session, includeBoundary: true, signal }))
502
542
  .filter(row => row.cwd === candidate.cwd && row.boundary?.complete);
503
543
  if (matches.length !== 1) return null;
504
544
  const parentPath = matches[0].transcriptPath;
505
- const readRows = async path => {
506
- signal?.throwIfAborted();
507
- if (await realpath(path) !== path) throw new Error('Native transcript link changed');
508
- const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
509
- try {
510
- const before = await handle.stat();
511
- if (!before.isFile() || before.size > 64 * 1024 * 1024) throw new Error('Native transcript unavailable');
512
- const bytes = await handle.readFile('utf8'), after = await handle.stat();
513
- const pathAfter = await lstat(path);
514
- if (await realpath(path) !== path || pathAfter.isSymbolicLink() || pathAfter.ino !== before.ino || pathAfter.dev !== before.dev ||
515
- before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || !bytes.endsWith('\n')) throw new Error('Native transcript changed');
516
- return bytes.trim().split('\n').map(line => JSON.parse(line));
517
- } finally { await handle.close(); }
518
- };
545
+ const scanned = await scanNativeRows(parentPath, row => {
546
+ if (row.sessionId !== undefined && row.sessionId !== candidate.session ||
547
+ ['user', 'assistant'].includes(row.type) && (row.isSidechain === true || row.cwd !== candidate.cwd)) return false;
548
+ return visit(row);
549
+ }, { signal, expectedDigest: matches[0].boundary.digest });
550
+ return { parentPath, ...scanned };
551
+ }
552
+
553
+ export async function readClaudeNativeAgentContract(candidate, { home, signal } = {}) {
554
+ try {
555
+ const collector = createForegroundAgentContractCollector(candidate);
556
+ const parent = await nativeParentTranscript(candidate, { home, signal }, row => collector.add(row));
557
+ return parent ? collector.finish() : null;
558
+ } catch (error) { if (signal?.aborted) throw error; return null; }
559
+ }
560
+
561
+ export async function readClaudeNativeReview(candidate, request, { home, signal } = {}) {
519
562
  try {
520
- const rows = await readRows(parentPath);
521
- const calls = rows.filter(row => row.sessionId === candidate.session && !row.isSidechain && row.type === 'assistant' && Date.parse(row.timestamp) >= request.requestedAt)
522
- .flatMap(row => (row.message?.content || []).filter(part => part.type === 'tool_use' && ['Agent', 'Task'].includes(part.name) && part.input?.prompt === request.prompt));
523
- if (calls.length !== 1) return null;
524
- const call = calls[0];
525
- if (call.input.subagent_type !== request.agentType || call.input.run_in_background !== false ||
526
- Object.keys(call.input).some(key => !['description', 'prompt', 'subagent_type', 'run_in_background'].includes(key))) return null;
527
- const receipts = rows.filter(row => row.sessionId === candidate.session && !row.isSidechain && row.type === 'user' &&
528
- row.message?.content?.some?.(part => part.type === 'tool_result' && part.tool_use_id === call.id) && row.toolUseResult);
563
+ const calls = [], contracts = createForegroundAgentContractCollector(candidate);
564
+ const parent = await nativeParentTranscript(candidate, { home, signal }, row => {
565
+ if (row.sessionId === candidate.session && !row.isSidechain && row.type === 'assistant' && Date.parse(row.timestamp) >= request.requestedAt) {
566
+ for (const part of row.message?.content || []) {
567
+ if (part.type === 'tool_use' && ['Agent', 'Task'].includes(part.name) && part.input?.prompt === request.prompt)
568
+ calls.push({ call: part, contract: contracts.finish() });
569
+ if (calls.length > 1) return false;
570
+ }
571
+ }
572
+ contracts.add(row);
573
+ });
574
+ if (!parent || calls.length !== 1) return null;
575
+ const { parentPath } = parent, { call, contract } = calls[0];
576
+ if (call.input.subagent_type !== request.agentType || !isForegroundNativeReviewInput(call.name, call.input, contract?.schema)) return null;
577
+ const receipts = [];
578
+ const receiptScan = await scanNativeRows(parentPath, row => {
579
+ if (row.sessionId === candidate.session && !row.isSidechain && row.type === 'user' &&
580
+ row.message?.content?.some?.(part => part.type === 'tool_result' && part.tool_use_id === call.id) && row.toolUseResult) receipts.push(row);
581
+ if (receipts.length > 1) return false;
582
+ }, { signal, expectedDigest: parent.digest });
583
+ if (receiptScan.snapshot !== parent.snapshot) return null;
529
584
  if (receipts.length !== 1) return null;
530
585
  const result = receipts[0].toolUseResult;
531
586
  if (result.status !== 'completed' || result.prompt !== request.prompt || result.agentType !== request.agentType ||
532
587
  result.totalToolUseCount !== 0 || !/^a[a-f0-9]{8,40}$/.test(result.agentId || '')) return null;
533
588
  const childPath = join(parentPath.slice(0, -6), 'subagents', `agent-${result.agentId}.jsonl`);
534
- const child = await readRows(childPath);
535
- if (child.some(row => row.sessionId !== candidate.session || row.agentId !== result.agentId || ['user', 'assistant', 'attachment'].includes(row.type) && row.isSidechain !== true)) return null;
536
- const users = child.filter(row => row.type === 'user');
537
- const snapshots = child.filter(row => row.attachment?.type === 'prompt_snapshot').map(row => row.attachment);
538
- if (users.length !== 1 || textContent(users[0].message?.content) !== request.prompt ||
539
- !snapshots.some(row => Array.isArray(row.tools) && row.tools.length === 0) || snapshots.some(row => row.tools && row.tools.length !== 0 || !row.systemPrompt?.some(text => text.trim() === CLAUDE_NATIVE_REVIEW_PROMPT))) return null;
540
- const answers = child.filter(row => row.type === 'assistant');
541
- if (!answers.length || answers.some(row => row.message?.content?.some(part => part.type === 'tool_use'))) return null;
542
- const answer = textContent(answers.at(-1).message?.content);
589
+ let users = 0, answer = null, emptyTools = false;
590
+ await scanNativeRows(childPath, row => {
591
+ if (row.sessionId !== candidate.session || row.agentId !== result.agentId || ['user', 'assistant', 'attachment'].includes(row.type) && row.isSidechain !== true) return false;
592
+ if (row.type === 'user' && (++users !== 1 || textContent(row.message?.content) !== request.prompt)) return false;
593
+ if (row.attachment?.type === 'prompt_snapshot') {
594
+ const snapshot = row.attachment;
595
+ if (snapshot.tools && snapshot.tools.length !== 0 || !snapshot.systemPrompt?.some(text => text.trim() === CLAUDE_NATIVE_REVIEW_PROMPT)) return false;
596
+ emptyTools ||= Array.isArray(snapshot.tools) && snapshot.tools.length === 0;
597
+ }
598
+ if (row.type === 'assistant') {
599
+ if (row.message?.content?.some?.(part => part.type === 'tool_use')) return false;
600
+ answer = textContent(row.message?.content);
601
+ }
602
+ }, { signal });
603
+ if (users !== 1 || !emptyTools || answer === null) return null;
543
604
  if (answer !== textContent(result.content)) return null;
544
605
  const output = JSON.parse(answer.trim().replace(/^```(?:json)?\s*\n([\s\S]*?)\n```$/, '$1'));
545
606
  if (typeof output.passed !== 'boolean' || typeof output.inspectionAvailable !== 'boolean' || typeof output.summary !== 'string' || output.summary.length > 4000 ||
@@ -28,7 +28,7 @@ import { CODEX_WRITER_LOCK_MINIMUM_VERSION, compareVersions, inspectBackendExecu
28
28
  import { hasPendingUserStop, readRecord, recordPath, rootDirectory, canExecuteAutonomousExternal, assessedCheckpointContract, deliveryProgressContext, manualAcceptanceFeedback, manualAcceptanceFeedbackInstruction, taskDirectory, understandingCheckpointContract } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
29
29
  import { boundConversation, boundProviderGoal, textContent } from './conversation-context.js';
30
30
  import { JsonDataUrlFilter } from './json-data-url-filter.js';
31
- import { RECENT_SESSION_LIMIT } from './discovery-window.js';
31
+ import { RECENT_SESSION_LIMIT, liveSessionWindow } from './discovery-window.js';
32
32
 
33
33
  const REQUEST_TIMEOUT = 15_000;
34
34
  const MAX_LINE = 256 * 1024;
@@ -307,7 +307,7 @@ export async function listCodexSessions({ client, all = false, signal, onPage, m
307
307
  // reports notLoaded rather than the original writer's actual turn state.
308
308
  // Bound this metadata fan-out independently of the analysis worker pool.
309
309
  const priorities = new Set(prioritySessions);
310
- const ordered = [...sessions].sort((a, b) => Number(priorities.has(b.session)) - Number(priorities.has(a.session)) || (b.lastActivityAt || 0) - (a.lastActivityAt || 0));
310
+ const ordered = liveSessionWindow(sessions, { priority: item => priorities.has(item.session) });
311
311
  let next = 0, unavailable = false;
312
312
  for (const metadata of sessions) metadata.goalReadState = 'unavailable';
313
313
  await Promise.all(Array.from({ length: Math.min(3, sessions.length) }, async () => {
@@ -355,7 +355,7 @@ export async function readCodexArchiveStates(client, candidates, { budgetMs = 30
355
355
  const offset = (archiveProbeOffsets.get(codexHome) || 0) % Math.max(1, missing.length);
356
356
  let next = 0;
357
357
  await Promise.all(Array.from({ length: Math.min(3, missing.length) }, async () => {
358
- while (next < missing.length && Date.now() < deadline) {
358
+ while (next < Math.min(missing.length, RECENT_SESSION_LIMIT) && Date.now() < deadline) {
359
359
  const candidate = missing[(offset + next++) % missing.length];
360
360
  try {
361
361
  const response = await boundedClient.request('thread/read', { threadId: candidate.session, includeTurns: false });
@@ -23,14 +23,14 @@ export async function readCodexChildSessions({ parents = [], includeArchived = f
23
23
  db = new DatabaseSync(path, { readOnly: true });
24
24
  db.exec('PRAGMA query_only = ON; PRAGMA busy_timeout = 50');
25
25
  const rows = db.prepare(`WITH RECURSIVE family(id, depth) AS (
26
- SELECT id, 0 FROM threads WHERE id IN (${roots.map(() => '?').join(',')})
26
+ SELECT id, 0 FROM threads WHERE id IN (SELECT value FROM json_each(?))
27
27
  UNION SELECT e.child_thread_id, f.depth + 1 FROM thread_spawn_edges e
28
28
  JOIN family f ON f.id = e.parent_thread_id WHERE f.depth < 32
29
29
  ) SELECT t.id, t.cwd, t.name, t.source, t.model, t.updated_at, t.archived,
30
30
  e.parent_thread_id, MIN(f.depth) AS depth
31
31
  FROM family f JOIN threads t ON t.id = f.id
32
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);
33
+ WHERE f.depth > 0 GROUP BY t.id ORDER BY depth, t.updated_at DESC LIMIT 2000`).all(JSON.stringify(roots));
34
34
  const candidates = [];
35
35
  for (const row of rows) {
36
36
  let source;
@@ -1,6 +1,8 @@
1
1
  // Only human/assistant prose crosses the analysis boundary. Tool payloads,
2
2
  // attachments, paths and provider envelopes are deliberately excluded.
3
3
  export const CONVERSATION_LIMITS = Object.freeze({ messages: 32, text: 4_000, total: 24_000, bytes: 512 * 1024 });
4
+ // Independent review preserves complete original requests within its own budget.
5
+ export const REVIEW_USER_CONTEXT_LIMITS = Object.freeze({ messages: 32, text: 64 * 1024, total: 128 * 1024 });
4
6
  const CREDENTIAL_KEY = 'api[_ -]?key|access[_ -]?token|password|authorization';
5
7
  const QUOTED_CREDENTIAL = new RegExp(String.raw`(["'])(${CREDENTIAL_KEY})\1(\s*:\s*)(["'])(?:\\.|(?!\4)[^\\])*\4`, 'gi');
6
8
  const PLAIN_CREDENTIAL = new RegExp(String.raw`\b(${CREDENTIAL_KEY})\s*[:=]\s*(?:Bearer\s+)?[^\s,;]+`, 'gi');
@@ -41,32 +43,62 @@ export function boundProviderGoal(goal) {
41
43
  ...(goal.textTruncated === true || objective.length > 4000 ? { textTruncated: true } : {}) };
42
44
  }
43
45
 
46
+ // Streaming readers and array callers share one prose-selection policy. Keep
47
+ // the first goal, latest messages and latest-user identity without retaining
48
+ // an entire transcript or sanitizing/trimming an already clipped message twice.
49
+ export function createConversationCollector({ reviewUserContext = false } = {}) {
50
+ const limits = reviewUserContext ? REVIEW_USER_CONTEXT_LIMITS : CONVERSATION_LIMITS;
51
+ const prose = [];
52
+ let count = 0, clipped = false, latestUser = null, foundRedacted = false;
53
+ return {
54
+ add(message) {
55
+ if (!['user', 'assistant'].includes(message?.role) || reviewUserContext && message.role !== 'user') return;
56
+ const text = sanitizedText(message.text);
57
+ if (reviewUserContext) {
58
+ foundRedacted ||= text.includes('[REDACTED]');
59
+ clipped ||= message.textTruncated === true;
60
+ }
61
+ if (!text) return;
62
+ if (text.length > limits.text) clipped = true;
63
+ const normalized = { role: message.role, turnRef: sanitizedText(message.turnRef).slice(0, 160) || `message:${count}`,
64
+ textTruncated: message.textTruncated === true || text.length > limits.text,
65
+ text: text.slice(0, limits.text) };
66
+ count++; prose.push(normalized);
67
+ if (normalized.role === 'user') latestUser = normalized;
68
+ if (prose.length > limits.messages + 1) prose.splice(1, 1);
69
+ },
70
+ finish({ truncated = false, incomplete = false, redacted = false } = {}) {
71
+ redacted ||= foundRedacted;
72
+ incomplete ||= reviewUserContext && redacted;
73
+ truncated ||= clipped || prose.length > limits.messages;
74
+ // Preserve goal provenance as well as the latest user/assistant exchange.
75
+ const selected = prose.length > limits.messages ? [prose[0], ...prose.slice(-(limits.messages - 1))] : prose;
76
+ let total = 0;
77
+ const result = [];
78
+ // Spend the bounded budget from newest to oldest; never trim a latest
79
+ // request to make room for early goal provenance.
80
+ for (const message of selected.toReversed()) {
81
+ if (total + message.text.length > limits.total) { truncated = true; continue; }
82
+ result.unshift(message);
83
+ total += message.text.length;
84
+ }
85
+ const finalMessage = prose.at(-1);
86
+ if (!latestUser || !result.includes(latestUser) || latestUser.textTruncated || finalMessage?.textTruncated) incomplete = true;
87
+ return { messages: result.map(({ textTruncated, ...message }) => ({ ...message, ...(textTruncated ? { textTruncated: true } : {}) })), truncated, incomplete, ...(reviewUserContext ? { redacted } : {}) };
88
+ },
89
+ };
90
+ }
91
+
44
92
  export function boundConversation(messages, { truncated = false, incomplete = false } = {}) {
45
93
  if (!Array.isArray(messages)) return { messages: [], truncated: false, incomplete: true };
46
- const prose = [];
47
- for (const message of messages) {
48
- if (!['user', 'assistant'].includes(message?.role)) continue;
49
- const text = sanitizedText(message.text);
50
- if (!text) continue;
51
- if (text.length > CONVERSATION_LIMITS.text) truncated = true;
52
- prose.push({ role: message.role, turnRef: sanitizedText(message.turnRef).slice(0, 160) || `message:${prose.length}`,
53
- textTruncated: message.textTruncated === true || text.length > CONVERSATION_LIMITS.text,
54
- text: text.slice(0, CONVERSATION_LIMITS.text) });
55
- }
56
- if (prose.length > CONVERSATION_LIMITS.messages) truncated = true;
57
- // Preserve goal provenance as well as the latest user/assistant exchange.
58
- const selected = prose.length > CONVERSATION_LIMITS.messages ? [prose[0], ...prose.slice(-(CONVERSATION_LIMITS.messages - 1))] : prose;
59
- let total = 0;
60
- const result = [];
61
- // Spend the bounded budget from newest to oldest; never trim a latest
62
- // request to make room for early goal provenance.
63
- for (const message of selected.toReversed()) {
64
- if (total + message.text.length > CONVERSATION_LIMITS.total) { truncated = true; continue; }
65
- result.unshift(message);
66
- total += message.text.length;
67
- }
68
- const latestUser = prose.findLast(message => message.role === 'user');
69
- const finalMessage = prose.at(-1);
70
- if (!latestUser || !result.includes(latestUser) || latestUser.textTruncated || finalMessage?.textTruncated) incomplete = true;
71
- return { messages: result.map(({ textTruncated, ...message }) => ({ ...message, ...(textTruncated ? { textTruncated: true } : {}) })), truncated, incomplete };
94
+ const collector = createConversationCollector();
95
+ for (const message of messages) collector.add(message);
96
+ return collector.finish({ truncated, incomplete });
97
+ }
98
+
99
+ export function boundReviewUserContext(messages, { truncated = false, incomplete = false, redacted = false } = {}) {
100
+ if (!Array.isArray(messages)) return { messages: [], truncated: false, incomplete: true, redacted };
101
+ const collector = createConversationCollector({ reviewUserContext: true });
102
+ for (const message of messages) collector.add(message);
103
+ return collector.finish({ truncated, incomplete, redacted });
72
104
  }
@@ -4,14 +4,16 @@ export const RECENT_SESSION_LIMIT = 200;
4
4
 
5
5
  // Explicit tracking and unsettled work remain visible outside recent history.
6
6
  // Completed, untracked records are history, not an unlimited extra catalog.
7
- export const retainedSession = record => Boolean(record?.listPinned || record?.autoContinue || record?.monitor || record?.tracked ||
7
+ export const supervisedSession = record => Boolean(record?.autoContinue || record?.monitor || record?.tracked ||
8
8
  record?.run && !TERMINAL.has(record.run.state));
9
+ export const retainedSession = record => Boolean(record?.listPinned || supervisedSession(record));
9
10
 
10
- export function recentSessionWindow(items, { limit = RECENT_SESSION_LIMIT, priority = () => false } = {}) {
11
+ export function recentSessionWindow(items, { limit = RECENT_SESSION_LIMIT, priority = () => false, includeAncestors = true } = {}) {
11
12
  const recent = [...items].sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0) || a.session.localeCompare(b.session));
12
13
  const retained = [], history = [];
13
14
  for (const item of recent) (priority(item) ? retained : history).push(item);
14
15
  const selected = [...retained, ...history.slice(0, Math.max(0, limit - retained.length))];
16
+ if (!includeAncestors) return selected;
15
17
  const byKey = new Map(items.map(item => [`${item.provider}:${item.session}`, item]));
16
18
  const included = new Set(selected.map(item => `${item.provider}:${item.session}`));
17
19
  // Keep observed ancestors so a recent child does not silently become a root.
@@ -25,3 +27,7 @@ export function recentSessionWindow(items, { limit = RECENT_SESSION_LIMIT, prior
25
27
  }
26
28
  return selected;
27
29
  }
30
+
31
+ // Tree visibility and navigation pins do not subscribe all ancestors to live
32
+ // work. Explicit supervision and owned jobs are supplied by each caller.
33
+ export const liveSessionWindow = (items, options = {}) => recentSessionWindow(items, { ...options, includeAncestors: false });