@ran-sh/dsh-crew 1.3.1 → 1.4.0
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/.claude-plugin/plugin.json +1 -1
- package/lib/client.js +5451 -5436
- package/package.json +1 -1
- package/src/client/history-panel.tsx +4 -4
- package/src/history/admission-gate.mjs +17 -1
- package/src/history/cleanup-plan.mjs +30 -5
- package/src/history/runner-detach.mjs +34 -0
- package/src/history/runtime.mjs +9 -2
- package/src/history/service.mjs +13 -2
- package/src/hub/index.mjs +5 -0
- package/src/jobs.mjs +7 -2
- package/src/runtime-identity.mjs +1 -1
- package/src/session-origins.mjs +60 -0
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@ const pending = (phase?: string) => !!phase && !['IDLE', 'DONE', 'FAILED', 'ROLL
|
|
|
5
5
|
const copy = {
|
|
6
6
|
zh: {
|
|
7
7
|
title: '工作区与会话清理', intro: '仅影响 3210 的工作区记录与会话日志;不删除项目文件、附件或 3080 数据。',
|
|
8
|
-
operation: '操作', archive: '归档(可恢复)', delete: '删除(不可恢复)', scope: '
|
|
8
|
+
operation: '操作', archive: '归档(可恢复)', delete: '删除(不可恢复)', scope: '范围', all: '全部(含你自己的会话)', crew: '仅 Crew 创建(推荐)', worktree: '仅 Crew 工作树', before: '指定时间之前',
|
|
9
9
|
time: '创建时间早于', timeHint: '按本机时区选择,严格按创建时间筛选;包含新会话的工作区会保留。',
|
|
10
10
|
preview: '预览清理范围', confirm: '确认执行', acknowledgement: '删除确认:请输入 DELETE', idle: '会短暂停止并重启 3210;请先结束或关闭所有正在使用的 3210 会话。',
|
|
11
11
|
consent: '我已确认范围,并同意短暂重启 3210', restore: '恢复', archives: '已归档批次', empty: '暂无可恢复的归档',
|
|
@@ -17,7 +17,7 @@ const copy = {
|
|
|
17
17
|
},
|
|
18
18
|
en: {
|
|
19
19
|
title: 'Workspace & session cleanup', intro: 'Only 3210 workspace records and session logs. Project files, attachments and 3080 data are untouched.',
|
|
20
|
-
operation: 'Action', archive: 'Archive (restorable)', delete: 'Delete (permanent)', scope: '
|
|
20
|
+
operation: 'Action', archive: 'Archive (restorable)', delete: 'Delete (permanent)', scope: 'Scope', all: 'Everything (includes your own sessions)', crew: 'Crew-created only (recommended)', worktree: 'Crew worktrees only', before: 'Before a date',
|
|
21
21
|
time: 'Created before', timeHint: 'Local timezone; strict creation-time cutoff. Workspaces with newer sessions are kept.',
|
|
22
22
|
preview: 'Preview cleanup', confirm: 'Confirm operation', acknowledgement: 'Type DELETE to confirm deletion', idle: '3210 will briefly stop and restart. End or close all active 3210 conversations first.',
|
|
23
23
|
consent: 'I reviewed the scope and agree to restart 3210', restore: 'Restore', archives: 'Archived batches', empty: 'No restorable archives',
|
|
@@ -33,7 +33,7 @@ export function HistoryPanel({ locale }: { locale: string }) {
|
|
|
33
33
|
const t = copy[locale === 'zh' ? 'zh' : 'en'];
|
|
34
34
|
const [open, setOpen] = useState(false);
|
|
35
35
|
const [operation, setOperation] = useState('archive');
|
|
36
|
-
const [scope, setScope] = useState('
|
|
36
|
+
const [scope, setScope] = useState('crew');
|
|
37
37
|
const [before, setBefore] = useState('');
|
|
38
38
|
const [preview, setPreview] = useState<any>(null);
|
|
39
39
|
const [status, setStatus] = useState<any>({ phase: 'IDLE' });
|
|
@@ -81,7 +81,7 @@ export function HistoryPanel({ locale }: { locale: string }) {
|
|
|
81
81
|
<p style={{ margin: 0, opacity: .75 }}>{t.intro}</p>
|
|
82
82
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
|
83
83
|
<label>{t.operation} <select style={style} value={operation} disabled={locked} onChange={e => { setOperation(e.target.value); invalidate(); }}><option value="archive">{t.archive}</option><option value="delete">{t.delete}</option></select></label>
|
|
84
|
-
<label>{t.scope} <select style={style} value={scope} disabled={locked} onChange={e => { setScope(e.target.value); invalidate(); }}><option value="all">{t.all}</option><option value="before">{t.before}</option></select></label>
|
|
84
|
+
<label>{t.scope} <select style={style} value={scope} disabled={locked} onChange={e => { setScope(e.target.value); invalidate(); }}><option value="crew">{t.crew}</option><option value="worktree">{t.worktree}</option><option value="all">{t.all}</option><option value="before">{t.before}</option></select></label>
|
|
85
85
|
</div>
|
|
86
86
|
{scope === 'before' && <label>{t.time} <input type="datetime-local" style={style} value={before} disabled={locked} onChange={e => { setBefore(e.target.value); invalidate(); }} /></label>}
|
|
87
87
|
<div style={{ fontSize: 11.5, opacity: .7 }}>{t.timeHint}</div>
|
|
@@ -13,6 +13,22 @@ function method(object, name) {
|
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Whether one registered agent currently owns a running turn.
|
|
18
|
+
*
|
|
19
|
+
* Registration is not activity: the session controller resumes a session into
|
|
20
|
+
* the registry as soon as it opens it, and leaves it there after the turn ends
|
|
21
|
+
* with `status: 'idle'`. Treating a non-empty registry as "in use" therefore
|
|
22
|
+
* wedged cleanup permanently behind ACTIVE_SESSIONS on a machine whose web UI had
|
|
23
|
+
* ever opened a session. Only a `running` status means work is actually in
|
|
24
|
+
* flight; anything else (idle, or a status an older agent does not expose) is
|
|
25
|
+
* treated as not running, and `creating > 0` remains the independent guard for a
|
|
26
|
+
* creation that has not published yet.
|
|
27
|
+
*/
|
|
28
|
+
function isRunning(agent) {
|
|
29
|
+
try { return agent?.status === 'running'; } catch { return false; }
|
|
30
|
+
}
|
|
31
|
+
|
|
16
32
|
export function installHistoryAdmissionGate(agents, isMaintenancePending) {
|
|
17
33
|
if (typeof agents?.create !== 'function' || typeof agents?.list !== 'function'
|
|
18
34
|
|| typeof isMaintenancePending !== 'function') throw new Error('HISTORY_ADMISSION_UNAVAILABLE');
|
|
@@ -40,7 +56,7 @@ export function installHistoryAdmissionGate(agents, isMaintenancePending) {
|
|
|
40
56
|
if (entries.some(entry => method(agents, entry.name) !== entry.wrapper)) return false;
|
|
41
57
|
try {
|
|
42
58
|
const live = agents.list();
|
|
43
|
-
return Array.isArray(live) && creating === 0 && live.
|
|
59
|
+
return Array.isArray(live) && creating === 0 && !live.some(isRunning);
|
|
44
60
|
} catch { return false; }
|
|
45
61
|
},
|
|
46
62
|
dispose() {
|
|
@@ -28,18 +28,42 @@ function records(values) {
|
|
|
28
28
|
return [...values].sort((a, b) => a.id.localeCompare(b.id, 'en'));
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Pure preview only. No filesystem or project-path operations are performed.
|
|
33
|
+
*
|
|
34
|
+
* `scope` defaults to `crew`: a caller that forgets to narrow the range gets the
|
|
35
|
+
* one range that cannot remove sessions the operator opened themselves. Widening
|
|
36
|
+
* to `all` is always an explicit choice.
|
|
37
|
+
*/
|
|
38
|
+
export function planHistoryCleanup(snapshot, { operation = 'archive', scope = 'crew', before } = {}) {
|
|
39
|
+
if (!['archive', 'delete'].includes(operation) || !['all', 'crew', 'worktree', 'before'].includes(scope)) throw new Error('HISTORY_INVALID_OPTIONS');
|
|
34
40
|
const cutoff = scope === 'before' ? instant(before) : null;
|
|
35
41
|
if (scope === 'before' && (typeof before !== 'string' || cutoff === null)) throw new Error('HISTORY_INVALID_CUTOFF');
|
|
36
42
|
const workspaces = records(snapshot.workspaces);
|
|
37
43
|
const sessions = records(snapshot.sessions);
|
|
38
44
|
const active = ids(snapshot.activeSessionIds);
|
|
39
|
-
|
|
45
|
+
// `crew` and `worktree` narrow by provenance, never by project path: a session
|
|
46
|
+
// is Crew's own only when Crew recorded creating it, and worktree scope adds
|
|
47
|
+
// the isolated-workspace marker the hub stamps at dispatch.
|
|
48
|
+
const origin = new Set(Array.isArray(snapshot.crewSessionIds) ? snapshot.crewSessionIds : []);
|
|
49
|
+
const admitted = (row) => {
|
|
50
|
+
if (scope === 'crew') return origin.has(row.id);
|
|
51
|
+
if (scope === 'worktree') return origin.has(row.id) && row.worktree === true;
|
|
52
|
+
return true;
|
|
53
|
+
};
|
|
54
|
+
const withinTime = (row) => cutoff === null
|
|
55
|
+
|| (instant(row.createdAt) !== null && instant(row.createdAt) < cutoff);
|
|
56
|
+
const selected = new Set(sessions.filter(row => admitted(row) && withinTime(row)).map(row => row.id));
|
|
57
|
+
// A workspace carries no provenance of its own: it follows its sessions, and
|
|
58
|
+
// only when every one of them is selected. Checking `admitted(row)` here would
|
|
59
|
+
// test a workspace id against a session ledger and always fail. A provenance
|
|
60
|
+
// scope additionally requires at least one selected child — an empty workspace
|
|
61
|
+
// holds no Crew work, so it is not Crew's to remove; `all` keeps its historical
|
|
62
|
+
// behaviour of following an empty workspace.
|
|
63
|
+
const requiresOwnedChild = scope === 'crew' || scope === 'worktree';
|
|
40
64
|
const workspaceIds = workspaces.filter(row => {
|
|
41
65
|
const children = ids(row.sessionIds);
|
|
42
|
-
return (
|
|
66
|
+
return withinTime(row) && (!requiresOwnedChild || children.length > 0)
|
|
43
67
|
&& children.every(id => selected.has(id));
|
|
44
68
|
}).map(row => row.id).sort();
|
|
45
69
|
const sessionIds = [...selected].sort();
|
|
@@ -48,6 +72,7 @@ export function planHistoryCleanup(snapshot, { operation = 'archive', scope = 'a
|
|
|
48
72
|
workspaces: workspaces.map(row => ({ id: row.id, createdAt: row.createdAt, updatedAt: row.updatedAt, sessionIds: ids(row.sessionIds) })),
|
|
49
73
|
sessions: sessions.map(row => ({ id: row.id, createdAt: row.createdAt, revision: row.revision })),
|
|
50
74
|
active,
|
|
75
|
+
crewSessionIds: [...origin].sort(),
|
|
51
76
|
};
|
|
52
77
|
return {
|
|
53
78
|
schemaVersion: 1, operation, scope,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Detach the history maintenance runner from the hub's process tree.
|
|
2
|
+
//
|
|
3
|
+
// The runner must outlive the hub it is maintaining: its transaction stops 3210,
|
|
4
|
+
// rewrites the session store while nothing holds it, then starts 3210 again. The
|
|
5
|
+
// supervisor stops a service by killing that service's whole tracked process
|
|
6
|
+
// tree, and the tree is walked through `ParentProcessId`. A runner spawned
|
|
7
|
+
// directly by the hub is therefore inside the tree and is killed by the very stop
|
|
8
|
+
// it requested, leaving the transaction stranded in STOPPING with a stale update
|
|
9
|
+
// lock and no operator-side way to finish it.
|
|
10
|
+
//
|
|
11
|
+
// This launcher is the intermediate: it starts the runner, confirms the process
|
|
12
|
+
// exists, and exits immediately. The runner's parent is then a process that no
|
|
13
|
+
// longer exists, so the supervisor's tree walk cannot reach it and it survives.
|
|
14
|
+
//
|
|
15
|
+
// Usage: node runner-detach.mjs <runnerPath> [...runnerArgs]
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
|
|
18
|
+
const [runnerPath, ...runnerArgs] = process.argv.slice(2);
|
|
19
|
+
if (!runnerPath) {
|
|
20
|
+
process.stderr.write('runner-detach: missing runner path\n');
|
|
21
|
+
process.exit(2);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const child = spawn(process.execPath, [runnerPath, ...runnerArgs], {
|
|
25
|
+
detached: true,
|
|
26
|
+
windowsHide: true,
|
|
27
|
+
stdio: 'ignore',
|
|
28
|
+
env: { ...process.env },
|
|
29
|
+
});
|
|
30
|
+
child.once('error', () => process.exit(1));
|
|
31
|
+
child.once('spawn', () => {
|
|
32
|
+
child.unref();
|
|
33
|
+
process.exit(0);
|
|
34
|
+
});
|
package/src/history/runtime.mjs
CHANGED
|
@@ -17,8 +17,15 @@ export function registerRuntimeHistory(ctx) {
|
|
|
17
17
|
const service = createHistoryService({ crewRoot, agents: host.agents, persistence: host.sessionPersistence,
|
|
18
18
|
runtimeId: runtime.runtime_id, launch(id, recover = false) {
|
|
19
19
|
return new Promise((accept, reject) => {
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
// Spawn through a launcher that exits immediately: a runner parented by
|
|
21
|
+
// this hub would sit inside the hub's tracked process tree and be killed
|
|
22
|
+
// by the stop it requested. See runner-detach.mjs.
|
|
23
|
+
const child = spawn(process.execPath, [
|
|
24
|
+
fileURLToPath(new URL('./runner-detach.mjs', import.meta.url)),
|
|
25
|
+
fileURLToPath(new URL('./runner.mjs', import.meta.url)),
|
|
26
|
+
id, ...(recover ? ['--recover'] : []),
|
|
27
|
+
], {
|
|
28
|
+
windowsHide: true, stdio: 'ignore', env: { ...process.env },
|
|
22
29
|
});
|
|
23
30
|
child.once('error', reject); child.once('spawn', () => { child.unref(); accept(); });
|
|
24
31
|
});
|
package/src/history/service.mjs
CHANGED
|
@@ -5,6 +5,8 @@ import { planHistoryCleanup } from './cleanup-plan.mjs';
|
|
|
5
5
|
import { installHistoryAdmissionGate } from './admission-gate.mjs';
|
|
6
6
|
import { historyHash, historyPath, readHistoryBytes, decodeWorkspaceStore, readHistoryManifest, isSessionArtifactPath } from './archive-store.mjs';
|
|
7
7
|
import { readHistoryState, writeHistoryState, historyPending, publicHistoryState } from './state.mjs';
|
|
8
|
+
import { readSessionOrigins } from '../session-origins.mjs';
|
|
9
|
+
import { defaultWorktreeRoot } from '../workspace-isolation.mjs';
|
|
8
10
|
|
|
9
11
|
export function createHistoryService({ crewRoot, agents, persistence, runtimeId, launch, now = Date.now }) {
|
|
10
12
|
const gate = installHistoryAdmissionGate(agents, () => historyPending(crewRoot));
|
|
@@ -60,12 +62,21 @@ export function createHistoryService({ crewRoot, agents, persistence, runtimeId,
|
|
|
60
62
|
}
|
|
61
63
|
const raw = readHistoryBytes(historyPath(crewRoot, path)); total += raw.length;
|
|
62
64
|
if (total > 512 * 1024 * 1024) throw Error('HISTORY_INVENTORY_TOO_LARGE');
|
|
63
|
-
return { id: header.id, createdAt: header.createdAt, parentSession: header.parentSession,
|
|
65
|
+
return { id: header.id, createdAt: header.createdAt, parentSession: header.parentSession, cwd: header.cwd,
|
|
64
66
|
revision: JSON.stringify([header.revision, historyHash(raw)]), artifact: { sessionId: header.id, relativePath: path, sha256: historyHash(raw) } };
|
|
65
67
|
});
|
|
66
68
|
// A retained child keeps its ancestor chain; do not leave a newer fork orphaned.
|
|
67
69
|
const workspaces = Object.entries(store.tables.workspaces).map(([id, row]) => ({ id, ...row }));
|
|
68
|
-
const
|
|
70
|
+
const crewSessionIds = [...readSessionOrigins()];
|
|
71
|
+
const crewSet = new Set(crewSessionIds);
|
|
72
|
+
for (const row of sessions) if (crewSet.has(row.id)) row.crew = true;
|
|
73
|
+
// The isolated-workspace marker: the hub stamps every worktree session with a
|
|
74
|
+
// cwd under the Crew worktree root, so that subset is scopeable on its own.
|
|
75
|
+
const worktreeRoot = defaultWorktreeRoot().replaceAll('\\', '/').toLowerCase();
|
|
76
|
+
for (const row of sessions) {
|
|
77
|
+
if (row.crew === true && typeof row.cwd === 'string' && row.cwd.replaceAll('\\', '/').toLowerCase().startsWith(worktreeRoot)) row.worktree = true;
|
|
78
|
+
}
|
|
79
|
+
const plan = planHistoryCleanup({ workspaces, sessions, crewSessionIds, activeSessionIds: gate.idle() ? [] : ['active-agent'] }, options);
|
|
69
80
|
const selected = new Set(plan.sessionIds);
|
|
70
81
|
const byId = new Map(sessions.map(row => [row.id, row]));
|
|
71
82
|
const queue = sessions.filter(row => !selected.has(row.id));
|
package/src/hub/index.mjs
CHANGED
|
@@ -101,6 +101,7 @@ function seedLangFromHost(ctx) {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
import { setLang } from '../i18n.mjs';
|
|
104
|
+
import { appendSessionOrigin } from '../session-origins.mjs';
|
|
104
105
|
|
|
105
106
|
const ROUTE_BASE = '/_dsh/dsh-crew';
|
|
106
107
|
// Quick-config allowlist: the ONLY config keys writable from the official
|
|
@@ -809,6 +810,10 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
809
810
|
isolatedWorkspace = { worktreePath: created.worktreePath, repoRoot: created.repoRoot };
|
|
810
811
|
}
|
|
811
812
|
const sessionId = `session-${randomUUID()}`;
|
|
813
|
+
// Record provenance while it is still knowable: a session header carries no
|
|
814
|
+
// field naming who asked for the session, so a later Crew-scoped cleanup can
|
|
815
|
+
// only tell Crew's own work apart from the operator's by this ledger.
|
|
816
|
+
appendSessionOrigin({ sessionId, role: jobRole, jobId: id });
|
|
812
817
|
const job = {
|
|
813
818
|
id, client_job_id: client_job_id ?? null, sessionId, role: jobRole, attempt, tier: effTier, provider: selection.provider, model: selection.model,
|
|
814
819
|
selection_source: selection.source, selection_trace: selection.selection_trace ?? null,
|
package/src/jobs.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// never passes an explicit dshBin that would bypass it. The worker rides the
|
|
9
9
|
// `sdk-minimal` profile with the Crew overlay applied as a --patch.
|
|
10
10
|
|
|
11
|
-
import { createStandaloneHarness } from './standalone-sdk.mjs';
|
|
11
|
+
import { createStandaloneHarness } from './standalone-sdk.mjs';
|
|
12
12
|
import { readFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
13
13
|
import { createShardWriter } from './status-shard.mjs';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
@@ -19,6 +19,7 @@ import { captureWorkspaceBaseline, captureWorkspaceDiff, NOT_A_GIT_REPOSITORY }
|
|
|
19
19
|
import { buildOutcome, JOB_PHASES } from './workflow.mjs';
|
|
20
20
|
import { raceWaiters } from './removable-waiter.mjs';
|
|
21
21
|
import { buildDirectSelectionTrace } from './model-routing.mjs';
|
|
22
|
+
import { appendSessionOrigin } from './session-origins.mjs';
|
|
22
23
|
|
|
23
24
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
25
|
const CONFIG_DIR = join(homedir(), '.config', 'dsh-crew');
|
|
@@ -123,7 +124,7 @@ export function startJob({
|
|
|
123
124
|
throw new Error(`DEEPSEEK_API_KEY not found in env or ${join(CONFIG_DIR, '.env')}`);
|
|
124
125
|
}
|
|
125
126
|
|
|
126
|
-
const harness = createStandaloneHarness({
|
|
127
|
+
const harness = createStandaloneHarness({
|
|
127
128
|
profile: 'sdk-minimal',
|
|
128
129
|
patches: [CORDIS],
|
|
129
130
|
dshHome: CREW_DSH_HOME,
|
|
@@ -178,6 +179,10 @@ export function startJob({
|
|
|
178
179
|
.catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace audit failed' }));
|
|
179
180
|
|
|
180
181
|
const sessionId = id;
|
|
182
|
+
// Standalone dispatches write into the same shared session store as the hub,
|
|
183
|
+
// so they need the same provenance record or a Crew-scoped cleanup would leave
|
|
184
|
+
// them behind.
|
|
185
|
+
appendSessionOrigin({ sessionId, role, jobId: id });
|
|
181
186
|
const onNotification = (n) => {
|
|
182
187
|
if (n.method === 'session.status') return;
|
|
183
188
|
if (n.method !== 'session.event') return;
|
package/src/runtime-identity.mjs
CHANGED
|
@@ -36,7 +36,7 @@ export {
|
|
|
36
36
|
// included in the identity contract.
|
|
37
37
|
const RUNTIME_ID = randomUUID();
|
|
38
38
|
|
|
39
|
-
export const RUNTIME_VERSION = '1.
|
|
39
|
+
export const RUNTIME_VERSION = '1.4.0';
|
|
40
40
|
export const HUB_PROTOCOL_VERSION = 1;
|
|
41
41
|
|
|
42
42
|
export const HUB_CAPABILITIES = Object.freeze([
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Durable provenance for sessions the Crew hub created.
|
|
2
|
+
//
|
|
3
|
+
// Cleanup needs to tell a session Crew dispatched apart from one the operator
|
|
4
|
+
// opened in the same UI, because they are otherwise identical: both are ordinary
|
|
5
|
+
// Harness sessions in one shared store, with the same header shape (no field
|
|
6
|
+
// records who asked for them). Provenance has to be recorded at the moment of
|
|
7
|
+
// creation, which only the hub knows.
|
|
8
|
+
//
|
|
9
|
+
// This is deliberately an append-only JSONL file rather than a rewrite of the
|
|
10
|
+
// status shards: a shard removes itself on clean process exit, so it is a
|
|
11
|
+
// best-effort view of live writers, not a record of what was ever created.
|
|
12
|
+
//
|
|
13
|
+
// Absence is not proof of user authorship — a session predating this ledger, or
|
|
14
|
+
// one whose line was lost, simply stays unclaimed. Unclaimed sessions are treated
|
|
15
|
+
// as the operator's and are never removed by a Crew-scoped cleanup.
|
|
16
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
export function sessionOriginsFile({ home = homedir() } = {}) {
|
|
21
|
+
return join(home, '.config', 'dsh-crew', 'session-origins.jsonl');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Record that Crew created one session. Best effort: losing a line weakens a
|
|
26
|
+
* later cleanup's scope, so it must never fail a dispatch that already started.
|
|
27
|
+
*/
|
|
28
|
+
export function appendSessionOrigin({ home = homedir(), sessionId, role = null, jobId = null, now = Date.now } = {}) {
|
|
29
|
+
if (typeof sessionId !== 'string' || !sessionId) return false;
|
|
30
|
+
try {
|
|
31
|
+
const file = sessionOriginsFile({ home });
|
|
32
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
33
|
+
appendFileSync(file, `${JSON.stringify({ sessionId, role, jobId, createdAt: now() })}\n`);
|
|
34
|
+
return true;
|
|
35
|
+
} catch { return false; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Session ids Crew is known to have created.
|
|
40
|
+
*
|
|
41
|
+
* A malformed or truncated line is skipped rather than thrown: the ledger is an
|
|
42
|
+
* optimization for scope, and a damaged tail must not make cleanup unusable. The
|
|
43
|
+
* failure direction is safe — an unread session is treated as the operator's.
|
|
44
|
+
*/
|
|
45
|
+
export function readSessionOrigins({ home = homedir() } = {}) {
|
|
46
|
+
const file = sessionOriginsFile({ home });
|
|
47
|
+
if (!existsSync(file)) return new Set();
|
|
48
|
+
try {
|
|
49
|
+
const ids = new Set();
|
|
50
|
+
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
51
|
+
const trimmed = line.trim();
|
|
52
|
+
if (!trimmed) continue;
|
|
53
|
+
try {
|
|
54
|
+
const row = JSON.parse(trimmed);
|
|
55
|
+
if (typeof row?.sessionId === 'string' && row.sessionId) ids.add(row.sessionId);
|
|
56
|
+
} catch { /* skip a torn line */ }
|
|
57
|
+
}
|
|
58
|
+
return ids;
|
|
59
|
+
} catch { return new Set(); }
|
|
60
|
+
}
|