@awak-app/simy-cli 0.7.36 → 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.
- package/README.md +36 -36
- package/package.json +1 -1
- package/src/agent.js +1 -1
- package/src/auto-update.js +25 -13
- package/src/backend-executable.js +1 -1
- package/src/cli-contract.js +1 -1
- package/src/cli-observability.js +2 -2
- package/src/codex-plugins/simy/.codex-plugin/plugin.json +5 -5
- package/src/codex-plugins/simy/hooks/README.md +3 -3
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +242 -26
- package/src/codex-plugins/simy/hooks/purpose-workspace.mjs +4 -1
- package/src/codex-plugins/simy/hooks/registered-requirement-checker.mjs +201 -0
- package/src/codex-plugins/simy/hooks/requirement-acceptance.mjs +69 -0
- package/src/codex-plugins/simy/skills/continue/SKILL.md +4 -4
- package/src/codex-plugins/simy/skills/continue/agents/openai.yaml +3 -3
- package/src/codex-plugins/simy/skills/diagnose/SKILL.md +2 -2
- package/src/codex-plugins/simy/skills/sqm-loop/SKILL.md +3 -3
- package/src/codex-plugins/simy/skills/sqm-loop/agents/openai.yaml +2 -2
- package/src/console/app.js +7 -7
- package/src/delivery-hooks/claude-workflow.md +1 -1
- package/src/delivery-hooks/command.js +5 -5
- package/src/delivery-hooks/supervisor-watchdog.js +3 -2
- package/src/desktop-host.js +53 -23
- package/src/index.js +1 -1
- package/src/local-task.js +1 -1
- package/src/orchestrator/presentation.js +1 -1
- package/src/runner.js +2 -2
- package/src/session-supervision/acceptance-reads.js +11 -2
- package/src/session-supervision/analysis-manager.js +9 -2
- package/src/session-supervision/automatic-continuation.js +11 -11
- package/src/session-supervision/catalog-refresh.js +16 -0
- package/src/session-supervision/claude-native-agent-contract.js +62 -0
- package/src/session-supervision/claude-outcome-review.js +7 -6
- package/src/session-supervision/claude-sessions.js +137 -76
- package/src/session-supervision/codex-app-server.js +3 -3
- package/src/session-supervision/codex-children.js +2 -2
- package/src/session-supervision/continuation-analysis.js +1 -1
- package/src/session-supervision/continuation-command.js +3 -3
- package/src/session-supervision/continuation-readiness.js +1 -1
- package/src/session-supervision/conversation-context.js +58 -26
- package/src/session-supervision/conversation-presentation.js +3 -3
- package/src/session-supervision/decision-presentation.js +8 -8
- package/src/session-supervision/discovery-window.js +8 -2
- package/src/session-supervision/index.js +38 -25
- package/src/session-supervision/purpose-verification.js +12 -3
- package/src/session-supervision/remote-errors.js +2 -2
- package/src/session-supervision/work-state.js +3 -3
- package/src/sqm/advisory-guidance.js +1 -0
- package/src/sqm/agent-skill.js +3 -3
- package/src/sqm/checkers.js +35 -1
- package/src/sqm/claude-plugin.js +8 -2
- package/src/sqm/hypothesis-knowledge.js +3 -3
- package/src/sqm/knowledge-recall.js +34 -12
- package/src/sqm/reasoned-assessment.js +22 -9
- package/src/update-detachment/runtime.js +2 -1
|
@@ -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,
|
|
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 =
|
|
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
|
-
|
|
322
|
+
let chunk = buffer.subarray(0, bytesRead);
|
|
321
323
|
hash.update(chunk); onChunk(chunk, offset); offset += bytesRead;
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
if (
|
|
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 (
|
|
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
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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 =
|
|
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 (
|
|
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(
|
|
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;
|
|
@@ -83,7 +83,7 @@ async function unavailableAnalysis(candidate, error, inspectProvider, signal) {
|
|
|
83
83
|
if (signal?.aborted) return uncertain('Conversation analysis was cancelled.', 'cancelled');
|
|
84
84
|
if (error?.code === 'provider_sign_in_required' || status?.state === 'signed_out') {
|
|
85
85
|
const label = candidate.provider === 'claude' ? 'Claude Code' : 'Codex';
|
|
86
|
-
return uncertain(`Sign in to ${label}. SIMY rechecks this conversation automatically when sign-in is detected. You can also retry manually. Unfinished work
|
|
86
|
+
return uncertain(`Sign in to ${label}. SIMY rechecks this conversation automatically when sign-in is detected. You can also retry manually. Unfinished work continues only when "Continue automatically" is on.`, 'provider_sign_in_required');
|
|
87
87
|
}
|
|
88
88
|
return uncertain('The local AI app could not check this turn. Check its connection, then retry.', 'analysis_unavailable');
|
|
89
89
|
}
|
|
@@ -8,7 +8,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
8
|
export async function runContinuationCommand(args, { root, output, signal,
|
|
9
9
|
request = requestOwnerContinuation } = {}) {
|
|
10
10
|
if (args.includes('--help') || args.includes('-h')) {
|
|
11
|
-
output('simy delivery auto-continue on|off|status --provider codex|claude --session UUID\
|
|
11
|
+
output('simy delivery auto-continue on|off|status --provider codex|claude --session UUID\nContinue this conversation automatically: turns Delivery Loop on or off for one exact conversation. Uses the running local SIMY app and its existing controls. ON saves delegation; only workState describes execution. Explicit stops, approvals, original goals and history remain in force.');
|
|
12
12
|
return 0;
|
|
13
13
|
}
|
|
14
14
|
const [operation, ...flags] = args;
|
|
@@ -34,12 +34,12 @@ export async function runContinuationCommand(args, { root, output, signal,
|
|
|
34
34
|
try {
|
|
35
35
|
const receipt = await request({ root, signal, command: command('session_get_status') });
|
|
36
36
|
output(JSON.stringify({ ...receipt, provider, session, accepted,
|
|
37
|
-
message: receipt?.message || (operation === 'on' && accepted ? '
|
|
37
|
+
message: receipt?.message || (operation === 'on' && accepted ? 'Delivery Loop delegation saved. Read workState and its nextAction for actual progress; this acknowledgement does not prove a provider turn executed.' : undefined) }));
|
|
38
38
|
return receipt?.ok ? 0 : 2;
|
|
39
39
|
} catch (error) {
|
|
40
40
|
if (!accepted) throw error;
|
|
41
41
|
output(JSON.stringify({ ok: false, provider, session, accepted, code: error.code || 'status_unavailable',
|
|
42
|
-
message: 'The setting was saved, but execution status could not be read. Check auto-continue status
|
|
42
|
+
message: 'The setting was saved, but execution status could not be read. Check `simy delivery auto-continue status`; do not repeat ON to infer execution.' }));
|
|
43
43
|
return 2;
|
|
44
44
|
}
|
|
45
45
|
}
|
|
@@ -54,5 +54,5 @@ export function projectContinuationWait(candidate, wait, now, noticeAfterMs = CO
|
|
|
54
54
|
const needsAttention = wait.reasonCode === 'analysis_retry_exhausted' || now - wait.since >= noticeAfterMs;
|
|
55
55
|
return { ...candidate, continuationWait: { id: wait.id, since: wait.since, reasonCode: wait.reasonCode, needsAttention },
|
|
56
56
|
...(needsAttention ? { autoContinueStatus: 'needs_user',
|
|
57
|
-
autoContinueReason: '
|
|
57
|
+
autoContinueReason: 'Delivery Loop is on, but work has been waiting for execution conditions. Check the original conversation or refresh its connection.' } : {}) };
|
|
58
58
|
}
|
|
@@ -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
|
|
47
|
-
for (const message of messages)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
}
|
|
@@ -82,7 +82,7 @@ export function conversationPresentation(candidate = {}, projectedRun, { rawReco
|
|
|
82
82
|
const pendingDecision = ['approval', 'question'].includes(work.state) || field('pendingApproval') && !field('pendingApproval').denied ||
|
|
83
83
|
field('understandingQuestion') || managed.userQuestion;
|
|
84
84
|
if (candidate.autoContinueStatus === 'save_failed') {
|
|
85
|
-
ready = readiness('save_failed', 'danger', reason?.text || 'Could not save
|
|
85
|
+
ready = readiness('save_failed', 'danger', reason?.text || 'Could not save Delivery Loop. Retry.');
|
|
86
86
|
} else if (candidate.autoContinueStatus === 'saving') {
|
|
87
87
|
ready = readiness('saving', 'accent', 'Saving On…');
|
|
88
88
|
} else if (candidate.autoContinue !== true) {
|
|
@@ -116,13 +116,13 @@ export function conversationPresentation(candidate = {}, projectedRun, { rawReco
|
|
|
116
116
|
} else if (terminal) {
|
|
117
117
|
ready = readiness('finished', 'secondary', state === 'reported_done' ? 'The AI reported completion; verification is pending.' : 'No current work to continue.');
|
|
118
118
|
} else if (candidate.provider === 'claude' && candidate.isRunning === false && !['starting', 'recovering'].includes(candidate.autoContinueStatus)) {
|
|
119
|
-
ready = readiness('original_turn_required', 'attention', 'Send one message in the original conversation.
|
|
119
|
+
ready = readiness('original_turn_required', 'attention', 'Send one message in the original conversation. Delivery Loop waits for its next verified hook.', action('open_original', 'Open original conversation'));
|
|
120
120
|
} else if (control && candidate.autoContinueStatus === 'supervising' && ['active', 'verification_pending'].includes(state) &&
|
|
121
121
|
ownership === 'simy_continues' && blocker.state === 'ready' &&
|
|
122
122
|
!field('executionUncertain') && !field('providerWarning') && !managed.nativeWait && !managed.purpose?.nativeBoundary) {
|
|
123
123
|
ready = readiness('ready', 'accent', 'Will continue after this turn');
|
|
124
124
|
} else if (['starting', 'recovering'].includes(candidate.autoContinueStatus)) {
|
|
125
|
-
ready = readiness(candidate.autoContinueStatus, 'accent', reason?.text || 'Checking
|
|
125
|
+
ready = readiness(candidate.autoContinueStatus, 'accent', reason?.text || 'Checking Delivery Loop readiness.');
|
|
126
126
|
} else {
|
|
127
127
|
ready = readiness('waiting', 'secondary', reason?.text || 'Checking whether the next step can be sent.', action('refresh', 'Refresh status'));
|
|
128
128
|
}
|
|
@@ -78,8 +78,8 @@ export function sessionDecisionPresentation(candidate, run) {
|
|
|
78
78
|
const acceptancePending = run.state === 'verification_pending';
|
|
79
79
|
const providerName = candidate.provider === 'claude' ? 'Claude' : candidate.provider === 'codex' ? 'Codex' : 'AI';
|
|
80
80
|
const reasons = {
|
|
81
|
-
session_end: pair(`元の${providerName}
|
|
82
|
-
process_exit: pair('元のAI
|
|
81
|
+
session_end: pair(`元の${providerName}会話が終了し、Delivery Loop が止まっています。`, `The original ${providerName} session ended, stopping Delivery Loop.`),
|
|
82
|
+
process_exit: pair('元のAIが終了したため、Delivery Loop を続けられません。', 'The original AI process exited. Delivery Loop cannot proceed.'),
|
|
83
83
|
provider_error: pair('AIの実行でエラーが発生し、作業が止まっています。', 'An AI execution error stopped the work.'),
|
|
84
84
|
shutdown_cancelled: pair('SIMYの終了時に作業が中断されました。', 'Work was interrupted when SIMY exited.'),
|
|
85
85
|
hook_owner_mismatch: pair('元のAI会話の接続が変わりました。', 'The connection to the original AI session changed.'),
|
|
@@ -91,9 +91,9 @@ export function sessionDecisionPresentation(candidate, run) {
|
|
|
91
91
|
const choices = [];
|
|
92
92
|
if (run.canReplan && frozen) choices.push(choice(pair('AIに状況確認を任せる', 'Ask AI to investigate'), pair('目的・履歴・制限を維持して原因と安全な次の手順を調べます。操作の許可を追加することはありません。', 'Investigate the cause and next steps while keeping the goal, history and limits. No new permission is granted.'), 'delivery_replan', {}, 'primary'));
|
|
93
93
|
const enablesAutoContinue = candidate.provider === 'codex' && run.managed !== true && run.state === 'stopped' && candidate.autoContinue !== true;
|
|
94
|
-
if (run.canResume && frozen) choices.push(choice(pair(enablesAutoContinue ? '
|
|
94
|
+
if (run.canResume && frozen) choices.push(choice(pair(enablesAutoContinue ? 'Delivery Loop をオンにして再開' : acceptancePending ? '未完了の作業を続ける' : '再開を依頼する', enablesAutoContinue ? 'Resume with Delivery Loop' : acceptancePending ? 'Continue remaining work' : 'Request resume'), pair(enablesAutoContinue ? 'Delivery Loop をオンにして元の会話の読み取り専用の状況確認から再開します。元の目的・権限・停止履歴・予算を維持します。' : acceptancePending ? '元の目的・履歴・制限を維持して残作業を確認します。最終確認を済ませたことにはしません。' : '保存した目的と履歴を引き継ぎます。接続や権限が不足する場合は、その内容を案内します。', enablesAutoContinue ? 'Turn on Delivery Loop and resume with a read-only status check in the original conversation. Preserve its goal, permissions, stop history and budgets.' : acceptancePending ? 'Keep the goal, history and limits while checking what remains. This does not accept the result.' : 'Keep the saved goal and history. Any missing connection or permission will be shown.'), 'delivery_resume', {}, choices.length ? 'secondary' : 'primary'));
|
|
95
95
|
choices.push(choice(pair(`元の${providerName}会話を開く`, 'Open original conversation ↗'), pair('接続や権限の確認が必要な場合、元のAI会話で確認できます。会話を開くだけでは作業を再開しません。', 'Check the original conversation. Opening it does not resume work.'), 'open_original', {}, 'link'));
|
|
96
|
-
if (candidate.autoContinue === true) choices.push(choice(pair('
|
|
96
|
+
if (candidate.autoContinue === true) choices.push(choice(pair('Delivery Loop をオフにする', 'Turn off Delivery Loop'), pair('今後の Delivery Loop をオフにします。作業の履歴は残ります。', 'Turn off Delivery Loop for future work and retain the history.'), 'session_set_auto_continue', { ...identity, enabled: false }));
|
|
97
97
|
const actionable = frozen && (run.canReplan || run.canResume);
|
|
98
98
|
return model('recovery', {
|
|
99
99
|
title: pair(acceptancePending ? '最終確認を待ちながら、残作業を続けられます' : '作業が止まっています。対応を選んでください', acceptancePending ? 'Continue work while acceptance is pending' : 'Work stopped. Choose how to proceed'),
|
|
@@ -105,12 +105,12 @@ export function sessionDecisionPresentation(candidate, run) {
|
|
|
105
105
|
candidate.capabilities?.canAutoContinue === true && candidate.autoContinueAvailability?.canEnable !== false) return model('continue', {
|
|
106
106
|
title: pair('続けますか?', 'Continue?'),
|
|
107
107
|
question: pair(clean(candidate.analysis.reason) || '許可済みの残作業があります。', clean(candidate.analysis.reason) || 'Authorized work remains.'),
|
|
108
|
-
context: pair('
|
|
109
|
-
}, { choices: [choice(pair('
|
|
108
|
+
context: pair('Delivery Loop はオフです。オンにすると、元の目的と権限の範囲で残作業を確認します。結果を受け入れる操作ではありません。', 'Delivery Loop is off. Turning it on checks remaining work within the original goal and permissions. It does not accept the result.'),
|
|
109
|
+
}, { choices: [choice(pair('Delivery Loop をオンにする', 'Turn on Delivery Loop'), pair('Delivery Loop の設定を保存します。作業の開始や完了を証明するものではありません。', 'Save consent for Delivery Loop. This does not confirm that work started or finished.'), 'session_set_auto_continue', { ...identity, enabled: true }, 'primary'),
|
|
110
110
|
choice(pair('元の会話を開く', 'Open original conversation ↗'), pair('会話を開くだけでは作業を再開しません。', 'Opening the conversation does not resume work.'), 'open_original', {}, 'link')] });
|
|
111
111
|
if (candidate.continuationWait?.needsAttention === true && candidate.autoContinue === true) return model('continuation_wait', {
|
|
112
|
-
title: pair('
|
|
113
|
-
question: pair('
|
|
112
|
+
title: pair('Delivery Loop の条件を確認してください', 'Check Delivery Loop conditions'),
|
|
113
|
+
question: pair('Delivery Loop はオンのまま、実行条件の確認を待っています。元の会話や接続状態を確認してください。', 'Delivery Loop is still on, waiting for execution conditions. Check the original conversation or its connection.'),
|
|
114
114
|
context: pair('設定・目的・停止指示・承認待ちは維持しています。この通知で作業を再開したり、権限を追加したりしません。', 'The setting, goal, stops and pending approvals are preserved. This notice does not resume work or grant permission.'),
|
|
115
115
|
}, { choices: [choice(pair('状況を再確認する', 'Refresh status'), pair('条件を再確認します。', 'Recheck conditions.'), 'refresh', identity),
|
|
116
116
|
choice(pair('元の会話を開く', 'Open original conversation ↗'), pair('元の会話を確認します。', 'Check the original conversation.'), 'open_original', {}, 'link')] }, 'helper');
|
|
@@ -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
|
|
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 });
|