@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
package/runs-read.mjs
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Recorded fire history, after the owning codec's complete home validity
|
|
2
|
+
// boundary. No scheduler, claim, retry, pruning or write runs here.
|
|
3
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
4
|
+
import { isAbsolute } from 'node:path';
|
|
5
|
+
import { resolveHarnessHomes } from './homes.mjs';
|
|
6
|
+
import { childPath, textName } from './session-spend-files.mjs';
|
|
7
|
+
import { profilePathJoin as join, profilePathParent as parent } from './profile-paths.mjs';
|
|
8
|
+
import { readRunFolderHome } from './hermes-read-home.mjs';
|
|
9
|
+
import { readOpenclawRunHome } from './openclaw-profile-home.mjs';
|
|
10
|
+
import { hermesRowText } from './hermes-fires.mjs';
|
|
11
|
+
import { usageMoment } from './session-usage-time.mjs';
|
|
12
|
+
import { nativeDuplicateObjectKeys, nativeInteger, parseNativeJson, setNativeInteger, stringifyNativeJsonMember } from './native-json.mjs';
|
|
13
|
+
|
|
14
|
+
export const RUN_HARNESSES = Object.freeze(['hermes', 'openclaw', 'orchestrator']);
|
|
15
|
+
const object = value => value != null && typeof value === 'object' && !Array.isArray(value);
|
|
16
|
+
const compare = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b));
|
|
17
|
+
const string = value => typeof value === 'string' ? value : null;
|
|
18
|
+
const display = path => Buffer.isBuffer(path) ? path.toString('utf8') : path;
|
|
19
|
+
const file = path => { try { return statSync(path).isFile(); } catch { return false; } };
|
|
20
|
+
const directory = path => { try { return statSync(path).isDirectory(); } catch { return false; } };
|
|
21
|
+
const filesystem = (path, cwd) => !path.length || isAbsolute(display(path)) || process.platform === 'win32' && /^[A-Za-z]:/u.test(display(path)) ? path : Buffer.isBuffer(path) ? childPath(cwd, path) : join(cwd, path);
|
|
22
|
+
const refuse = (message, rpcCode = -32602) => { throw Object.assign(new Error(message), { rpcCode }); };
|
|
23
|
+
const intMin = -(1n << 63n), intMax = (1n << 63n) - 1n, uintMax = (1n << 64n) - 1n;
|
|
24
|
+
function struct(value, fields, name) {
|
|
25
|
+
if (Array.isArray(value)) {
|
|
26
|
+
if (value.length > fields.length) refuse(`${name} has too many fields`);
|
|
27
|
+
// Preserve integer facts when a serde struct is represented as a sequence.
|
|
28
|
+
return parseNativeJson(`{${value.map((_, at) => `${JSON.stringify(fields[at])}:${stringifyNativeJsonMember(value, String(at))}`).join(',')}}`);
|
|
29
|
+
}
|
|
30
|
+
if (!object(value)) refuse(`${name} must be a struct`);
|
|
31
|
+
if (nativeDuplicateObjectKeys(value).some(key => fields.includes(key))) refuse(`${name} has a duplicate field`);
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
function query(params, context, get = false) {
|
|
35
|
+
const query = struct(params, get ? ['harness', 'id', 'homes'] : ['harness', 'job', 'limit', 'homes'], 'runs params');
|
|
36
|
+
for (const key of get ? ['harness', 'id'] : ['harness', 'job']) {
|
|
37
|
+
if (get ? typeof query[key] !== 'string' : query[key] != null && typeof query[key] !== 'string') refuse(`${key} must be a string`);
|
|
38
|
+
}
|
|
39
|
+
let limit = null;
|
|
40
|
+
if (!get && query.limit != null) {
|
|
41
|
+
limit = nativeInteger(query, 'limit');
|
|
42
|
+
if (limit == null) refuse('limit must be a native unsigned integer');
|
|
43
|
+
}
|
|
44
|
+
const homes = resolveHarnessHomes(context.env);
|
|
45
|
+
if (Object.hasOwn(query, 'homes')) {
|
|
46
|
+
const supplied = struct(query.homes, Object.keys(homes), 'homes');
|
|
47
|
+
for (const key of Object.keys(homes)) if (Object.hasOwn(supplied, key)) {
|
|
48
|
+
if (typeof supplied[key] !== 'string') refuse(`homes.${key} must be a string`);
|
|
49
|
+
homes[key] = supplied[key];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (query.harness != null && !RUN_HARNESSES.includes(query.harness)) refuse(`\`${query.harness}\` keeps no run store; \`runs.${get ? 'get' : 'list'}\` is supported for: ${RUN_HARNESSES.join(', ')}`, -32020);
|
|
53
|
+
return { harness: query.harness ?? null, job: get ? null : query.job ?? null, id: query.id, limit, homes };
|
|
54
|
+
}
|
|
55
|
+
const limited = (rows, limit) => limit == null || limit >= BigInt(rows.length) ? rows : rows.slice(0, Number(limit));
|
|
56
|
+
const source = (harness, path, profile, state, detail = null) => ({ harness, path: display(path), state, profile, detail });
|
|
57
|
+
const member = (row, key, fallback = 'null') => Object.hasOwn(row, key) ? stringifyNativeJsonMember(row, key) : fallback;
|
|
58
|
+
const nativeObject = entries => parseNativeJson(`{${entries.map(([key, token]) => `${JSON.stringify(key)}:${token}`).join(',')}}`);
|
|
59
|
+
const token = value => JSON.stringify(value);
|
|
60
|
+
|
|
61
|
+
function ledgers(root, harness, cwd) {
|
|
62
|
+
if (harness === 'orchestrator' && !directory(filesystem(root, cwd))) return [];
|
|
63
|
+
const rows = [{ path: join(root, 'cron/executions.db'), profile: null }], profiles = join(root, 'profiles');
|
|
64
|
+
try {
|
|
65
|
+
const named = readdirSync(filesystem(profiles, cwd), { encoding: 'buffer' }).flatMap(name => {
|
|
66
|
+
const dir = childPath(profiles, name);
|
|
67
|
+
if (harness === 'orchestrator') {
|
|
68
|
+
// This inventory's native wrapper returns no directories on any
|
|
69
|
+
// registry read error. Its complete home read still refuses separately.
|
|
70
|
+
if (!statSync(filesystem(dir, cwd)).isDirectory()) return [];
|
|
71
|
+
const text = textName(name); if (text == null || text.startsWith('.')) return [];
|
|
72
|
+
return [{ path: childPath(dir, 'cron/executions.db'), profile: text === 'default' ? null : text, sort: text }];
|
|
73
|
+
}
|
|
74
|
+
return directory(filesystem(dir, cwd)) ? [{ path: childPath(dir, 'cron/executions.db'), profile: name.toString('utf8'), sort: name.toString('utf8') }] : [];
|
|
75
|
+
}).sort((a, b) => compare(a.sort, b.sort));
|
|
76
|
+
rows.push(...named);
|
|
77
|
+
} catch (error) { if (harness === 'orchestrator' && !['ENOENT', 'ENOTDIR'].includes(error.code)) return []; }
|
|
78
|
+
return rows;
|
|
79
|
+
}
|
|
80
|
+
const float = value => {
|
|
81
|
+
if (typeof value === 'number') return value;
|
|
82
|
+
if (typeof value !== 'string') return null;
|
|
83
|
+
if (/^[+-]?(?:inf|infinity)$/iu.test(value)) return value.startsWith('-') ? -Infinity : Infinity;
|
|
84
|
+
if (/^[+-]?nan$/iu.test(value)) return NaN;
|
|
85
|
+
return /^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/u.test(value) ? Number(value) : null;
|
|
86
|
+
};
|
|
87
|
+
const cast = (number, low, high) => Number.isNaN(number) ? 0n : number <= Number(low) ? low : number >= Number(high) ? high : BigInt(Math.trunc(number));
|
|
88
|
+
function obligationDelivery(profiles, id) {
|
|
89
|
+
let obligation;
|
|
90
|
+
for (const [, profile] of [...profiles].sort(([a], [b]) => compare(a, b))) {
|
|
91
|
+
obligation = profile.obligations.find(row => row.id === id); if (obligation) break;
|
|
92
|
+
}
|
|
93
|
+
if (!obligation) return null;
|
|
94
|
+
const row = obligation.row, thread = hermesRowText(row, 'thread_id'), state = row.state === 'sent' ? 'delivered' : row.state;
|
|
95
|
+
const updated = hermesRowText(row, 'updated_at') ?? obligation.created_at;
|
|
96
|
+
const at = state === 'delivered' ? float(updated) : null;
|
|
97
|
+
const delivery = { target: `${obligation.target.platform}:${obligation.target.chat_id}${thread ? `:${thread}` : ''}`, state,
|
|
98
|
+
attempts: 0, last_error: hermesRowText(row, 'last_error') || null, delivered_at: at == null ? null : usageMoment(cast(at * 1000, intMin, intMax)) };
|
|
99
|
+
setNativeInteger(delivery, 'attempts', cast(float(row.attempts) ?? 0, 0n, uintMax));
|
|
100
|
+
return delivery;
|
|
101
|
+
}
|
|
102
|
+
function hermesSource(fire) {
|
|
103
|
+
return nativeObject([
|
|
104
|
+
['id', token(fire.id)], ['job_id', token(fire.job_id)], ['source', member(fire.residue, 'source', '"scheduler"')],
|
|
105
|
+
['process_id', member(fire.residue, 'process_id', '""')], ['pid', member(fire.residue, 'pid', '0')],
|
|
106
|
+
['process_started_at', member(fire.residue, 'process_started_at')], ['status', token(fire.status === 'succeeded' ? 'completed' : fire.status)],
|
|
107
|
+
...['claimed_at', 'started_at', 'finished_at', 'error'].map(key => [key, token(fire[key])]),
|
|
108
|
+
]);
|
|
109
|
+
}
|
|
110
|
+
function folderRuns(harness, selected, context) {
|
|
111
|
+
const home = harness === 'hermes' ? parent(selected.homes.hermes) ?? '.' : selected.homes.orchestrator;
|
|
112
|
+
const inventory = ledgers(home, harness, context.cwd), rows = [], sources = [];
|
|
113
|
+
// Display the caller's lexical paths, just as native PathBuf serialization
|
|
114
|
+
// does, while filesystem access resolves them against the caller's cwd.
|
|
115
|
+
const actual = filesystem(home, context.cwd);
|
|
116
|
+
let profiles;
|
|
117
|
+
try { profiles = readRunFolderHome(actual, harness); }
|
|
118
|
+
catch (error) {
|
|
119
|
+
for (const ledger of inventory) { const state = file(filesystem(ledger.path, context.cwd)) ? 'unreadable' : 'absent_store'; sources.push(source(harness, ledger.path, ledger.profile, state, state === 'unreadable' ? error.message : null)); }
|
|
120
|
+
return { rows, sources };
|
|
121
|
+
}
|
|
122
|
+
for (const ledger of inventory) {
|
|
123
|
+
const profile = profiles.get(ledger.profile ?? 'default'), state = file(filesystem(ledger.path, context.cwd)) && profile ? 'read' : 'absent_store';
|
|
124
|
+
sources.push(source(harness, ledger.path, ledger.profile, state)); if (state !== 'read') continue;
|
|
125
|
+
const fires = limited(profile.fires.filter(fire => selected.job == null || fire.job_id === selected.job).sort((a, b) => compare(b.claimed_at, a.claimed_at) || compare(b.id, a.id)), selected.limit);
|
|
126
|
+
for (const fire of fires) rows.push({ run: { id: fire.id, harness, job_id: fire.job_id, status: fire.status === 'succeeded' ? 'completed' : fire.status,
|
|
127
|
+
claimed_at: fire.claimed_at, started_at: fire.started_at, finished_at: fire.finished_at, error: fire.error,
|
|
128
|
+
session_id: fire.session_id, delivery: fire.obligation_id == null ? null : obligationDelivery(profiles, fire.obligation_id) }, source: hermesSource(fire) });
|
|
129
|
+
}
|
|
130
|
+
return { rows, sources };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const floor = (a, b) => a < 0n ? (a - b + 1n) / b : a / b;
|
|
134
|
+
function openclawMoment(ms) {
|
|
135
|
+
if (ms == null) return null;
|
|
136
|
+
const seconds = floor(ms, 1000n), sub = ms - seconds * 1000n, days = floor(seconds, 86400n), sod = seconds - days * 86400n;
|
|
137
|
+
const z = days + 719468n, era = floor(z, 146097n), doe = z - era * 146097n;
|
|
138
|
+
const yoe = (doe - doe / 1460n + doe / 36524n - doe / 146096n) / 365n;
|
|
139
|
+
let y = yoe + era * 400n;
|
|
140
|
+
const doy = doe - (365n * yoe + yoe / 4n - yoe / 100n), mp = (5n * doy + 2n) / 153n;
|
|
141
|
+
const day = doy - (153n * mp + 2n) / 5n + 1n, month = mp < 10n ? mp + 3n : mp - 9n;
|
|
142
|
+
y += month <= 2n ? 1n : 0n;
|
|
143
|
+
const pad = (n, width = 2) => n < 0n ? `-${String(-n).padStart(width - 1, '0')}` : String(n).padStart(width, '0');
|
|
144
|
+
return `${pad(y, 4)}-${pad(month)}-${pad(day)}T${pad(sod / 3600n)}:${pad(sod % 3600n / 60n)}:${pad(sod % 60n)}.${pad(sub, 3)}Z`;
|
|
145
|
+
}
|
|
146
|
+
const msOf = (row, key) => nativeInteger(row, key, { signed: true }) ?? (float(row[key]) == null ? null : cast(float(row[key]), intMin, intMax));
|
|
147
|
+
const openText = (row, key) => typeof row[key] === 'string' ? row[key] || null : typeof row[key] === 'number' ? stringifyNativeJsonMember(row, key) : null;
|
|
148
|
+
const jsonObject = value => { try { const parsed = typeof value === 'string' ? parseNativeJson(value) : null; return object(parsed) ? parsed : {}; } catch { return {}; } };
|
|
149
|
+
const present = (...entries) => entries.find(([row, key]) => object(row) && Object.hasOwn(row, key));
|
|
150
|
+
function openclawProfiles(loaded) {
|
|
151
|
+
const profiles = new Map(loaded.profiles.map(name => [name, { jobs: new Map(), fires: [] }])), owners = new Map();
|
|
152
|
+
const owner = value => { const name = value === loaded.defaultAgent ? 'default' : value; return profiles.has(name) ? name : 'default'; };
|
|
153
|
+
for (const row of loaded.jobs) {
|
|
154
|
+
const id = openText(row, 'job_id') ?? '', record = jsonObject(row.job_json);
|
|
155
|
+
const field = present([record, 'agentId'], [row, 'agent_id'], [row, 'owner_agent_id']);
|
|
156
|
+
const name = owner(field ? string(field[0][field[1]]) ?? loaded.defaultAgent : loaded.defaultAgent);
|
|
157
|
+
const delivery = object(record.delivery) ? record.delivery : Object.fromEntries(['channel', 'to'].flatMap(key => row[`delivery_${key}`] != null && row[`delivery_${key}`] !== '' ? [[key, row[`delivery_${key}`]]] : []));
|
|
158
|
+
profiles.get(name).jobs.set(id, delivery); owners.set(id, name);
|
|
159
|
+
}
|
|
160
|
+
let legacy; try { legacy = loaded.legacy == null ? null : parseNativeJson(loaded.legacy); } catch { /* infallible legacy decoding */ }
|
|
161
|
+
for (const row of Array.isArray(legacy) ? legacy : Array.isArray(legacy?.jobs) ? legacy.jobs : []) {
|
|
162
|
+
const id = [row?.id, row?.job_id, row?.jobId].find(value => typeof value === 'string');
|
|
163
|
+
if (!id || [...profiles.values()].some(profile => profile.jobs.has(id))) continue;
|
|
164
|
+
const field = present([row, 'agentId'], [row, 'agent_id']), name = owner(field ? string(field[0][field[1]]) ?? loaded.defaultAgent : loaded.defaultAgent);
|
|
165
|
+
profiles.get(name).jobs.set(id, object(row.delivery) ? row.delivery : {}); owners.set(id, name);
|
|
166
|
+
}
|
|
167
|
+
for (const row of loaded.fires) {
|
|
168
|
+
const id = openText(row, 'run_id'), job_id = openText(row, 'job_id') ?? '', start = msOf(row, 'run_at_ms'), finish = msOf(row, 'ts');
|
|
169
|
+
profiles.get(owners.get(job_id) ?? 'default').fires.push({ row, job_id, id: id ?? `${job_id}#${member(row, 'seq', '')}`,
|
|
170
|
+
started_at: openclawMoment(start), finished_at: openclawMoment(finish), start, finish });
|
|
171
|
+
}
|
|
172
|
+
return profiles;
|
|
173
|
+
}
|
|
174
|
+
const openColumns = ['store_key', 'job_id', 'seq', 'ts', 'status', 'error', 'summary', 'diagnostics_summary', 'delivery_status', 'delivery_error', 'delivered', 'session_id', 'session_key', 'run_id', 'run_at_ms', 'duration_ms', 'next_run_at_ms', 'model', 'provider', 'total_tokens', 'entry_json', 'created_at'];
|
|
175
|
+
function openclawSource(fire) {
|
|
176
|
+
const row = fire.row, encoded = new Map(openColumns.map(key => [key, 'null']));
|
|
177
|
+
const ts = fire.finished_at?.startsWith('-') ? 0n : fire.finish ?? 0n, start = fire.started_at?.startsWith('-') ? null : fire.start;
|
|
178
|
+
encoded.set('store_key', row.store_key == null ? '""' : member(row, 'store_key'));
|
|
179
|
+
encoded.set('job_id', token(fire.job_id)); encoded.set('ts', String(ts));
|
|
180
|
+
// Mapped seq is consumed by decoding and is absent from Fire residue.
|
|
181
|
+
encoded.set('status', row.status == null ? '"skipped"' : member(row, 'status'));
|
|
182
|
+
for (const key of ['error', 'session_id']) encoded.set(key, token(openText(row, key)));
|
|
183
|
+
for (const key of ['delivery_status', 'delivery_error', 'delivered', 'session_key']) encoded.set(key, row[key] == null ? 'null' : member(row, key));
|
|
184
|
+
encoded.set('run_id', fire.id.includes('#') ? 'null' : token(fire.id)); encoded.set('run_at_ms', start == null ? 'null' : String(start));
|
|
185
|
+
encoded.set('entry_json', row.entry_json == null ? token(`{"action":"finished","ts":${ts},"jobId":${token(fire.job_id)},"status":${encoded.get('status')}}`) : member(row, 'entry_json'));
|
|
186
|
+
encoded.set('created_at', row.created_at == null ? String(ts) : member(row, 'created_at'));
|
|
187
|
+
return nativeObject([...encoded]);
|
|
188
|
+
}
|
|
189
|
+
function openclawRuns(selected, context) {
|
|
190
|
+
const path = join(selected.homes.openclaw, 'state/openclaw.sqlite'), actual = filesystem(path, context.cwd);
|
|
191
|
+
if (!file(actual)) return { rows: [], sources: [source('openclaw', path, null, 'absent_store')] };
|
|
192
|
+
let loaded;
|
|
193
|
+
try { loaded = readOpenclawRunHome(filesystem(selected.homes.openclaw, context.cwd)); }
|
|
194
|
+
catch (error) { return { rows: [], sources: [source('openclaw', path, null, 'unreadable', error.message)] }; }
|
|
195
|
+
const fires = [...openclawProfiles(loaded)].sort(([a], [b]) => compare(a, b)).flatMap(([, profile]) => profile.fires.map(fire => ({ fire, profile })));
|
|
196
|
+
fires.sort((a, b) => a.fire.finished_at == null ? b.fire.finished_at == null ? compare(b.fire.id, a.fire.id) : 1 : b.fire.finished_at == null ? -1 : compare(b.fire.finished_at, a.fire.finished_at) || compare(b.fire.id, a.fire.id));
|
|
197
|
+
const rows = limited(fires.filter(({ fire }) => selected.job == null || selected.job === fire.job_id), selected.limit).map(({ fire, profile }) => {
|
|
198
|
+
const row = fire.row, words = profile.jobs.get(fire.job_id), channel = string(words?.channel) || null, to = string(words?.to) || null;
|
|
199
|
+
const target = channel && to ? `${channel}:${to}` : channel ?? to;
|
|
200
|
+
const status = string(row.delivery_status) || null, error = string(row.delivery_error) || null, delivered = nativeInteger(row, 'delivered', { signed: true });
|
|
201
|
+
return { run: { id: fire.id, harness: 'openclaw', job_id: fire.job_id, status: string(row.status) ?? '', claimed_at: null,
|
|
202
|
+
started_at: fire.started_at, finished_at: fire.finished_at, error: openText(row, 'error'), session_id: openText(row, 'session_id'),
|
|
203
|
+
delivery: status == null && error == null && delivered == null ? null : { target, state: status ?? (delivered == null ? null : delivered === 0n ? 'not-delivered' : 'delivered'), attempts: null, last_error: error, delivered_at: null } }, source: openclawSource(fire) };
|
|
204
|
+
});
|
|
205
|
+
return { rows, sources: [source('openclaw', path, null, 'read')] };
|
|
206
|
+
}
|
|
207
|
+
function collect(selected, context) {
|
|
208
|
+
const rows = [], sources = [];
|
|
209
|
+
for (const harness of RUN_HARNESSES) if (selected.harness == null || selected.harness === harness) {
|
|
210
|
+
const reading = harness === 'openclaw' ? openclawRuns(selected, context) : folderRuns(harness, selected, context);
|
|
211
|
+
rows.push(...reading.rows); sources.push(...reading.sources);
|
|
212
|
+
}
|
|
213
|
+
return { rows, sources };
|
|
214
|
+
}
|
|
215
|
+
export function listRuns(params, { env = process.env, cwd = process.cwd() } = {}) {
|
|
216
|
+
const context = { env, cwd }, selected = query(params, context), result = collect(selected, context);
|
|
217
|
+
return { runs: limited(result.rows, selected.limit).map(row => row.run), sources: result.sources };
|
|
218
|
+
}
|
|
219
|
+
export function getRun(params, { env = process.env, cwd = process.cwd() } = {}) {
|
|
220
|
+
const context = { env, cwd }, selected = query(params, context, true);
|
|
221
|
+
const found = collect(selected, context).rows.find(row => row.run.id === selected.id);
|
|
222
|
+
if (!found) refuse(`\`${selected.harness}\` has no run \`${selected.id}\``, -32000);
|
|
223
|
+
return found;
|
|
224
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SessionReadContext } from './session-read.js';
|
|
2
|
+
export interface SessionActivityReading {
|
|
3
|
+
harness: string; session_id: string;
|
|
4
|
+
presence: 'persisted' | 'running' | 'shutting_down';
|
|
5
|
+
turn: 'unknown' | 'idle' | 'working' | 'needs_input';
|
|
6
|
+
evidence: { source: string; native_state: string | null; observed_at_ms: number; harness_version: string | null; waiting_for?: string; native_state_since_ms?: number; turn_trigger?: string };
|
|
7
|
+
}
|
|
8
|
+
export interface ActivitySubscriptionQuery {
|
|
9
|
+
locators: Array<{ harness: string; session_id: string; storage: { kind: 'file' | 'sqlite'; path: string; selector?: string } }>;
|
|
10
|
+
homes: Record<string, string>; home: string;
|
|
11
|
+
}
|
|
12
|
+
export const SESSION_ACTIVITY_EVENT: 'harness.v1.sessions.activity_event';
|
|
13
|
+
export function sessionActivityKey(activity: { harness: string; session_id: string }): string;
|
|
14
|
+
export function activitySubscribeQuery(params: unknown, context?: SessionReadContext): ActivitySubscriptionQuery;
|
|
15
|
+
export function activityUnsubscribeQuery(params: unknown): string;
|
|
16
|
+
export function sameSessionActivity(left: SessionActivityReading, right: SessionActivityReading): boolean;
|
|
17
|
+
export function activityUnderQuery(params: unknown, context?: SessionReadContext): { pids: number[]; homes: Record<string, string>; home: string };
|
|
18
|
+
export function codexDescendantActivity(statuses: Map<string, string>): Map<string, string>;
|
|
19
|
+
export function sampledSessionActivity(locator: { harness: string; session_id: string; storage: { path: string } }, evidence: {
|
|
20
|
+
claude: Map<string, { status?: string | null; version?: string | null; waitingFor?: string | null; statusUpdatedAt?: number | null }>;
|
|
21
|
+
codex: Map<string, string>; codexTurns?: Map<string, { trigger: string | null; started_at_ms: number | null }>; descendants: Map<string, string>; owned: Map<string, string>; observedAt?: number;
|
|
22
|
+
hermes?: Map<string, { status: string; pid: number | null }>; live?: (pid: number) => boolean; codexPath?: string;
|
|
23
|
+
}): SessionActivityReading;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Proven lifecycle evidence, separate from transcript freshness and attention.
|
|
2
|
+
import { sessionTreeQuery, treeUnsigned, treeRolloutHeader } from './session-machine-tree.mjs';
|
|
3
|
+
import { parseSessionReadParams } from './session-read.mjs';
|
|
4
|
+
import { setNativeInteger } from './native-json.mjs';
|
|
5
|
+
|
|
6
|
+
const invalid = message => { throw Object.assign(new Error(message), { rpcCode: -32602 }); };
|
|
7
|
+
const object = value => value != null && typeof value === 'object' && !Array.isArray(value);
|
|
8
|
+
const key = locator => JSON.stringify([locator.harness, locator.session_id]);
|
|
9
|
+
const rank = { running: 0, idle: 1, busy: 2, waiting: 3 };
|
|
10
|
+
const stronger = (left, right) => left == null ? right : right == null || rank[left] >= rank[right] ? left : right;
|
|
11
|
+
|
|
12
|
+
export const SESSION_ACTIVITY_EVENT = 'harness.v1.sessions.activity_event';
|
|
13
|
+
export const sessionActivityKey = key;
|
|
14
|
+
|
|
15
|
+
/** Subscription admission reads locators as addresses, without loading a
|
|
16
|
+
* transcript or applying load/view options. Unmodeled request keys are ignored. */
|
|
17
|
+
export function activitySubscribeQuery(params, context = {}) {
|
|
18
|
+
if (!object(params)) invalid('activity subscribe params must be an object');
|
|
19
|
+
if (!Array.isArray(params.locators)) invalid('locators must be an array');
|
|
20
|
+
if (!params.locators.length) invalid('sessions.activity.subscribe requires at least one locator');
|
|
21
|
+
if (params.locators.length > 2048) invalid('sessions.activity.subscribe accepts at most 2048 locators');
|
|
22
|
+
const locators = params.locators.map(supplied => {
|
|
23
|
+
const { locator } = parseSessionReadParams({ locator: supplied }, context);
|
|
24
|
+
// Activity's native locator keeps its lexical path. The ownership sampler
|
|
25
|
+
// canonicalizes it at lookup time, without changing the recorded address.
|
|
26
|
+
locator.storage.path = supplied.storage.path;
|
|
27
|
+
return locator;
|
|
28
|
+
});
|
|
29
|
+
const query = sessionTreeQuery(Object.hasOwn(params, 'homes') ? { homes: params.homes } : {}, context);
|
|
30
|
+
return { locators, homes: query.homes, home: query.home };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function activityUnsubscribeQuery(params) {
|
|
34
|
+
if (!object(params) || typeof params.subscription !== 'string') invalid('subscription must be a string');
|
|
35
|
+
return params.subscription;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Observation time is evidence, but changing only the clock is no transition. */
|
|
39
|
+
export function sameSessionActivity(left, right) {
|
|
40
|
+
return left.harness === right.harness && left.session_id === right.session_id
|
|
41
|
+
&& left.presence === right.presence && left.turn === right.turn
|
|
42
|
+
&& left.evidence.source === right.evidence.source
|
|
43
|
+
&& left.evidence.native_state === right.evidence.native_state
|
|
44
|
+
&& left.evidence.waiting_for === right.evidence.waiting_for
|
|
45
|
+
&& (treeUnsigned(left.evidence, 'native_state_since_ms') ?? left.evidence.native_state_since_ms)
|
|
46
|
+
=== (treeUnsigned(right.evidence, 'native_state_since_ms') ?? right.evidence.native_state_since_ms)
|
|
47
|
+
&& left.evidence.turn_trigger === right.evidence.turn_trigger
|
|
48
|
+
&& left.evidence.harness_version === right.evidence.harness_version;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function activityUnderQuery(params, context) {
|
|
52
|
+
if (!object(params)) invalid('activity_under params must be an object');
|
|
53
|
+
for (const name of Object.keys(params)) if (!['pids', 'homes'].includes(name)) invalid(`unknown field ${name}`);
|
|
54
|
+
if (!Array.isArray(params.pids)) invalid('pids must be an array');
|
|
55
|
+
if (params.pids.length > 1024) invalid('sessions.activity_under accepts at most 1024 pids');
|
|
56
|
+
const pids = params.pids.map((_, index) => {
|
|
57
|
+
const pid = treeUnsigned(params.pids, String(index));
|
|
58
|
+
if (pid == null || pid > 0xffffffffn) invalid('pids must contain unsigned 32-bit integers');
|
|
59
|
+
return Number(pid);
|
|
60
|
+
});
|
|
61
|
+
const query = sessionTreeQuery(Object.hasOwn(params, 'homes') ? { homes: params.homes } : {}, context);
|
|
62
|
+
return { pids, homes: query.homes, home: query.home };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Strongest live descendant at the root of the recorded parent chain. */
|
|
66
|
+
export function codexDescendantActivity(statuses) {
|
|
67
|
+
const lineage = [...statuses].flatMap(([path, status]) => {
|
|
68
|
+
const header = treeRolloutHeader(path); return header == null ? [] : [{ ...header, status }];
|
|
69
|
+
});
|
|
70
|
+
const parents = new Map(lineage.filter(row => row.parent != null).map(row => [row.thread, row.parent]));
|
|
71
|
+
const result = new Map();
|
|
72
|
+
for (const row of lineage) {
|
|
73
|
+
if (row.parent == null) continue;
|
|
74
|
+
let root = row.parent; const seen = new Set();
|
|
75
|
+
while (!seen.has(root)) {
|
|
76
|
+
seen.add(root); if (!parents.has(root)) break; root = parents.get(root);
|
|
77
|
+
}
|
|
78
|
+
result.set(root, stronger(result.get(root), row.status));
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Runtime evidence wins over stock evidence; absent ownership stays unknown. */
|
|
84
|
+
export function sampledSessionActivity(locator, { claude, codex, codexTurns = new Map(), descendants, owned, hermes = new Map(), live = () => false, codexPath = locator.storage.path, observedAt = Date.now() }) {
|
|
85
|
+
const result = { harness: locator.harness, session_id: locator.session_id, presence: 'persisted', turn: 'unknown',
|
|
86
|
+
evidence: { source: 'persisted_store', native_state: null, observed_at_ms: observedAt, harness_version: null } };
|
|
87
|
+
const runtime = owned.get(key(locator));
|
|
88
|
+
if (runtime != null) {
|
|
89
|
+
result.presence = runtime === 'persisted' ? 'persisted' : runtime === 'shutting_down' ? 'shutting_down' : 'running';
|
|
90
|
+
result.turn = runtime === 'busy' ? 'working' : runtime === 'idle' ? 'idle' : 'unknown';
|
|
91
|
+
result.evidence.source = 'supercode_runtime'; result.evidence.native_state = runtime;
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
if (locator.harness === 'claude-code' && claude.has(locator.session_id)) {
|
|
95
|
+
const peer = claude.get(locator.session_id), status = peer.status === 'shell' ? 'idle' : peer.status;
|
|
96
|
+
result.presence = 'running'; result.evidence.source = 'claude_registry';
|
|
97
|
+
result.evidence.native_state = ['busy', 'idle', 'waiting'].includes(status) ? status : null;
|
|
98
|
+
result.turn = status === 'waiting' && peer.waitingFor === 'dialog open' ? 'unknown'
|
|
99
|
+
: ({ busy: 'working', idle: 'idle', waiting: 'needs_input' })[status] ?? 'unknown';
|
|
100
|
+
if (status === 'waiting' && peer.waitingFor != null) result.evidence.waiting_for = peer.waitingFor;
|
|
101
|
+
const since = treeUnsigned(peer, 'statusUpdatedAt');
|
|
102
|
+
if (since != null) setNativeInteger(result.evidence, 'native_state_since_ms', since);
|
|
103
|
+
result.evidence.harness_version = peer.version ?? null;
|
|
104
|
+
} else if (locator.harness === 'hermes' && hermes.has(locator.session_id)) {
|
|
105
|
+
const fire = hermes.get(locator.session_id), running = ['claimed', 'running'].includes(fire.status);
|
|
106
|
+
const proven = running && fire.pid != null && live(fire.pid);
|
|
107
|
+
result.presence = proven ? 'running' : 'persisted'; result.turn = proven ? 'working' : 'unknown';
|
|
108
|
+
result.evidence.source = 'hermes_executions';
|
|
109
|
+
result.evidence.native_state = running && !proven ? 'abandoned' : fire.status === 'succeeded' ? 'completed' : fire.status;
|
|
110
|
+
} else if (locator.harness === 'codex') {
|
|
111
|
+
const status = stronger(codex.get(codexPath), descendants.get(locator.session_id));
|
|
112
|
+
if (status != null) {
|
|
113
|
+
result.presence = 'running'; result.evidence.source = 'codex_rollout'; result.evidence.native_state = status;
|
|
114
|
+
result.turn = ({ busy: 'working', idle: 'idle', waiting: 'needs_input' })[status] ?? 'unknown';
|
|
115
|
+
if (result.turn === 'working') {
|
|
116
|
+
const turn = codexTurns.get(codexPath);
|
|
117
|
+
if (turn?.trigger != null) result.evidence.turn_trigger = turn.trigger;
|
|
118
|
+
const since = treeUnsigned(turn, 'started_at_ms');
|
|
119
|
+
if (since != null) setNativeInteger(result.evidence, 'native_state_since_ms', since);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
}
|
package/session-append-scan.mjs
CHANGED
|
@@ -1,31 +1,40 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
|
|
3
|
-
export const EDGE_BYTES = 64 * 1024,
|
|
3
|
+
export const EDGE_BYTES = 64 * 1024, CHECK_BYTES = 4 * 1024 * 1024;
|
|
4
4
|
export const shortHash = (bytes) => createHash('sha256').update(bytes).digest('hex').slice(0, 24);
|
|
5
5
|
|
|
6
6
|
/** What a pass over a file that is only added to keeps of where it read: the file, the bytes it read through (from
|
|
7
|
-
* `from` to `bytes`,
|
|
8
|
-
*
|
|
7
|
+
* `from` to `bytes`, given to `took` as they were read) and how the file stood when it stopped. The next read of the
|
|
8
|
+
* same file takes only what was written since. The bytes read are kept as checks of at most CHECK_BYTES each (the
|
|
9
|
+
* hash of each run of whole lines), so that what was read is verified a bounded read at a time, never all at once. */
|
|
9
10
|
export class AppendScan {
|
|
10
|
-
bytes = 0; from = 0;
|
|
11
|
+
bytes = 0; from = 0; stamp = null; edge = null; size = 0;
|
|
12
|
+
checks = []; open = null; verifyAt = 0;
|
|
11
13
|
|
|
12
14
|
constructor(reader) { this.device = reader.fingerprint.device; this.inode = reader.fingerprint.inode; }
|
|
13
15
|
|
|
16
|
+
/** A line's bytes, read through to `end`: added to the check being made, which closes at CHECK_BYTES. */
|
|
17
|
+
took(bytes, end) {
|
|
18
|
+
if (this.open == null) this.open = { from: end - bytes.length, hash: createHash('sha256') };
|
|
19
|
+
this.open.hash.update(bytes);
|
|
20
|
+
if (end - this.open.from >= CHECK_BYTES) { this.checks.push({ from: this.open.from, to: end, hash: this.open.hash.digest('hex') }); this.open = null; }
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
/** Whether this is the scan of `reader`'s file: the same file, no shorter, its scanned bytes as they were. A file
|
|
15
|
-
* whose size and modification time are those last read is as it was.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* line decoded: a record rewritten in place, whatever its length, ends the scan and every mark made from it, at
|
|
20
|
-
* once when the file did not also grow, and within VERIFY_MS when it did. */
|
|
24
|
+
* whose size and modification time are those last read is as it was. Any other must still hold the last EDGE_BYTES
|
|
25
|
+
* the scan read, and one check of what it read, taken in turn from read to read, must hold too: a record rewritten
|
|
26
|
+
* in place, whatever its length, ends the scan and every mark made from it within as many reads as the scan has
|
|
27
|
+
* checks, each a bounded read (a scan of no more than one check is verified whole at every read). */
|
|
21
28
|
async holds(reader) {
|
|
22
29
|
if (reader.fingerprint.device !== this.device || reader.fingerprint.inode !== this.inode || reader.size < this.bytes) return false;
|
|
23
30
|
if (this.unchanged(reader)) return true;
|
|
24
|
-
if (
|
|
31
|
+
if (this.edge !== await this.edgeOf(reader)) return false;
|
|
32
|
+
const checks = this.open == null ? this.checks : [...this.checks, { from: this.open.from, to: this.bytes, hash: this.open.hash.copy().digest('hex') }];
|
|
33
|
+
if (!checks.length) return true;
|
|
34
|
+
const check = checks[this.verifyAt++ % checks.length];
|
|
25
35
|
const hash = createHash('sha256');
|
|
26
|
-
for (let at =
|
|
27
|
-
|
|
28
|
-
return hash.digest('hex') === this.hashed.copy().digest('hex');
|
|
36
|
+
for (let at = check.from; at < check.to;) { const block = await reader.read(at, Math.min(1024 * 1024, check.to - at)); hash.update(block); at += block.length; }
|
|
37
|
+
return hash.digest('hex') === check.hash;
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
/** Whether the file's size and modification time are those this scan last read it at. */
|
|
@@ -36,9 +45,10 @@ export class AppendScan {
|
|
|
36
45
|
return shortHash(await reader.read(from, this.bytes - from));
|
|
37
46
|
}
|
|
38
47
|
|
|
39
|
-
/** The file as a pass that read on from `from` leaves it.
|
|
40
|
-
|
|
48
|
+
/** The file as a pass that read on from `from` leaves it. A pass that stopped before the file's last whole line
|
|
49
|
+
* (`behind`) leaves no stamp: the next read verifies and reads on. */
|
|
50
|
+
async settle(reader, from, behind = false) {
|
|
41
51
|
if (this.bytes !== from || this.edge == null) this.edge = await this.edgeOf(reader);
|
|
42
|
-
this.size = reader.size; this.stamp = `${reader.fingerprint.size}:${reader.fingerprint.mtime_ns}`;
|
|
52
|
+
this.size = behind ? this.bytes : reader.size; this.stamp = behind ? null : `${reader.fingerprint.size}:${reader.fingerprint.mtime_ns}`;
|
|
43
53
|
}
|
|
44
54
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Frozen branch artifacts and handoff specifications. Neither door launches a
|
|
2
|
+
// vendor or materializes its native store; the source residue cache is shared
|
|
3
|
+
// with transfer. Cuts use a recorded user-message identity, never a view offset.
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { cloneNativeJson } from './native-json.mjs';
|
|
6
|
+
import { normalizedSessionJson } from './session-model.mjs';
|
|
7
|
+
import { loadSessionLocator } from './session-path.mjs';
|
|
8
|
+
import { parseTransferLocator, transferTarget, sessionArtifact } from './session-transfer.mjs';
|
|
9
|
+
import { freshSessionId } from './session-fresh-id.mjs';
|
|
10
|
+
|
|
11
|
+
const fail = (message, rpcCode = -32602) => { throw Object.assign(new Error(message), { rpcCode }); };
|
|
12
|
+
function struct(value, fields, name, required = fields.length) {
|
|
13
|
+
if (Array.isArray(value)) {
|
|
14
|
+
if (value.length < required || value.length > fields.length) fail(`${name} positional params require ${fields.join(', ')}`);
|
|
15
|
+
return Object.fromEntries(fields.map((field, at) => [field, value[at]]));
|
|
16
|
+
}
|
|
17
|
+
if (value == null || typeof value !== 'object') fail(`${name} must be an object`);
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
const string = (value, name) => { if (typeof value !== 'string') fail(`${name} must be a string`); return value; };
|
|
21
|
+
function messageId(value) {
|
|
22
|
+
if (value == null) return null;
|
|
23
|
+
const id = struct(value, ['key', 'value'], 'before_message_id');
|
|
24
|
+
return { key: string(id.key, 'before_message_id.key'), value: string(id.value, 'before_message_id.value') };
|
|
25
|
+
}
|
|
26
|
+
function parseParams(value, handoff) {
|
|
27
|
+
const params = struct(value, handoff ? ['locator', 'target_harness', 'cwd', 'before_message_id']
|
|
28
|
+
: ['locator', 'target_harness', 'before_message_id'], handoff ? 'session handoff params' : 'session branch params', handoff ? 2 : 1);
|
|
29
|
+
const locator = parseTransferLocator(params.locator);
|
|
30
|
+
const target = !handoff && params.target_harness == null ? null : transferTarget(params.target_harness);
|
|
31
|
+
const cwd = handoff && params.cwd != null ? string(params.cwd, 'cwd') : null;
|
|
32
|
+
return { locator, target, cwd, before: messageId(params.before_message_id) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sessionBefore(session, id) {
|
|
36
|
+
if (id == null) return { session, cut: null };
|
|
37
|
+
if (id.key !== 'claude_uuid') fail(`${id.key} is not a per-message id supercode reads; a fork at a turn names a message by claude_uuid`, -32020);
|
|
38
|
+
const places = [];
|
|
39
|
+
for (const [at, message] of session.messages.entries()) {
|
|
40
|
+
if (message.role === 'user' && message.metadata[id.key] === id.value) places.push(at);
|
|
41
|
+
if (places.length === 2) break;
|
|
42
|
+
}
|
|
43
|
+
if (!places.length) fail(`no user message in this session has ${id.key} ${id.value}`, -32000);
|
|
44
|
+
if (places.length > 1) fail(`more than one user message has ${id.key} ${id.value}; it does not name one turn`, -32000);
|
|
45
|
+
const keep = places[0], messages = cloneNativeJson(session.messages.slice(0, keep));
|
|
46
|
+
const spawned = new Set(messages.flatMap(message => message.tool_calls ?? []).map(call => call.id));
|
|
47
|
+
const subagents = session.subagents.filter(child => child.meta.parent_tool_use_id != null && spawned.has(child.meta.parent_tool_use_id));
|
|
48
|
+
const meta = cloneNativeJson(session.meta);
|
|
49
|
+
// Other source headers/message records are retained as in Session::cut_before.
|
|
50
|
+
meta.native_residue = []; meta.native_residue_source = null; meta.codex_provenance = [];
|
|
51
|
+
return { cut: keep + 1, session: { ...session, meta, messages, subagents: cloneNativeJson(subagents),
|
|
52
|
+
raw: [], raw_trailing_newline: true, imported_message_count: null, raw_is_verbatim: false,
|
|
53
|
+
load_residue: [...session.load_residue] } };
|
|
54
|
+
}
|
|
55
|
+
async function loadBefore(params, context) {
|
|
56
|
+
context.signal?.throwIfAborted();
|
|
57
|
+
const locator = { ...params.locator, storage: { ...params.locator.storage,
|
|
58
|
+
path: resolve(context.cwd ?? process.cwd(), params.locator.storage.path) } };
|
|
59
|
+
const session = await loadSessionLocator(locator, { ...context, fidelity: 'byte_lossless', includeSubagents: true });
|
|
60
|
+
context.signal?.throwIfAborted();
|
|
61
|
+
return sessionBefore(session, params.before);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function branchSession(value, context = {}) {
|
|
65
|
+
const params = parseParams(value, false), { session, cut } = await loadBefore(params, context);
|
|
66
|
+
const { locator, target } = params;
|
|
67
|
+
const prefix = `Continue as a new branch from ${locator.harness} session ${locator.session_id}`;
|
|
68
|
+
const bootstrap_prompt = cut == null
|
|
69
|
+
? `${prefix}. The frozen parent transcript is at ${locator.storage.path}. Read or load that parent for context, summarize the relevant state, then continue independently without mutating the parent session.`
|
|
70
|
+
: `${prefix}, forked before its message ${cut}. The frozen parent transcript is at ${locator.storage.path}. Read or load only what precedes message ${cut} for context, summarize the relevant state, then continue independently without mutating the parent session.`;
|
|
71
|
+
const artifact = target == null ? null : await sessionArtifact(locator, session, target, context);
|
|
72
|
+
return { parent: locator, session: normalizedSessionJson(session), bootstrap_prompt, artifact };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function handoffInstructions(target, sessionId, cwd) {
|
|
76
|
+
const launch = (program, args) => ({ cwd, program, arguments: args, env: {} });
|
|
77
|
+
let program, args, materialize = null, note;
|
|
78
|
+
switch (target) {
|
|
79
|
+
case 'claude-code':
|
|
80
|
+
program = 'claude'; args = ['--resume', sessionId];
|
|
81
|
+
note = "Write the artifact into Claude Code's native project session store before running the resume launch; Claude Code has no general transcript-import command."; break;
|
|
82
|
+
case 'hermes':
|
|
83
|
+
program = 'hermes'; args = ['--resume', sessionId];
|
|
84
|
+
note = 'Hand the artifact (a Codex rollout) to `hermes sessions import --from codex <file>` — `sessions.export --to hermes` does exactly that — and resume the id Hermes prints: Hermes mints its own id and writes its own store.'; break;
|
|
85
|
+
case 'codex':
|
|
86
|
+
program = 'codex'; args = ['resume', sessionId];
|
|
87
|
+
note = "Write the artifact into Codex's native rollout store before running the resume launch; Codex has no general transcript-import command."; break;
|
|
88
|
+
case 'opencode':
|
|
89
|
+
program = 'opencode'; args = ['--session', sessionId]; materialize = launch('opencode', ['import', '{artifact_path}']);
|
|
90
|
+
note = 'Write the artifact to a file, run the materialize command with its path, then launch the imported session.'; break;
|
|
91
|
+
case 'pi':
|
|
92
|
+
program = 'pi'; args = ['--session', '{artifact_path}'];
|
|
93
|
+
note = 'Write the artifact to a file and replace {artifact_path} in the launch arguments; Pi can resume that file directly.'; break;
|
|
94
|
+
case 'grok':
|
|
95
|
+
program = 'grok'; args = ['--resume', '{materialized_session_id}'];
|
|
96
|
+
note = "Grok has no import command. Materialize the artifact through `harness.v1.sessions.materialize` (target `grok`, `value_lossless`, the destination cwd): it writes Grok's store entry (`chat_history.jsonl` and the `summary.json` `--resume` requires) under a fresh id; replace {materialized_session_id} with the id it returns."; break;
|
|
97
|
+
case 'gemini':
|
|
98
|
+
program = 'gemini'; args = ['--session-file', '{artifact_path}'];
|
|
99
|
+
note = "Write the Gemini JSONL artifact to a file and replace {artifact_path}; Gemini imports it into the current project's chat store before opening the continuation."; break;
|
|
100
|
+
case 'goose':
|
|
101
|
+
program = 'goose'; args = ['session', '--resume', '--session-id', '{imported_session_id}'];
|
|
102
|
+
materialize = launch('goose', ['session', 'import', '{artifact_path}']);
|
|
103
|
+
note = 'Write the Goose JSON artifact to a file, run the materialize command, read the imported session id from its output, replace {imported_session_id}, then resume that native Goose session.'; break;
|
|
104
|
+
default: throw new TypeError(`unknown transfer target: ${target}`);
|
|
105
|
+
}
|
|
106
|
+
return { launch: launch(program, args), materialize, requires_materialization: true, note };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function handoffSession(value, context = {}) {
|
|
110
|
+
const params = parseParams(value, true), { session } = await loadBefore(params, context);
|
|
111
|
+
const fresh = freshSessionId(), id = params.target === 'opencode' ? `ses_${fresh.replaceAll('-', '')}` : fresh;
|
|
112
|
+
const artifact = await sessionArtifact(params.locator, session, params.target, context, id);
|
|
113
|
+
if (artifact.session_id == null) fail('handoff artifact omitted target session identity', -32000);
|
|
114
|
+
return { artifact, ...handoffInstructions(params.target, artifact.session_id, params.cwd ?? session.meta.cwd ?? '.') };
|
|
115
|
+
}
|