@ran-sh/dsh-crew 1.3.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ran-sh/dsh-crew",
3
- "version": "1.3.2",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
@@ -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: '时间范围', all: '全部', before: '指定时间之前',
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: 'Time range', all: 'All', before: 'Before a date',
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('all');
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>
@@ -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
- /** Pure preview only. No filesystem or project-path operations are performed. */
32
- export function planHistoryCleanup(snapshot, { operation = 'archive', scope = 'all', before } = {}) {
33
- if (!['archive', 'delete'].includes(operation) || !['all', 'before'].includes(scope)) throw new Error('HISTORY_INVALID_OPTIONS');
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
- const selected = new Set(sessions.filter(row => scope === 'all' || (instant(row.createdAt) !== null && instant(row.createdAt) < cutoff)).map(row => row.id));
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 (scope === 'all' || (instant(row.createdAt) !== null && instant(row.createdAt) < cutoff))
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
+ });
@@ -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
- const child = spawn(process.execPath, [fileURLToPath(new URL('./runner.mjs', import.meta.url)), id, ...(recover ? ['--recover'] : [])], {
21
- detached: true, windowsHide: true, stdio: 'ignore', env: { ...process.env },
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
  });
@@ -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 plan = planHistoryCleanup({ workspaces, sessions, activeSessionIds: gate.idle() ? [] : ['active-agent'] }, options);
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;
@@ -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.3.2';
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
+ }