@volter/supercode-interchange 0.1.22 → 0.1.24
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/LICENSE-diffy-MIT +23 -0
- package/README.md +422 -2
- package/channel-records.mjs +71 -0
- package/channel-session-counts.mjs +81 -0
- package/channels-read.mjs +90 -0
- package/corpus-audit-records.mjs +192 -0
- package/corpus-audit-render.mjs +47 -0
- package/corpus-audit-schema.mjs +89 -0
- package/corpus-audit.d.ts +16 -0
- package/corpus-audit.mjs +132 -0
- package/file-io.mjs +21 -2
- package/harness-settings.mjs +19 -0
- package/hermes-fire-links.mjs +135 -0
- package/hermes-fires.mjs +106 -0
- package/hermes-read-home.d.ts +3 -0
- package/hermes-read-home.mjs +235 -0
- package/hermes-read-store.mjs +49 -0
- package/hermes-read-validation.mjs +170 -0
- package/hermes-read-workflow.mjs +164 -0
- package/hermes-read-yaml.mjs +68 -0
- package/hermes-store.mjs +1 -1
- package/homes.mjs +2 -2
- package/index.d.ts +14 -1
- package/index.mjs +1 -1
- package/inspection-builtin-tools.json +261 -0
- package/inspection-config-fields.json +765 -0
- package/inspection-schemas.d.ts +4 -0
- package/inspection-schemas.mjs +117 -0
- package/job-notepad-read.mjs +97 -0
- package/jobs-control.mjs +250 -0
- package/jobs-read.mjs +198 -0
- package/jsonl.mjs +4 -3
- package/memory-show.mjs +187 -0
- package/metadata.mjs +45 -3
- package/native-json.mjs +46 -3
- package/ontology-contracts.d.ts +5 -0
- package/ontology-contracts.mjs +29 -0
- package/ontology-operations.json +198 -0
- package/ontology-schemas.json +7946 -0
- package/openclaw-jobs-read.mjs +117 -0
- package/openclaw-profile-home.mjs +142 -0
- package/orchestrator-read-records.mjs +112 -0
- package/orchestrator-store.mjs +21 -9
- package/package.json +59 -4
- package/parity-ledger.json +6984 -0
- package/parity-orchestration.json +5538 -0
- package/parity-report.d.ts +7 -0
- package/parity-report.mjs +46 -0
- package/preset-profiles.mjs +27 -0
- package/profile-command.mjs +32 -0
- package/profile-orchestrator-door.mjs +104 -0
- package/profile-paths.mjs +25 -0
- package/profiles-control.mjs +98 -0
- package/profiles-read.mjs +119 -0
- package/reduction-attribution.mjs +54 -0
- package/reduction-diff.mjs +165 -0
- package/reduction-log.mjs +177 -0
- package/reduction-view.mjs +145 -0
- package/retained-projection.mjs +9 -0
- package/routes-read.mjs +103 -0
- package/runs-read.mjs +224 -0
- package/session-activity.d.ts +23 -0
- package/session-activity.mjs +124 -0
- package/session-append-scan.mjs +27 -17
- package/session-branch-handoff.mjs +115 -0
- package/session-claude-graph.mjs +29 -7
- package/session-claude-index.mjs +732 -114
- package/session-claude-runtime.d.ts +28 -0
- package/session-claude-runtime.mjs +171 -6
- package/session-claude-scan-store.mjs +164 -0
- package/session-claude.mjs +9 -0
- package/session-codex-activity.d.ts +6 -0
- package/session-codex-activity.mjs +120 -0
- package/session-codex-window.mjs +24 -4
- package/session-config-scan.mjs +141 -0
- package/session-convert.d.ts +33 -0
- package/session-convert.mjs +179 -0
- package/session-emission-version.json +1 -0
- package/session-file.mjs +11 -1
- package/session-follower.mjs +17 -5
- package/session-fresh-id.mjs +15 -0
- package/session-hermes-import.mjs +61 -0
- package/session-hermes.mjs +1 -1
- package/session-import.mjs +37 -0
- package/session-inspect.d.ts +18 -0
- package/session-inspect.mjs +122 -0
- package/session-machine-tree.d.ts +33 -0
- package/session-machine-tree.mjs +274 -0
- package/session-materialize-files.mjs +78 -0
- package/session-materialize-opencode.mjs +137 -0
- package/session-materialize.mjs +186 -0
- package/session-message.mjs +85 -0
- package/session-model.d.ts +9 -0
- package/session-model.mjs +1 -0
- package/session-opencode-store.mjs +39 -3
- package/session-path.mjs +6 -3
- package/session-pi-content.mjs +7 -2
- package/session-read.d.ts +134 -2
- package/session-read.mjs +86 -9
- package/session-recorded-config.mjs +3 -2
- package/session-records.mjs +12 -6
- package/session-residue-store.mjs +90 -0
- package/session-residue.mjs +150 -0
- package/session-resume-instructions.mjs +1 -1
- package/session-saved-read.mjs +105 -0
- package/session-transfer.mjs +141 -0
- package/session-views.mjs +1 -0
- package/session-write-claude.mjs +135 -0
- package/session-write-codex.mjs +146 -0
- package/session-write-gemini.mjs +77 -0
- package/session-write-goose.mjs +99 -0
- package/session-write-grok.mjs +70 -0
- package/session-write-native.mjs +41 -0
- package/session-write-opencode.mjs +197 -0
- package/session-write-pi.mjs +130 -0
- package/session-write-residue.mjs +19 -0
- package/session-write-utils.mjs +77 -0
- package/session-write.d.ts +10 -0
- package/session-write.mjs +37 -0
- package/skill-toml.mjs +80 -0
- package/skills-control.mjs +193 -0
- package/skills-list.mjs +237 -0
- package/source-files.mjs +12 -2
- package/sqlite-reader.mjs +134 -18
- package/support-registry.json +402 -0
- package/support-report.mjs +69 -0
- package/trigger-records.mjs +77 -0
- package/triggers-read.mjs +67 -0
- package/workflow-read.mjs +165 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Full literal input or a verified saved family, with the persisted working
|
|
2
|
+
// transcript taking precedence over a mechanical reduction projection.
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { loadSessionPath, looksLikeSessionSqlite } from './session-path.mjs';
|
|
5
|
+
import { readStore, statement } from './sqlite-reader.mjs';
|
|
6
|
+
import { cloneNativeJson, copyNativeJsonMember, nativeInteger, setNativeInteger, stringifyNativeJsonPretty } from './native-json.mjs';
|
|
7
|
+
import { fromSidecarStr } from './session-native.mjs';
|
|
8
|
+
import { sessionTrigger, sessionWorkspace } from './session-model.mjs';
|
|
9
|
+
import { SavedSessionReader, parseSavedMessages, savedPathExists, savedSessionRoot } from './session-saved-read.mjs';
|
|
10
|
+
import { estimateViewTokens, verifyReductionLog } from './reduction-log.mjs';
|
|
11
|
+
import { reapplyReductionLog } from './reduction-view.mjs';
|
|
12
|
+
import { savedReductionAttribution } from './reduction-attribution.mjs';
|
|
13
|
+
import { conversionParseLoss } from './session-convert.mjs';
|
|
14
|
+
|
|
15
|
+
export const inspectionFirstLine = (text, maximum) => {
|
|
16
|
+
const end = text.indexOf('\n'), line = end < 0 ? text : text.slice(0, end).replace(/\r$/u, ''), chars = [...line];
|
|
17
|
+
return chars.length > maximum ? chars.slice(0, maximum).join('') + '…' : line;
|
|
18
|
+
};
|
|
19
|
+
export function inspectionPercentage(view, full) {
|
|
20
|
+
if (full === 0n) return 0n;
|
|
21
|
+
const value = Math.round(Number(view) / Number(full) * 100);
|
|
22
|
+
return value >= Number((1n << 63n) - 1n) ? (1n << 63n) - 1n : BigInt(value);
|
|
23
|
+
}
|
|
24
|
+
export function inspectionStoredRecords(session) {
|
|
25
|
+
if (!['pi', 'openclaw'].includes(session.meta.source)) return null;
|
|
26
|
+
const stored = session.raw.filter(line => line.includes('"type":"message"')).length;
|
|
27
|
+
return stored === session.messages.length ? null : stored;
|
|
28
|
+
}
|
|
29
|
+
const aggregateFields = ['full_bytes', 'view_bytes', 'full_tokens', 'view_tokens', 'aggregate_saved_bytes', 'aggregate_saved_tokens',
|
|
30
|
+
'standalone_saved_bytes', 'standalone_saved_tokens', 'overlap_suppressed_bytes', 'overlap_suppressed_tokens'];
|
|
31
|
+
const passFields = ['candidate_count', 'candidate_original_bytes', 'applied_count', 'applied_original_bytes', 'suppressed_by_later_pass_count',
|
|
32
|
+
'suppressed_by_later_pass_bytes', 'standalone_saved_bytes', 'standalone_saved_tokens', 'marginal_saved_bytes', 'marginal_saved_tokens',
|
|
33
|
+
'suppressed_bytes', 'suppressed_tokens', 'retained_bytes', 'retained_tokens'];
|
|
34
|
+
function attributionJson(value) {
|
|
35
|
+
const out = {};
|
|
36
|
+
for (const key of aggregateFields) copyNativeJsonMember(out, key, value);
|
|
37
|
+
const sum = key => value.passes.reduce((total, row) => (total + nativeInteger(row, key)) & ((1n << 64n) - 1n), 0n);
|
|
38
|
+
out.aggregate_bytes_is_marginal_sum = sum('marginal_saved_bytes') === nativeInteger(value, 'aggregate_saved_bytes');
|
|
39
|
+
out.aggregate_is_marginal_sum = sum('marginal_saved_tokens') === nativeInteger(value, 'aggregate_saved_tokens');
|
|
40
|
+
out.passes = value.passes.map(row => {
|
|
41
|
+
const out = { kind: row.kind, enabled: row.enabled };
|
|
42
|
+
for (const key of passFields) copyNativeJsonMember(out, key, row);
|
|
43
|
+
return out;
|
|
44
|
+
});
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
export function inspectionSessionSummary(session, reduced = null) {
|
|
48
|
+
const meta = session.meta, workspace = sessionWorkspace(meta);
|
|
49
|
+
const out = { source: meta.source.replace('claude_code', 'claude-code'), session_id: meta.session_id, model: meta.model, cwd: meta.cwd,
|
|
50
|
+
trigger: sessionTrigger(meta), surface: cloneNativeJson(meta.surface), profile: meta.profile, recurrence: cloneNativeJson(meta.recurrence),
|
|
51
|
+
cross_surface: cloneNativeJson(meta.cross_surface), workspace: { kind: workspace.kind, value: workspace.value ?? null },
|
|
52
|
+
message_count: session.messages.length, messages: session.messages.map(message => ({ role: message.role,
|
|
53
|
+
content_preview: message.content == null ? null : inspectionFirstLine(message.content, 120), tool_calls: (message.tool_calls ?? []).map(call => call.function.name) })) };
|
|
54
|
+
const stored = inspectionStoredRecords(session); if (stored != null) out.stored_message_records = stored;
|
|
55
|
+
if (reduced) {
|
|
56
|
+
out.reduced = { stub_count: reduced.stubCount };
|
|
57
|
+
for (const [key, value] of [['view_tokens', reduced.viewTokens], ['full_tokens', reduced.fullTokens], ['pct', reduced.pct]]) setNativeInteger(out.reduced, key, value);
|
|
58
|
+
out.reduced.sidecar_path = reduced.sidecarPath; out.reduced.attribution = attributionJson(reduced.attribution);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
async function storeSummary(path) {
|
|
63
|
+
try {
|
|
64
|
+
if (!await looksLikeSessionSqlite(path)) return null;
|
|
65
|
+
return (await readStore(path, db => {
|
|
66
|
+
const out = { path };
|
|
67
|
+
for (const [key, table] of [['sessions', 'session'], ['messages', 'message'], ['parts', 'part'], ['todos', 'todo']]) {
|
|
68
|
+
const count = statement(db, `SELECT COUNT(*) AS count FROM ${table}`).get().count;
|
|
69
|
+
const value = typeof count === 'bigint' ? count : BigInt(count);
|
|
70
|
+
setNativeInteger(out, key, value < 0n ? 0n : value);
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
})).result;
|
|
74
|
+
} catch { return null; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** No tier/configuration read occurs in JSON mode or after parse loss. Render
|
|
78
|
+
* callbacks keep presentation separate while preserving partial reports. */
|
|
79
|
+
export async function inspectSession({ file, json = false }, context = {}) {
|
|
80
|
+
const output = context.output ?? (() => {}); let session, reduced = null, opencodeStore = null, literal = false;
|
|
81
|
+
if (await savedPathExists(file)) {
|
|
82
|
+
literal = true; opencodeStore = await storeSummary(file);
|
|
83
|
+
try { session = await loadSessionPath(file, { env: context.env ?? process.env }); }
|
|
84
|
+
catch (error) { throw new Error(`loading session ${file}: ${error.message}`, { cause: error }); }
|
|
85
|
+
} else {
|
|
86
|
+
const store = new SavedSessionReader(context.storeRoot ?? savedSessionRoot(context.env)), name = await store.resolve(file);
|
|
87
|
+
const source = await store.read(name, '.sidecar.jsonl', true);
|
|
88
|
+
if (source != null) {
|
|
89
|
+
let sidecar;
|
|
90
|
+
try { sidecar = fromSidecarStr(source); } catch (error) { throw new Error(`parsing sidecar for \`${name}\`: ${error.message}`, { cause: error }); }
|
|
91
|
+
const log = await store.reductionLog(name);
|
|
92
|
+
try { verifyReductionLog(log, sidecar.messages); }
|
|
93
|
+
catch (error) { throw new Error(`${error.message} — repair pointer: \`sessions show-reductions ${name}\``, { cause: error }); }
|
|
94
|
+
let saved;
|
|
95
|
+
try { saved = await store.read(name, '.jsonl', true); }
|
|
96
|
+
catch (error) { throw new Error(`reading stored working view for \`${name}\`: ${error.message}`, { cause: error }); }
|
|
97
|
+
let messages;
|
|
98
|
+
if (saved != null) {
|
|
99
|
+
try { messages = parseSavedMessages(name, saved).messages; }
|
|
100
|
+
catch (error) { throw new Error(`parsing stored working view for \`${name}\`: ${error.message}`, { cause: error }); }
|
|
101
|
+
} else messages = reapplyReductionLog(sidecar.messages, log);
|
|
102
|
+
const fullTokens = estimateViewTokens(sidecar.messages), viewTokens = estimateViewTokens(messages);
|
|
103
|
+
reduced = { stubCount: log.reductions.length, fullTokens, viewTokens, pct: inspectionPercentage(viewTokens, fullTokens),
|
|
104
|
+
sidecarPath: join(store.root, name + '.sidecar.jsonl'), attribution: savedReductionAttribution(log, sidecar.messages) };
|
|
105
|
+
session = cloneNativeJson(sidecar); session.messages = messages;
|
|
106
|
+
} else session = parseSavedMessages(name, await store.read(name, '.jsonl'));
|
|
107
|
+
}
|
|
108
|
+
const report = { session: inspectionSessionSummary(session, reduced) };
|
|
109
|
+
if (opencodeStore) report.opencode_store = opencodeStore;
|
|
110
|
+
if (json) await output(stringifyNativeJsonPretty(report) + '\n');
|
|
111
|
+
else await context.render?.({ session, reduced, opencodeStore });
|
|
112
|
+
if (literal) {
|
|
113
|
+
const { errors, lines, children, childErrors } = conversionParseLoss(session);
|
|
114
|
+
if (errors) {
|
|
115
|
+
const note = childErrors ? ` (${childErrors} of which came from ${children} subagent transcript(s))` : '';
|
|
116
|
+
context.diagnostic?.(`error: ${file} — ${errors} of ${lines} line(s) failed to parse and were DROPPED${note} (the file may be truncated or corrupted); everything above reflects only the ${Math.max(0, lines - errors)} line(s) that DID parse, not the complete session`, 'ERR');
|
|
117
|
+
throw new Error(`${file}: ${errors} of ${lines} line(s) failed to parse — refusing to report success on a partially-loaded session (PARITY-15)${note}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!json) await context.schemas?.();
|
|
121
|
+
return report;
|
|
122
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { HarnessHomes } from './index.js';
|
|
2
|
+
type JsonRecord = Record<string, unknown>;
|
|
3
|
+
type Context = { env?: Record<string, string | undefined>; cwd?: string };
|
|
4
|
+
interface Launch { key: string; by: string | null; at: bigint; resumes: string | null; kind: string | null }
|
|
5
|
+
export const TREE_HEARTBEAT_MS: bigint;
|
|
6
|
+
export const TREE_JOURNAL_MAX_BYTES: number;
|
|
7
|
+
export function treePath(...parts: string[]): string;
|
|
8
|
+
export function treeSelectedPath(path: string, cwd: string): string;
|
|
9
|
+
export function machineTreeHome(env: Record<string, string | undefined>, cwd: string): string;
|
|
10
|
+
export function sessionTreeQuery(params: unknown, context?: Context): { at: string | null; homes: HarnessHomes; home: string };
|
|
11
|
+
export function treeMoment(at: bigint): string;
|
|
12
|
+
export function treeTimestamp(at: unknown): bigint | null;
|
|
13
|
+
export function treeParseSince(at: string, now?: bigint): bigint;
|
|
14
|
+
export function treeRecordTime(record: unknown): bigint | null;
|
|
15
|
+
export function treeUnsigned(record: unknown, key: string): bigint | null;
|
|
16
|
+
export function treeMachineName(env: Record<string, string | undefined>, cwd: string): string;
|
|
17
|
+
export function treeAddress(machine: string, harness: string, id: string): string | null;
|
|
18
|
+
export function treeParseAddress(value: unknown): { machine: string; harness: string; session: string; address: string } | null;
|
|
19
|
+
export function treeReadText(path: string | Buffer): string | null;
|
|
20
|
+
export function treeRecords(text: string | null): unknown[];
|
|
21
|
+
export function readTreeLaunches(home: string): { origins: Map<string, Launch>; panes: Map<string, Launch> };
|
|
22
|
+
export function readTreeTail(home: string): { last: JsonRecord | null; lastAt: bigint | null };
|
|
23
|
+
export function recordedMachineTree(home: string, at: bigint): JsonRecord | null;
|
|
24
|
+
export function treeShape(tree: unknown): string[][];
|
|
25
|
+
export function nextTreeRecord(tree: unknown, tail: { last: JsonRecord | null; lastAt: bigint | null }, now?: bigint): { written: 'tree' | 'heartbeat'; record: JsonRecord } | null;
|
|
26
|
+
export function firstTreeStarts(home: string, addresses: Set<string>): Map<string, { parent: string | null; evidence: 'launch'; recorded: true; detail: string; launch_key: string | null; at: bigint | null }>;
|
|
27
|
+
export function machineTreeEdges(rows: JsonRecord[]): { rows: JsonRecord[]; elsewhere: string[]; children: Map<string, string[]> };
|
|
28
|
+
export function treeRegistryDirectory(homes: HarnessHomes): string;
|
|
29
|
+
export function treeClaudeRegistry(homes: HarnessHomes, env: Record<string, string | undefined>): Promise<JsonRecord[]>;
|
|
30
|
+
export function treeFindRollout(root: string, id: string): Buffer | null;
|
|
31
|
+
export function treeRolloutHeader(path: string | Buffer): { thread: string; parent: string | null; cwd: string | null } | null;
|
|
32
|
+
export function treeFile(path: string | Buffer): boolean;
|
|
33
|
+
export function treeRolloutStatus(path: string | Buffer): 'busy' | 'idle' | 'running';
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// The machine's launch/tree records and graph fold. These records describe
|
|
2
|
+
// starter lineage; they never prove caller identity or confer a grant.
|
|
3
|
+
import { readFileSync, openSync, fstatSync, readSync, closeSync, statSync } from 'node:fs';
|
|
4
|
+
import { dirname, isAbsolute, parse, sep } from 'node:path';
|
|
5
|
+
import { userInfo } from 'node:os';
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { parseNativeJson, cloneNativeJson, nativeInteger, nativeNumberKind, setNativeInteger, stringifyNativeJsonMember } from './native-json.mjs';
|
|
8
|
+
import { field, stringField } from './session-codec-utils.mjs';
|
|
9
|
+
import { childPath, directoryEntries, rolloutSession, isDirectory, textName } from './session-spend-files.mjs';
|
|
10
|
+
import { spendMachineName, spendAddress } from './session-spend-metadata.mjs';
|
|
11
|
+
import { nativeTrim } from './text.mjs';
|
|
12
|
+
import { resolveHarnessHomes } from './homes.mjs';
|
|
13
|
+
import { usageMoment, usageTimestamp, parseUsageSince } from './session-usage-time.mjs';
|
|
14
|
+
import { CodexLifecycleCursor } from './session-codex-activity.mjs';
|
|
15
|
+
|
|
16
|
+
export const TREE_HEARTBEAT_MS = 9n * 60n * 1000n;
|
|
17
|
+
export const TREE_JOURNAL_MAX_BYTES = 16 * 1024 * 1024;
|
|
18
|
+
const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
19
|
+
const invalid = message => { throw Object.assign(new Error(message), { rpcCode: -32602 }); };
|
|
20
|
+
const object = value => value != null && typeof value === 'object' && !Array.isArray(value);
|
|
21
|
+
const hasSessions = record => object(record) && Object.hasOwn(record, 'sessions');
|
|
22
|
+
const moment = value => usageMoment(value);
|
|
23
|
+
export const treeMoment = moment;
|
|
24
|
+
export const treeTimestamp = usageTimestamp;
|
|
25
|
+
export const treeRecordTime = record => nativeInteger(record, 't', { signed: true });
|
|
26
|
+
export const treeParseSince = parseUsageSince;
|
|
27
|
+
export const treeMachineName = spendMachineName;
|
|
28
|
+
export const treeAddress = (machine, harness, id) => /^[A-Za-z0-9._-]+$/.test(machine) && /^[A-Za-z0-9._-]+$/.test(harness) ? spendAddress(machine, harness, id) : null;
|
|
29
|
+
export function treeParseAddress(value) {
|
|
30
|
+
if (typeof value !== 'string') return null;
|
|
31
|
+
const match = /^sc:([A-Za-z0-9._-]+):([A-Za-z0-9._-]+):(.+)$/su.exec(value);
|
|
32
|
+
if (!match || /[\u0009-\u000d\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/u.test(match[3])) return null;
|
|
33
|
+
return { machine: match[1], harness: match[2], session: match[3], address: value };
|
|
34
|
+
}
|
|
35
|
+
export const treeUnsigned = (record, key) => nativeInteger(record, key)
|
|
36
|
+
?? (nativeNumberKind(record, key) == null && Number.isSafeInteger(record?.[key]) && record[key] >= 0 ? BigInt(record[key]) : null);
|
|
37
|
+
// A rollout's first record never changes once written: its header is kept per file identity (device, inode, birth),
|
|
38
|
+
// so a session's every reading costs one stat, not a header read. A file not yet holding its header is read again.
|
|
39
|
+
const ROLLOUT_HEADERS_KEPT = 4096, rolloutHeaders = new Map();
|
|
40
|
+
const pathKey = path => Buffer.isBuffer(path) ? `b:${path.toString('hex')}` : `s:${path}`;
|
|
41
|
+
export function treeRolloutHeader(path) {
|
|
42
|
+
let stat; try { stat = statSync(path, { bigint: true }); } catch { return null; }
|
|
43
|
+
const key = pathKey(path), identity = `${stat.dev}:${stat.ino}:${stat.birthtimeNs}`, kept = rolloutHeaders.get(key);
|
|
44
|
+
if (kept?.identity === identity) return kept.header;
|
|
45
|
+
const header = rolloutSession(path);
|
|
46
|
+
rolloutHeaders.delete(key);
|
|
47
|
+
if (header != null) {
|
|
48
|
+
if (rolloutHeaders.size >= ROLLOUT_HEADERS_KEPT) rolloutHeaders.delete(rolloutHeaders.keys().next().value);
|
|
49
|
+
rolloutHeaders.set(key, { identity, header: Object.freeze(header) });
|
|
50
|
+
}
|
|
51
|
+
return header;
|
|
52
|
+
}
|
|
53
|
+
export const treeFile = path => { try { return statSync(path).isFile(); } catch { return false; } };
|
|
54
|
+
|
|
55
|
+
export function treePath(...parts) {
|
|
56
|
+
let result = '';
|
|
57
|
+
for (const part of parts) result = isAbsolute(part) || process.platform === 'win32' && /^[A-Za-z]:/.test(part)
|
|
58
|
+
? part : childPath(result, part).toString('utf8');
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
export const treeSelectedPath = (path, cwd) => !path || isAbsolute(path) || process.platform === 'win32' && /^[A-Za-z]:/.test(path)
|
|
62
|
+
? path : `${cwd}${sep}${path}`;
|
|
63
|
+
export function machineTreeHome(env, cwd) {
|
|
64
|
+
let home; try { home = env.HOME || (process.platform === 'win32' && env.USERPROFILE) || userInfo().homedir || '.'; } catch { home = '.'; }
|
|
65
|
+
const supercode = env.SUPERCODE_HOME || (env.XDG_CONFIG_HOME ? treePath(env.XDG_CONFIG_HOME, 'supercode') : treePath(home, '.config', 'supercode'));
|
|
66
|
+
return treeSelectedPath(env.SUPERCODE_TEAMS_HOME || treePath(supercode, 'teams'), cwd);
|
|
67
|
+
}
|
|
68
|
+
export function sessionTreeQuery(params, { env = process.env, cwd = process.cwd() } = {}) {
|
|
69
|
+
params ??= {};
|
|
70
|
+
if (!object(params)) invalid('tree params must be an object');
|
|
71
|
+
for (const key of Object.keys(params)) if (!['at', 'homes'].includes(key)) invalid(`unknown field ${key}`);
|
|
72
|
+
if (params.at != null && typeof params.at !== 'string') invalid('at must be a string');
|
|
73
|
+
const homes = resolveHarnessHomes(env);
|
|
74
|
+
if (Object.hasOwn(params, 'homes')) {
|
|
75
|
+
if (!object(params.homes)) invalid('homes must be an object');
|
|
76
|
+
for (const key of Object.keys(homes)) if (Object.hasOwn(params.homes, key)) {
|
|
77
|
+
if (typeof params.homes[key] !== 'string') invalid(`homes.${key} must be a string`);
|
|
78
|
+
homes[key] = params.homes[key];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (const key of Object.keys(homes)) homes[key] = treeSelectedPath(homes[key], cwd);
|
|
82
|
+
return { at: params.at ?? null, homes, home: machineTreeHome(env, cwd) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function treeReadText(path) {
|
|
86
|
+
try { return decoder.decode(readFileSync(path)); } catch { return null; }
|
|
87
|
+
}
|
|
88
|
+
const lines = text => text.split('\n').map(line => line.endsWith('\r') ? line.slice(0, -1) : line);
|
|
89
|
+
export function treeRecords(text) {
|
|
90
|
+
const records = [];
|
|
91
|
+
for (const line of lines(text ?? '')) { try { records.push(parseNativeJson(line)); } catch { /* an unread record establishes nothing */ } }
|
|
92
|
+
return records;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A key/pane's first issue, each session's first non-resume launch, and
|
|
96
|
+
* each pane's last launch, in journal order. */
|
|
97
|
+
export function readTreeLaunches(home) {
|
|
98
|
+
const origins = new Map(), panes = new Map(), issued = new Map();
|
|
99
|
+
for (const record of treeRecords(treeReadText(treePath(home, 'launches.jsonl')))) {
|
|
100
|
+
const key = stringField(record, 'key'), t = treeRecordTime(record); if (key == null || t == null) continue;
|
|
101
|
+
const pane = stringField(record, 'pane'), issueKey = JSON.stringify([key, pane ?? '']);
|
|
102
|
+
if (!issued.has(issueKey)) issued.set(issueKey, t);
|
|
103
|
+
let resumes = stringField(record, 'resumes');
|
|
104
|
+
if (resumes == null && key.startsWith('open:')) {
|
|
105
|
+
let rest = key.slice(5); if (rest.startsWith('native:')) rest = rest.slice(7);
|
|
106
|
+
const colon = rest.indexOf(':'); if (colon >= 0 && rest.slice(colon + 1)) resumes = rest.slice(colon + 1);
|
|
107
|
+
}
|
|
108
|
+
const launch = { key, by: stringField(record, 'by'), at: issued.get(issueKey), resumes, kind: stringField(record, 'kind') };
|
|
109
|
+
const id = stringField(record, 'native_session');
|
|
110
|
+
if (id != null && resumes == null && !origins.has(id)) origins.set(id, launch);
|
|
111
|
+
if (pane != null) panes.set(pane, launch);
|
|
112
|
+
}
|
|
113
|
+
return { origins, panes };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The last full record and the newest valid i64 clock in the same 4 MiB
|
|
117
|
+
* tail. A split UTF-8 sequence makes this native read unavailable. */
|
|
118
|
+
export function readTreeTail(home) {
|
|
119
|
+
let file;
|
|
120
|
+
try {
|
|
121
|
+
file = openSync(treePath(home, 'sessions-tree.jsonl'), 'r');
|
|
122
|
+
const length = fstatSync(file).size, start = Math.max(0, length - 4 * 1024 * 1024), buffer = Buffer.alloc(length - start);
|
|
123
|
+
let read = 0; while (read < buffer.length) { const n = readSync(file, buffer, read, buffer.length - read, start + read); if (!n) break; read += n; }
|
|
124
|
+
const text = decoder.decode(buffer.subarray(0, read)); let lastAt = null;
|
|
125
|
+
for (const record of treeRecords(text).reverse()) {
|
|
126
|
+
lastAt ??= treeRecordTime(record);
|
|
127
|
+
if (hasSessions(record)) return { last: record, lastAt };
|
|
128
|
+
}
|
|
129
|
+
return { last: null, lastAt };
|
|
130
|
+
} catch { return { last: null, lastAt: null }; }
|
|
131
|
+
finally { if (file != null) closeSync(file); }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function recordedMachineTree(home, at) {
|
|
135
|
+
const text = (treeReadText(treePath(home, 'sessions-tree.jsonl.1')) ?? '') + (treeReadText(treePath(home, 'sessions-tree.jsonl')) ?? '');
|
|
136
|
+
let found = null, confirmed = null, nextChange = null;
|
|
137
|
+
for (const record of treeRecords(text)) {
|
|
138
|
+
const t = treeRecordTime(record); if (t == null) continue;
|
|
139
|
+
if (t <= at) { if (hasSessions(record)) found = record; confirmed = t; }
|
|
140
|
+
else if (hasSessions(record)) { nextChange = t; break; }
|
|
141
|
+
else if (found != null && nextChange == null) confirmed = t;
|
|
142
|
+
}
|
|
143
|
+
if (found == null) return null;
|
|
144
|
+
const tree = cloneNativeJson(found), recorded = treeRecordTime(tree) ?? 0n;
|
|
145
|
+
tree.at = moment(at); tree.recorded_at = moment(recorded);
|
|
146
|
+
tree.confirmed_through = confirmed == null ? null : moment(confirmed);
|
|
147
|
+
tree.next_change_at = nextChange == null ? null : moment(nextChange);
|
|
148
|
+
delete tree.t;
|
|
149
|
+
return tree;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function treeShape(tree) {
|
|
153
|
+
const rows = Array.isArray(field(tree, 'sessions')) ? tree.sessions : [];
|
|
154
|
+
return rows.map(row => ['address', 'parent', 'pane'].map(key => stringField(row, key) ?? ''))
|
|
155
|
+
.sort((a, b) => { for (let i = 0; i < 3; i++) { const n = Buffer.compare(Buffer.from(a[i]), Buffer.from(b[i])); if (n) return n; } return 0; });
|
|
156
|
+
}
|
|
157
|
+
/** Decide the next append without changing the journal. The daemon/CLI
|
|
158
|
+
* owns rollover and the single append. */
|
|
159
|
+
export function nextTreeRecord(tree, { last, lastAt }, now = BigInt(Date.now())) {
|
|
160
|
+
const same = last != null && JSON.stringify(treeShape(last)) === JSON.stringify(treeShape(tree));
|
|
161
|
+
if (same && lastAt != null && BigInt.asIntN(64, now - lastAt) < TREE_HEARTBEAT_MS) return null;
|
|
162
|
+
const record = same ? parseNativeJson(`{"t":0,"unchanged_since":${stringifyNativeJsonMember(last, 't')}}`) : {
|
|
163
|
+
t: 0, machine: cloneNativeJson(field(tree, 'machine') ?? null), sessions: cloneNativeJson(field(tree, 'sessions') ?? null),
|
|
164
|
+
parents_elsewhere: cloneNativeJson(field(tree, 'parents_elsewhere') ?? null),
|
|
165
|
+
};
|
|
166
|
+
setNativeInteger(record, 't', BigInt.asIntN(64, now));
|
|
167
|
+
return { written: same ? 'heartbeat' : 'tree', record };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function firstTreeStarts(home, addresses) {
|
|
171
|
+
if (!addresses.size) return new Map();
|
|
172
|
+
const { last } = readTreeTail(home); if (last == null) return new Map();
|
|
173
|
+
const at = treeRecordTime(last), recordedAt = at == null ? '' : moment(at), starts = new Map();
|
|
174
|
+
for (const row of Array.isArray(field(last, 'sessions')) ? last.sessions : []) {
|
|
175
|
+
const address = stringField(row, 'address'), started = field(row, 'started_by');
|
|
176
|
+
if (!addresses.has(address) || field(started, 'recorded') !== true || stringField(started, 'evidence') !== 'launch') continue;
|
|
177
|
+
starts.set(address, { parent: stringField(row, 'parent'), evidence: 'launch', recorded: true,
|
|
178
|
+
detail: `its first launch, as the session tree recorded it at ${recordedAt}`,
|
|
179
|
+
launch_key: stringField(started, 'launch_key'), at: usageTimestamp(stringField(started, 'at')) });
|
|
180
|
+
}
|
|
181
|
+
return starts;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Drop only the first edge encountered on each cycle, then fill children
|
|
185
|
+
* in input order and name each missing parent once. */
|
|
186
|
+
export function machineTreeEdges(rows) {
|
|
187
|
+
const index = new Map(); rows.forEach((row, i) => { const address = stringField(row, 'address'); if (address != null) index.set(address, i); });
|
|
188
|
+
for (let i = 0; i < rows.length; i++) {
|
|
189
|
+
const seen = new Set([i]); let current = index.get(stringField(rows[i], 'parent'));
|
|
190
|
+
while (current != null) {
|
|
191
|
+
if (current === i) { rows[i].parent = null; rows[i].started_by.cycle = true; break; }
|
|
192
|
+
if (seen.has(current)) break;
|
|
193
|
+
seen.add(current); current = index.get(stringField(rows[current], 'parent'));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const children = new Map(), elsewhere = [];
|
|
197
|
+
for (const row of rows) {
|
|
198
|
+
const parent = stringField(row, 'parent'); if (parent == null) continue;
|
|
199
|
+
if (!children.has(parent)) children.set(parent, []);
|
|
200
|
+
children.get(parent).push(stringField(row, 'address') ?? '');
|
|
201
|
+
if (!index.has(parent) && !elsewhere.includes(parent)) elsewhere.push(parent);
|
|
202
|
+
}
|
|
203
|
+
for (const row of rows) { const address = stringField(row, 'address') ?? ''; row.children = children.get(address) ?? []; children.delete(address); }
|
|
204
|
+
return { rows, elsewhere, children };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function treeRegistryDirectory(homes) {
|
|
208
|
+
const root = homes.claude_code, parent = root && root !== parse(root).root ? dirname(root) : '.';
|
|
209
|
+
return treePath(parent, 'sessions');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function treeClaudeRegistry(homes, env, { signal } = {}) {
|
|
213
|
+
const rows = [], starts = new Map();
|
|
214
|
+
const optionalText = (row, key) => row[key] == null || typeof row[key] === 'string';
|
|
215
|
+
for (const file of directoryEntries(treeRegistryDirectory(homes))) {
|
|
216
|
+
const name = textName(file.name); if (!name?.endsWith('.json') || name === '.json') continue;
|
|
217
|
+
let row; try { row = parseNativeJson(treeReadText(file.path)); } catch { continue; }
|
|
218
|
+
if (!object(row)) continue;
|
|
219
|
+
const pid = nativeInteger(row, 'pid');
|
|
220
|
+
if (pid == null || pid > 0xffffffffn || typeof row.sessionId !== 'string' || !row.sessionId
|
|
221
|
+
|| typeof row.name !== 'string' || !row.name || typeof row.messagingSocketPath !== 'string' || row.parkedJobId != null
|
|
222
|
+
|| !['cwd', 'name', 'messagingSocketPath', 'status', 'version', 'tmux', 'parkedJobId', 'procStart', 'waitingFor'].every(key => optionalText(row, key))
|
|
223
|
+
|| row.updatedAt != null && nativeInteger(row, 'updatedAt') == null
|
|
224
|
+
|| row.statusUpdatedAt != null && nativeInteger(row, 'statusUpdatedAt') == null) continue;
|
|
225
|
+
let live = false;
|
|
226
|
+
try { process.kill(process.platform === 'win32' ? Number(pid) : Number(BigInt.asIntN(32, pid)), 0); live = true; }
|
|
227
|
+
catch (error) { live = process.platform !== 'win32' && error.code === 'EPERM'; }
|
|
228
|
+
if (live) rows.push(cloneNativeJson(row));
|
|
229
|
+
}
|
|
230
|
+
const normalizedWords = value => nativeTrim(value).split(/[\u0009-\u000d\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+/u).join(' ');
|
|
231
|
+
if (process.platform === 'darwin' && rows.some(row => row.procStart != null)) await new Promise(resolve => {
|
|
232
|
+
execFile('ps', ['-o', 'pid=,lstart=', '-p', rows.map(row => row.pid).join(',')], { env: { ...env, TZ: 'UTC', LC_ALL: 'C' }, signal, timeout: 2000, maxBuffer: 1024 * 1024 }, (error, stdout) => {
|
|
233
|
+
if (!error) for (const line of stdout.split('\n')) { const match = /^\s*(\d+)\s+(.+)$/.exec(line); if (match) starts.set(Number(match[1]), normalizedWords(match[2])); }
|
|
234
|
+
resolve();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
return rows.sort((a, b) => a.pid - b.pid).filter(row => {
|
|
238
|
+
let actual = starts.get(row.pid);
|
|
239
|
+
if (process.platform === 'linux' && row.procStart != null) {
|
|
240
|
+
try { const stat = readFileSync(`/proc/${row.pid}/stat`, 'utf8'); actual = stat.slice(stat.lastIndexOf(')') + 1).trim().split(/\s+/)[19]; } catch { /* unread proves no mismatch */ }
|
|
241
|
+
}
|
|
242
|
+
return row.procStart == null || actual == null || actual === (process.platform === 'darwin' ? normalizedWords(row.procStart) : nativeTrim(row.procStart));
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// The rollout directory's listings, kept per directory and listed again only when its modification time moved (or was
|
|
247
|
+
// too recent to trust): a lookup costs one stat per directory, never a listing of every day's rollouts.
|
|
248
|
+
const LISTING_SETTLED_NS = 2_000_000_000n, rolloutListings = new Map();
|
|
249
|
+
function rolloutListing(directory, depth) {
|
|
250
|
+
let stat; try { stat = statSync(directory, { bigint: true }); } catch { stat = null; }
|
|
251
|
+
const key = `${depth}:${pathKey(directory)}`, kept = rolloutListings.get(key);
|
|
252
|
+
if (stat != null && kept?.settled && kept.mtime === stat.mtimeNs && kept.ino === stat.ino) return kept.entries;
|
|
253
|
+
const entries = [];
|
|
254
|
+
for (const entry of directoryEntries(directory)) entries.push({ path: entry.path, name: textName(entry.name), directory: depth < 3 && isDirectory(entry.path) });
|
|
255
|
+
if (stat == null) rolloutListings.delete(key);
|
|
256
|
+
else rolloutListings.set(key, { mtime: stat.mtimeNs, ino: stat.ino, settled: BigInt(Date.now()) * 1_000_000n - stat.mtimeNs > LISTING_SETTLED_NS, entries });
|
|
257
|
+
return entries;
|
|
258
|
+
}
|
|
259
|
+
export function treeFindRollout(root, id) {
|
|
260
|
+
const suffix = `-${id}.jsonl`, stack = [[root, 0]];
|
|
261
|
+
while (stack.length) {
|
|
262
|
+
const [directory, depth] = stack.pop();
|
|
263
|
+
for (const entry of rolloutListing(directory, depth)) {
|
|
264
|
+
if (entry.directory) stack.push([entry.path, depth + 1]);
|
|
265
|
+
else if (entry.name?.endsWith(suffix)) return entry.path;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Fresh lifecycle scan, with the native 1 MiB blocks and 64 KiB overlap. */
|
|
272
|
+
export function treeRolloutStatus(path) {
|
|
273
|
+
return new CodexLifecycleCursor().read(path) ?? 'running';
|
|
274
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Native continuation files are new, owner-only and synced. The selected home
|
|
2
|
+
// may itself be a symlink; every directory below its canonical root may not.
|
|
3
|
+
import { mkdir, lstat, realpath, open, unlink } from 'node:fs/promises';
|
|
4
|
+
import { join, resolve, sep, toNamespacedPath } from 'node:path';
|
|
5
|
+
export { freshSessionId } from './session-fresh-id.mjs';
|
|
6
|
+
|
|
7
|
+
export const isObject = value => value != null && typeof value === 'object' && !Array.isArray(value);
|
|
8
|
+
export const nativeJoin = (root, ...parts) => process.platform === 'win32' && /^[A-Za-z]:$/u.test(root)
|
|
9
|
+
? root + parts.join(sep) : join(root, ...parts);
|
|
10
|
+
export const operationPath = (path, cwd) => resolve(cwd, path);
|
|
11
|
+
// std::fs::canonicalize returns the extended-length spelling on Windows.
|
|
12
|
+
export const canonicalPath = async path => {
|
|
13
|
+
const canonical = await realpath(path);
|
|
14
|
+
return process.platform === 'win32' ? toNamespacedPath(canonical) : canonical;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Path::components ignores interior dots/repeated separators, but retains a
|
|
18
|
+
* leading CurDir, roots and Windows prefixes. Only Normal components qualify. */
|
|
19
|
+
export function safeArtifactPath(path) {
|
|
20
|
+
if (path === '' || (process.platform === 'win32' ? /^[\\/]|^[A-Za-z]:/u : /^\//u).test(path)) return false;
|
|
21
|
+
// Unix separators deliberately exclude backslash, which is a normal byte.
|
|
22
|
+
const components = path.split(process.platform === 'win32' ? /[\\/]+/u : /\/+/u);
|
|
23
|
+
return components[0] !== '.' && components.every(part => part !== '..') && components.some(part => part !== '' && part !== '.');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function writeExclusive(path, content, signal) {
|
|
27
|
+
signal?.throwIfAborted();
|
|
28
|
+
let file;
|
|
29
|
+
try { file = await open(path, 'wx', 0o600); }
|
|
30
|
+
catch (error) { throw new Error(`refusing to overwrite native continuation: ${error.message}`, { cause: error }); }
|
|
31
|
+
try {
|
|
32
|
+
signal?.throwIfAborted();
|
|
33
|
+
await file.writeFile(content, 'utf8'); await file.sync();
|
|
34
|
+
} catch (error) {
|
|
35
|
+
// Only this newly created path belongs to us. Earlier successful bundle
|
|
36
|
+
// files remain, as in the native writer; cancellation claims no rollback.
|
|
37
|
+
await unlink(path).catch(() => {}); throw error;
|
|
38
|
+
} finally { await file.close().catch(() => {}); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const inside = (root, child) => {
|
|
42
|
+
const prefix = pathComponents(root), components = pathComponents(child);
|
|
43
|
+
return prefix.length <= components.length && prefix.every((part, at) => part === components[at]);
|
|
44
|
+
};
|
|
45
|
+
export async function containedDirectory(root, parts, signal, claude = false) {
|
|
46
|
+
signal?.throwIfAborted();
|
|
47
|
+
await mkdir(root, { recursive: true });
|
|
48
|
+
const label = claude ? 'Claude projects root' : 'native store root';
|
|
49
|
+
try { root = await canonicalPath(root); }
|
|
50
|
+
catch (error) { throw new Error(`${label} is unavailable: ${error.message}`, { cause: error }); }
|
|
51
|
+
let directory = root;
|
|
52
|
+
for (const part of parts) {
|
|
53
|
+
signal?.throwIfAborted(); directory = join(directory, part);
|
|
54
|
+
let metadata;
|
|
55
|
+
try { metadata = await lstat(directory); }
|
|
56
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; await mkdir(directory); }
|
|
57
|
+
if (metadata && (metadata.isSymbolicLink() || !metadata.isDirectory())) throw new Error(
|
|
58
|
+
claude ? 'refusing unsafe Claude project directory' : 'refusing unsafe native store directory');
|
|
59
|
+
}
|
|
60
|
+
let canonical;
|
|
61
|
+
try { canonical = await canonicalPath(directory); }
|
|
62
|
+
catch (error) { throw new Error(`${claude ? 'Claude project' : 'native store'} directory is unavailable: ${error.message}`, { cause: error }); }
|
|
63
|
+
if (!inside(root, canonical)) throw new Error(claude
|
|
64
|
+
? 'refusing Claude project directory outside its native home' : 'refusing native store directory outside its home');
|
|
65
|
+
return canonical;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function pathComponents(path) {
|
|
69
|
+
const windows = process.platform === 'win32', prefix = windows ? path.match(/^[A-Za-z]:/u)?.[0] ?? '' : '';
|
|
70
|
+
const tail = path.slice(prefix.length), components = tail.split(windows ? /[\\/]+/u : /\/+/u).filter(part => part !== '' && part !== '.');
|
|
71
|
+
const root = (windows ? /^[\\/]/u : /^\//u).test(tail);
|
|
72
|
+
const dot = tail === '.' || (windows ? /^\.[\\/]/u : /^\.\//u).test(tail);
|
|
73
|
+
return [prefix, root ? '/' : dot ? '.' : '', ...components];
|
|
74
|
+
}
|
|
75
|
+
export async function samePath(a, b, cwd) {
|
|
76
|
+
try { return await canonicalPath(operationPath(a, cwd)) === await canonicalPath(operationPath(b, cwd)); }
|
|
77
|
+
catch { return JSON.stringify(pathComponents(a)) === JSON.stringify(pathComponents(b)); }
|
|
78
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// OpenCode owns its database writes. This module imports a fresh-ID document
|
|
2
|
+
// through its CLI and opens candidate databases read-only to verify the result.
|
|
3
|
+
import { mkdir, readFile, readdir, stat, rm } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
9
|
+
import { parseNativeJson, stringifyNativeJson, swapRemoveNativeJsonMember } from './native-json.mjs';
|
|
10
|
+
import { decodeSessionUtf8 } from './session-file.mjs';
|
|
11
|
+
import { nativeTrim } from './text.mjs';
|
|
12
|
+
import { isObject, nativeJoin, operationPath, writeExclusive } from './session-materialize-files.mjs';
|
|
13
|
+
|
|
14
|
+
class OpenCodeIds {
|
|
15
|
+
counter = 0n;
|
|
16
|
+
next(prefix, descending) {
|
|
17
|
+
this.counter = BigInt.asUintN(64, this.counter + 1n);
|
|
18
|
+
const millis = Date.now(); if (millis < 0) throw new Error('system time is before the Unix epoch');
|
|
19
|
+
let value = BigInt.asUintN(64, BigInt(millis) * 0x1000n + this.counter);
|
|
20
|
+
if (descending) value = BigInt.asUintN(64, ~value);
|
|
21
|
+
const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
|
22
|
+
const suffix = [...randomBytes(14)].map(byte => alphabet[byte % alphabet.length]).join('');
|
|
23
|
+
return `${prefix}_${(value & 0xffffffffffffn).toString(16).padStart(12, '0')}${suffix}`;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** This handle belongs only to the command we start. An aborted worker waits
|
|
28
|
+
* for close before deleting its staging directory, and claims no DB rollback. */
|
|
29
|
+
async function command(program, arguments_, { cwd, env, signal }, stderr = true) {
|
|
30
|
+
signal?.throwIfAborted();
|
|
31
|
+
return await new Promise((resolve, reject) => {
|
|
32
|
+
const stdout = [], errors = [];
|
|
33
|
+
const child = spawn(program, arguments_, { cwd, env, windowsHide: true, stdio: ['ignore', 'pipe', stderr ? 'pipe' : 'ignore'] });
|
|
34
|
+
let forceStop, startError;
|
|
35
|
+
const abort = () => {
|
|
36
|
+
child.kill('SIGTERM');
|
|
37
|
+
forceStop = setTimeout(() => child.kill('SIGKILL'), 250); forceStop.unref();
|
|
38
|
+
};
|
|
39
|
+
signal?.addEventListener('abort', abort, { once: true }); if (signal?.aborted) abort();
|
|
40
|
+
child.stdout.on('data', chunk => stdout.push(chunk)); child.stderr?.on('data', chunk => errors.push(chunk));
|
|
41
|
+
child.once('error', error => { startError = error; });
|
|
42
|
+
child.once('close', (code, stoppedBy) => {
|
|
43
|
+
clearTimeout(forceStop); signal?.removeEventListener('abort', abort);
|
|
44
|
+
if (signal?.aborted) reject(signal.reason ?? new Error('OpenCode import cancelled'));
|
|
45
|
+
else if (startError) reject(startError);
|
|
46
|
+
else resolve({ code, stoppedBy, stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(errors).toString('utf8') });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function projectId(cwd, context) {
|
|
52
|
+
const git = async arguments_ => {
|
|
53
|
+
try {
|
|
54
|
+
const output = await command('git', ['-C', cwd, ...arguments_], context, false);
|
|
55
|
+
return output.code === 0 ? output.stdout : null;
|
|
56
|
+
} catch (error) { if (context.signal?.aborted) throw error; return null; }
|
|
57
|
+
};
|
|
58
|
+
const common = await git(['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
59
|
+
if (common == null) return 'global';
|
|
60
|
+
try {
|
|
61
|
+
const cached = nativeTrim(decodeSessionUtf8(await readFile(nativeJoin(nativeTrim(common), 'opencode'))));
|
|
62
|
+
if (cached !== '') return cached;
|
|
63
|
+
} catch { /* Absent, unreadable or invalid UTF-8 cache falls back to Git roots. */ }
|
|
64
|
+
const roots = (await git(['rev-list', '--max-parents=0', '--all']))?.split('\n').map(nativeTrim).filter(value => value !== '') ?? [];
|
|
65
|
+
roots.sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)));
|
|
66
|
+
return roots[0] ?? 'global';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function databaseHolding(root, id, context) {
|
|
70
|
+
const absolute = operationPath(root, context.cwd); let databases;
|
|
71
|
+
try {
|
|
72
|
+
if ((await stat(absolute)).isFile()) databases = [root];
|
|
73
|
+
else databases = (await readdir(absolute)).filter(name => name.startsWith('opencode') && name.endsWith('.db')).map(name => nativeJoin(root, name));
|
|
74
|
+
} catch { return null; }
|
|
75
|
+
databases.sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)));
|
|
76
|
+
for (const path of databases) {
|
|
77
|
+
context.signal?.throwIfAborted(); let database;
|
|
78
|
+
try {
|
|
79
|
+
database = new DatabaseSync(operationPath(path, context.cwd), { readOnly: true, allowExtension: false });
|
|
80
|
+
// The replaced rusqlite connection uses its default five-second busy
|
|
81
|
+
// handler. This changes no journal mode and performs no database write.
|
|
82
|
+
database.exec('PRAGMA busy_timeout=5000');
|
|
83
|
+
if (database.prepare('SELECT 1 FROM session WHERE id = ?1').get(id) != null) return path;
|
|
84
|
+
} catch { /* A missing row, unreadable store or incompatible schema proves no import here. */ }
|
|
85
|
+
finally { try { database?.close(); } catch { /* Native connection drop has no reported close result. */ } }
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function materializeOpenCode(artifact, cwd, root, context) {
|
|
91
|
+
let document;
|
|
92
|
+
try { document = parseNativeJson(artifact.content); }
|
|
93
|
+
catch (error) { throw new Error(`invalid OpenCode export: ${error.message}`, { cause: error }); }
|
|
94
|
+
if (artifact.session_id != null && document?.info?.id !== artifact.session_id) throw new Error('native artifact declared session identity does not match its content');
|
|
95
|
+
const ids = new OpenCodeIds(), id = ids.next('ses', true);
|
|
96
|
+
if (!isObject(document?.info)) throw new Error('OpenCode export has no session info');
|
|
97
|
+
document.info.id = id; document.info.directory = cwd; document.info.projectID = await projectId(cwd, context); swapRemoveNativeJsonMember(document.info, 'parentID');
|
|
98
|
+
const renamed = new Map(), rename = old => {
|
|
99
|
+
if (renamed.has(old)) return renamed.get(old);
|
|
100
|
+
const prefix = old.includes('_') ? old.slice(0, old.indexOf('_')) : 'msg', next = ids.next(prefix, false);
|
|
101
|
+
renamed.set(old, next); return next;
|
|
102
|
+
};
|
|
103
|
+
if (!Array.isArray(document.messages)) throw new Error('OpenCode export has no messages');
|
|
104
|
+
for (const message of document.messages) {
|
|
105
|
+
if (!isObject(message?.info)) throw new Error('OpenCode message has no info');
|
|
106
|
+
const info = message.info; info.sessionID = id;
|
|
107
|
+
const messageId = rename(typeof info.id === 'string' ? info.id : ''); info.id = messageId;
|
|
108
|
+
if (typeof info.parentID === 'string') info.parentID = rename(info.parentID);
|
|
109
|
+
if (isObject(info.path)) for (const key of ['cwd', 'root']) if (Object.hasOwn(info.path, key)) info.path[key] = cwd;
|
|
110
|
+
for (const part of Array.isArray(message.parts) ? message.parts : []) if (isObject(part)) {
|
|
111
|
+
part.sessionID = id; part.messageID = messageId; part.id = rename(typeof part.id === 'string' ? part.id : '');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
context.signal?.throwIfAborted();
|
|
115
|
+
const staging = join(tmpdir(), `supercode-opencode-${id}`), file = join(staging, 'session.json');
|
|
116
|
+
await mkdir(staging); let output;
|
|
117
|
+
try {
|
|
118
|
+
await writeExclusive(file, stringifyNativeJson(document), context.signal);
|
|
119
|
+
try { output = await command('opencode', ['import', file], { ...context, cwd }); }
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (context.signal?.aborted) throw error;
|
|
122
|
+
throw new Error(`\`opencode import\` could not start: ${error.message}`, { cause: error });
|
|
123
|
+
}
|
|
124
|
+
} finally { await rm(staging, { recursive: true, force: true }).catch(() => {}); }
|
|
125
|
+
let reported;
|
|
126
|
+
for (const line of output.stdout.split('\n')) {
|
|
127
|
+
const text = nativeTrim(line);
|
|
128
|
+
if (text.startsWith('Imported session: ')) { reported = nativeTrim(text.slice('Imported session: '.length)); break; }
|
|
129
|
+
}
|
|
130
|
+
if (output.code !== 0 || reported !== id) {
|
|
131
|
+
const status = output.stoppedBy ? `signal: ${output.stoppedBy}` : `exit status: ${output.code}`;
|
|
132
|
+
throw new Error(`\`opencode import\` did not import ${id} (exit ${status}): ${nativeTrim(output.stdout)}${nativeTrim(output.stderr)}`);
|
|
133
|
+
}
|
|
134
|
+
const database = await databaseHolding(root, id, context);
|
|
135
|
+
if (database == null) throw new Error(`\`opencode import\` reported ${id}, but no OpenCode database under ${root} holds it`);
|
|
136
|
+
return { harness: 'opencode', session_id: id, storage: { kind: 'sqlite', path: database, selector: id } };
|
|
137
|
+
}
|