@mafdet/cli 0.4.0 → 0.4.2

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +64 -3
  3. package/README.zh-CN.md +53 -1
  4. package/dist/agent/loop.d.ts +14 -0
  5. package/dist/agent/loop.d.ts.map +1 -1
  6. package/dist/agent/loop.js +16 -3
  7. package/dist/agent/session.d.ts +5 -0
  8. package/dist/agent/session.d.ts.map +1 -1
  9. package/dist/agent/session.js +1 -1
  10. package/dist/agent/tools/read-only.d.ts.map +1 -1
  11. package/dist/agent/tools/read-only.js +6 -0
  12. package/dist/api/account.d.ts +43 -0
  13. package/dist/api/account.d.ts.map +1 -1
  14. package/dist/api/account.js +40 -0
  15. package/dist/commands/sessions.d.ts +5 -0
  16. package/dist/commands/sessions.d.ts.map +1 -0
  17. package/dist/commands/sessions.js +379 -0
  18. package/dist/config/paths.d.ts +7 -0
  19. package/dist/config/paths.d.ts.map +1 -1
  20. package/dist/config/paths.js +24 -0
  21. package/dist/filesystem/protected-dirs.d.ts +26 -0
  22. package/dist/filesystem/protected-dirs.d.ts.map +1 -0
  23. package/dist/filesystem/protected-dirs.js +78 -0
  24. package/dist/filesystem/workspace.d.ts +12 -1
  25. package/dist/filesystem/workspace.d.ts.map +1 -1
  26. package/dist/filesystem/workspace.js +24 -3
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +8 -0
  29. package/dist/security/redact.d.ts +26 -3
  30. package/dist/security/redact.d.ts.map +1 -1
  31. package/dist/security/redact.js +48 -3
  32. package/dist/sessions/index-file.d.ts +38 -0
  33. package/dist/sessions/index-file.d.ts.map +1 -0
  34. package/dist/sessions/index-file.js +174 -0
  35. package/dist/sessions/lock.d.ts +39 -0
  36. package/dist/sessions/lock.d.ts.map +1 -0
  37. package/dist/sessions/lock.js +134 -0
  38. package/dist/sessions/open.d.ts +45 -0
  39. package/dist/sessions/open.d.ts.map +1 -0
  40. package/dist/sessions/open.js +96 -0
  41. package/dist/sessions/policy.d.ts +32 -0
  42. package/dist/sessions/policy.d.ts.map +1 -0
  43. package/dist/sessions/policy.js +57 -0
  44. package/dist/sessions/reconcile.d.ts +49 -0
  45. package/dist/sessions/reconcile.d.ts.map +1 -0
  46. package/dist/sessions/reconcile.js +115 -0
  47. package/dist/sessions/recorder.d.ts +42 -0
  48. package/dist/sessions/recorder.d.ts.map +1 -0
  49. package/dist/sessions/recorder.js +147 -0
  50. package/dist/sessions/resume.d.ts +67 -0
  51. package/dist/sessions/resume.d.ts.map +1 -0
  52. package/dist/sessions/resume.js +100 -0
  53. package/dist/sessions/store.d.ts +141 -0
  54. package/dist/sessions/store.d.ts.map +1 -0
  55. package/dist/sessions/store.js +272 -0
  56. package/dist/sessions/usage.d.ts +35 -0
  57. package/dist/sessions/usage.d.ts.map +1 -0
  58. package/dist/sessions/usage.js +157 -0
  59. package/dist/tui/agent-surface.d.ts +10 -0
  60. package/dist/tui/agent-surface.d.ts.map +1 -1
  61. package/dist/tui/agent-surface.js +27 -3
  62. package/dist/tui/app.d.ts.map +1 -1
  63. package/dist/tui/app.js +61 -3
  64. package/dist/tui/input.d.ts +13 -1
  65. package/dist/tui/input.d.ts.map +1 -1
  66. package/dist/tui/input.js +9 -2
  67. package/dist/tui/route.d.ts.map +1 -1
  68. package/dist/tui/route.js +19 -2
  69. package/dist/tui/session-centre.d.ts +15 -0
  70. package/dist/tui/session-centre.d.ts.map +1 -0
  71. package/dist/tui/session-centre.js +59 -0
  72. package/dist/tui/start-agent.d.ts +16 -1
  73. package/dist/tui/start-agent.d.ts.map +1 -1
  74. package/dist/tui/start-agent.js +125 -1
  75. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { doctorCommand } from "./commands/doctor.js";
10
10
  import { embedCommand } from "./commands/embed.js";
11
11
  import { speechCommand } from "./commands/speech.js";
12
12
  import { modelsCommand } from "./commands/models.js";
13
+ import { sessionsCommand } from "./commands/sessions.js";
13
14
  import { ExitCode } from "./output/exit-codes.js";
14
15
  import { decideEntry, explainRefusal } from "./tui/route.js";
15
16
  // Read from the installed package rather than baking a literal in at build
@@ -36,6 +37,9 @@ Commands:
36
37
  account balance Wallet balance and bonus
37
38
  account usage Requests, tokens and spend over a window
38
39
  account subscription Current plan and Playground allowance
40
+ sessions list Conversations saved on this computer
41
+ sessions show One saved conversation in detail
42
+ sessions delete Remove a saved conversation from this computer
39
43
  config list Show the resolved settings and where each came from
40
44
  doctor Check key, endpoints, model, balance and workspace
41
45
  help Show this help
@@ -110,6 +114,8 @@ export async function run(argv) {
110
114
  const io = {
111
115
  stdout: (text) => process.stdout.write(text),
112
116
  stderr: (text) => process.stderr.write(text),
117
+ env: process.env,
118
+ cwd: process.cwd(),
113
119
  version: version(),
114
120
  };
115
121
  if (command === "agent") {
@@ -142,6 +148,8 @@ export async function run(argv) {
142
148
  return modelsCommand(argv.slice(1), io);
143
149
  if (command === "account")
144
150
  return accountCommand(argv.slice(1), io);
151
+ if (command === "sessions")
152
+ return sessionsCommand(argv.slice(1), io);
145
153
  process.stderr.write(`mafdet: unknown command '${command}'\nRun 'mafdet help' to see available commands.\n`);
146
154
  return ExitCode.Usage;
147
155
  }
@@ -8,9 +8,32 @@ export declare function redact(value: string): string;
8
8
  */
9
9
  export declare function redactDeep(value: unknown): unknown;
10
10
  /**
11
- * How a key may be shown: enough to tell two keys apart, never enough to use
12
- * one. Mafdet keys carry a meaningful prefix (`sk-mafdet-live-…`), so the
13
- * environment is visible without exposing the secret.
11
+ * How a key may be *shown*: the environment stays visible, the secret does not.
12
+ *
13
+ * ⚠️ This is a display form and nothing else. Every live Mafdet key renders as
14
+ * the same `sk-mafdet-live-<redacted>`, so two keys cannot be told apart by it.
15
+ * Use `keyDigest` wherever the question is "was this the same key?".
14
16
  */
15
17
  export declare function keyFingerprint(apiKey: string): string;
18
+ /**
19
+ * How a key may be *compared*: a value that identifies which key was used
20
+ * without being usable as one, and without being confusable with another key.
21
+ *
22
+ * Plan §20.3 asks for per-key attribution while forbidding the key on disk, so
23
+ * what goes to disk is a SHA-256 digest of it. Twelve hex characters is plenty
24
+ * to separate the handful of keys one person holds, and the digest of a
25
+ * 40-plus character random secret is not going back the other way.
26
+ *
27
+ * The shape is deliberately NOT `sk-…`: `redact()` runs over everything the
28
+ * session store writes, and anything key-shaped would be replaced there — the
29
+ * stored value has to be able to survive its own redaction pass.
30
+ */
31
+ export declare function keyDigest(apiKey: string): string;
32
+ /**
33
+ * True for a ref written before `keyDigest` existed.
34
+ *
35
+ * Those were `keyFingerprint()` output, which is identical for every live key,
36
+ * so they cannot answer "same key?" and must not be treated as if they could.
37
+ */
38
+ export declare function isComparableKeyRef(ref: string | undefined): ref is string;
16
39
  //# sourceMappingURL=redact.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"redact.d.ts","sourceRoot":"","sources":["../../src/security/redact.ts"],"names":[],"mappings":"AAUA,eAAO,MAAM,QAAQ,eAAe,CAAC;AAErC,4DAA4D;AAC5D,wBAAgB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAY5C;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAkBlD;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAOrD"}
1
+ {"version":3,"file":"redact.d.ts","sourceRoot":"","sources":["../../src/security/redact.ts"],"names":[],"mappings":"AAYA,eAAO,MAAM,QAAQ,eAAe,CAAC;AAErC,4DAA4D;AAC5D,wBAAgB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAY5C;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAkBlD;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAOrD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAIhD;AAgBD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,GAAG,IAAI,MAAM,CAEzE"}
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  // Every string that can reach a terminal, a log, an error or telemetry passes
2
3
  // through here first. Credentials leak through the boring paths — an exception
3
4
  // message, a --debug dump, a pasted bug report — so redaction lives at the
@@ -46,9 +47,11 @@ export function redactDeep(value) {
46
47
  return value;
47
48
  }
48
49
  /**
49
- * How a key may be shown: enough to tell two keys apart, never enough to use
50
- * one. Mafdet keys carry a meaningful prefix (`sk-mafdet-live-…`), so the
51
- * environment is visible without exposing the secret.
50
+ * How a key may be *shown*: the environment stays visible, the secret does not.
51
+ *
52
+ * ⚠️ This is a display form and nothing else. Every live Mafdet key renders as
53
+ * the same `sk-mafdet-live-<redacted>`, so two keys cannot be told apart by it.
54
+ * Use `keyDigest` wherever the question is "was this the same key?".
52
55
  */
53
56
  export function keyFingerprint(apiKey) {
54
57
  const trimmed = apiKey.trim();
@@ -59,3 +62,45 @@ export function keyFingerprint(apiKey) {
59
62
  const head = parts.slice(0, 3).join("-");
60
63
  return `${head}-${REDACTED}`;
61
64
  }
65
+ /**
66
+ * How a key may be *compared*: a value that identifies which key was used
67
+ * without being usable as one, and without being confusable with another key.
68
+ *
69
+ * Plan §20.3 asks for per-key attribution while forbidding the key on disk, so
70
+ * what goes to disk is a SHA-256 digest of it. Twelve hex characters is plenty
71
+ * to separate the handful of keys one person holds, and the digest of a
72
+ * 40-plus character random secret is not going back the other way.
73
+ *
74
+ * The shape is deliberately NOT `sk-…`: `redact()` runs over everything the
75
+ * session store writes, and anything key-shaped would be replaced there — the
76
+ * stored value has to be able to survive its own redaction pass.
77
+ */
78
+ export function keyDigest(apiKey) {
79
+ const trimmed = apiKey.trim();
80
+ if (trimmed.length === 0)
81
+ return "";
82
+ return `${environmentLabel(trimmed)}:${createHash("sha256").update(trimmed).digest("hex").slice(0, 12)}`;
83
+ }
84
+ /**
85
+ * `sk-mafdet-live-<secret>` → `mafdet-live`, and anything else → `key`.
86
+ *
87
+ * Only the segments `keyFingerprint` already prints are used, so this exposes
88
+ * nothing new; a key that does not have that shape gets no label at all rather
89
+ * than a guess that might quote part of the secret.
90
+ */
91
+ function environmentLabel(key) {
92
+ const parts = key.split("-");
93
+ if (parts.length < 4 || parts[0] !== "sk")
94
+ return "key";
95
+ const label = parts.slice(1, 3).join("-");
96
+ return /^[A-Za-z0-9_-]+$/.test(label) ? label : "key";
97
+ }
98
+ /**
99
+ * True for a ref written before `keyDigest` existed.
100
+ *
101
+ * Those were `keyFingerprint()` output, which is identical for every live key,
102
+ * so they cannot answer "same key?" and must not be treated as if they could.
103
+ */
104
+ export function isComparableKeyRef(ref) {
105
+ return typeof ref === "string" && ref.length > 0 && !ref.includes(REDACTED);
106
+ }
@@ -0,0 +1,38 @@
1
+ import { type SessionMetadata } from "./store.js";
2
+ export interface IndexEntry {
3
+ readonly session_id: string;
4
+ readonly title: string;
5
+ readonly updated_at: string;
6
+ readonly model_id: string;
7
+ readonly workspace: string;
8
+ readonly model_requests: number;
9
+ readonly estimated_charge_micro_usd: string;
10
+ }
11
+ export interface SessionIndex {
12
+ readonly schema_version: number;
13
+ readonly sessions: readonly IndexEntry[];
14
+ }
15
+ export declare function indexPath(root: string): string;
16
+ /**
17
+ * Rebuild from the metadata files, which are the truth.
18
+ *
19
+ * A session whose metadata cannot be read is skipped rather than fatal: one
20
+ * damaged directory must not hide every other conversation.
21
+ */
22
+ export declare function rebuildIndex(root: string): SessionIndex;
23
+ /** Temp file plus rename, so a crash never leaves half an index. */
24
+ export declare function writeIndex(root: string, index: SessionIndex): void;
25
+ /**
26
+ * Read the index, rebuilding whenever it cannot be trusted.
27
+ *
28
+ * "Cannot be trusted" includes disagreeing with the directory: an index that
29
+ * is merely stale would otherwise hide a session that exists, which is worse
30
+ * than the cost of a rebuild.
31
+ */
32
+ export declare function loadIndex(root: string): SessionIndex;
33
+ /** Record or update one session, leaving the rest of the index alone. */
34
+ export declare function upsertIndexEntry(root: string, metadata: SessionMetadata): SessionIndex;
35
+ export declare function removeIndexEntry(root: string, sessionId: string): SessionIndex;
36
+ /** Stray temp files from a killed write, which nothing else will clean up. */
37
+ export declare function sweepTempFiles(root: string): number;
38
+ //# sourceMappingURL=index-file.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index-file.d.ts","sourceRoot":"","sources":["../../src/sessions/index-file.ts"],"names":[],"mappings":"AAqBA,OAAO,EAIL,KAAK,eAAe,EACrB,MAAM,YAAY,CAAC;AAKpB,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,0BAA0B,EAAE,MAAM,CAAC;CAC7C;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,QAAQ,EAAE,SAAS,UAAU,EAAE,CAAC;CAC1C;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9C;AAmBD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CAoBvD;AAED,oEAAoE;AACpE,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,YAAY,GAAG,IAAI,CAiBlE;AAED;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CAapD;AAwBD,yEAAyE;AACzE,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,eAAe,GACxB,YAAY,CAUd;AAED,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,GAChB,YAAY,CAed;AAED,8EAA8E;AAC9E,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAkBnD"}
@@ -0,0 +1,174 @@
1
+ // CLI-023 · The session index.
2
+ //
3
+ // Plan §20.13: "index 损坏可扫描各 metadata 重建". That line decides the whole
4
+ // design - if the index can always be rebuilt from the metadata files, then it
5
+ // is a cache and never the source of truth, and its corruption stops being an
6
+ // incident. So nothing here ever fails because the index is wrong; it just
7
+ // rebuilds.
8
+ //
9
+ // Listing also reconciles against the directory itself, so a session written
10
+ // by a process that crashed before updating the index still appears.
11
+ import { mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
12
+ import { basename, join } from "node:path";
13
+ import { SCHEMA_VERSION, listSessionDirectories, readSession, } from "./store.js";
14
+ const INDEX_FILE = "index.json";
15
+ const FILE_MODE = 0o600;
16
+ export function indexPath(root) {
17
+ return join(root, INDEX_FILE);
18
+ }
19
+ function entryOf(metadata) {
20
+ return {
21
+ session_id: metadata.session_id,
22
+ title: metadata.title,
23
+ updated_at: metadata.updated_at,
24
+ model_id: metadata.model_id,
25
+ workspace: metadata.workspace,
26
+ model_requests: metadata.model_requests,
27
+ estimated_charge_micro_usd: metadata.estimated_charge_micro_usd,
28
+ };
29
+ }
30
+ /** Newest first, which is the order a session list wants. */
31
+ function byRecency(a, b) {
32
+ return b.updated_at.localeCompare(a.updated_at);
33
+ }
34
+ /**
35
+ * Rebuild from the metadata files, which are the truth.
36
+ *
37
+ * A session whose metadata cannot be read is skipped rather than fatal: one
38
+ * damaged directory must not hide every other conversation.
39
+ */
40
+ export function rebuildIndex(root) {
41
+ const sessions = [];
42
+ for (const directory of listSessionDirectories(root)) {
43
+ try {
44
+ sessions.push(entryOf(readSession(directory).metadata));
45
+ }
46
+ catch {
47
+ // Unreadable or written by a newer CLI; leave it out of the listing.
48
+ }
49
+ }
50
+ sessions.sort(byRecency);
51
+ const index = { schema_version: SCHEMA_VERSION, sessions };
52
+ // Best effort. The index is a cache, so failing to persist it must not fail
53
+ // the read - on a machine with no sessions yet the directory does not even
54
+ // exist, and `mafdet sessions list` still has a correct answer: none.
55
+ try {
56
+ writeIndex(root, index);
57
+ }
58
+ catch {
59
+ // Nothing to do; the caller already has the right data.
60
+ }
61
+ return index;
62
+ }
63
+ /** Temp file plus rename, so a crash never leaves half an index. */
64
+ export function writeIndex(root, index) {
65
+ mkdirSync(root, { recursive: true, mode: 0o700 });
66
+ const target = indexPath(root);
67
+ const temp = join(root, `.index.${process.pid}.tmp`);
68
+ try {
69
+ writeFileSync(temp, `${JSON.stringify(index, null, 2)}\n`, {
70
+ mode: FILE_MODE,
71
+ });
72
+ renameSync(temp, target);
73
+ }
74
+ catch (error) {
75
+ try {
76
+ unlinkSync(temp);
77
+ }
78
+ catch {
79
+ // May never have been created.
80
+ }
81
+ throw error;
82
+ }
83
+ }
84
+ /**
85
+ * Read the index, rebuilding whenever it cannot be trusted.
86
+ *
87
+ * "Cannot be trusted" includes disagreeing with the directory: an index that
88
+ * is merely stale would otherwise hide a session that exists, which is worse
89
+ * than the cost of a rebuild.
90
+ */
91
+ export function loadIndex(root) {
92
+ let parsed;
93
+ try {
94
+ parsed = JSON.parse(readFileSync(indexPath(root), "utf8"));
95
+ }
96
+ catch {
97
+ return rebuildIndex(root);
98
+ }
99
+ if (!isIndex(parsed) || parsed.schema_version > SCHEMA_VERSION) {
100
+ return rebuildIndex(root);
101
+ }
102
+ if (!matchesDisk(root, parsed))
103
+ return rebuildIndex(root);
104
+ return parsed;
105
+ }
106
+ function isIndex(value) {
107
+ if (value === null || typeof value !== "object")
108
+ return false;
109
+ const candidate = value;
110
+ if (typeof candidate.schema_version !== "number")
111
+ return false;
112
+ if (!Array.isArray(candidate.sessions))
113
+ return false;
114
+ return candidate.sessions.every((entry) => entry !== null &&
115
+ typeof entry === "object" &&
116
+ typeof entry.session_id === "string");
117
+ }
118
+ /** Same set of session ids as the directory, or the index is stale. */
119
+ function matchesDisk(root, index) {
120
+ const onDisk = new Set(listSessionDirectories(root).map((directory) => basename(directory)));
121
+ if (onDisk.size !== index.sessions.length)
122
+ return false;
123
+ return index.sessions.every((entry) => onDisk.has(entry.session_id));
124
+ }
125
+ /** Record or update one session, leaving the rest of the index alone. */
126
+ export function upsertIndexEntry(root, metadata) {
127
+ const current = loadIndex(root);
128
+ const entry = entryOf(metadata);
129
+ const sessions = current.sessions
130
+ .filter((existing) => existing.session_id !== entry.session_id)
131
+ .concat(entry)
132
+ .sort(byRecency);
133
+ const next = { schema_version: SCHEMA_VERSION, sessions };
134
+ writeIndex(root, next);
135
+ return next;
136
+ }
137
+ export function removeIndexEntry(root, sessionId) {
138
+ let current;
139
+ try {
140
+ current = loadIndex(root);
141
+ }
142
+ catch {
143
+ current = { schema_version: SCHEMA_VERSION, sessions: [] };
144
+ }
145
+ const next = {
146
+ schema_version: SCHEMA_VERSION,
147
+ sessions: current.sessions.filter((entry) => entry.session_id !== sessionId),
148
+ };
149
+ writeIndex(root, next);
150
+ return next;
151
+ }
152
+ /** Stray temp files from a killed write, which nothing else will clean up. */
153
+ export function sweepTempFiles(root) {
154
+ let removed = 0;
155
+ let entries;
156
+ try {
157
+ entries = readdirSync(root);
158
+ }
159
+ catch {
160
+ return 0;
161
+ }
162
+ for (const name of entries) {
163
+ if (!name.startsWith(".index.") || !name.endsWith(".tmp"))
164
+ continue;
165
+ try {
166
+ unlinkSync(join(root, name));
167
+ removed += 1;
168
+ }
169
+ catch {
170
+ // Someone else's in-flight write; leave it.
171
+ }
172
+ }
173
+ return removed;
174
+ }
@@ -0,0 +1,39 @@
1
+ export interface LockRecord {
2
+ readonly pid: number;
3
+ /** Distinguishes a recycled pid on this machine from the original holder. */
4
+ readonly started_at: string;
5
+ readonly host: string;
6
+ }
7
+ export declare class SessionLockedError extends Error {
8
+ readonly holder: LockRecord | undefined;
9
+ constructor(message: string, holder: LockRecord | undefined);
10
+ }
11
+ export interface LockHost {
12
+ readonly pid: number;
13
+ /** True when a process with this id exists and is ours to signal. */
14
+ isAlive(pid: number): boolean;
15
+ readonly host: string;
16
+ now(): Date;
17
+ }
18
+ export declare const systemHost: LockHost;
19
+ export interface SessionLock {
20
+ readonly path: string;
21
+ release(): void;
22
+ }
23
+ export declare function lockPath(directory: string): string;
24
+ /**
25
+ * Is this lock safe to take over?
26
+ *
27
+ * Only when we can be sure the holder is gone. A lock from another machine is
28
+ * never reclaimed: the pid means nothing here, and a shared filesystem is
29
+ * exactly where a wrong answer corrupts someone else's transcript.
30
+ */
31
+ export declare function isStale(record: LockRecord | undefined, host: LockHost): boolean;
32
+ /**
33
+ * Take the lock, reclaiming a dead one.
34
+ *
35
+ * Throws `SessionLockedError` when another live process holds it - the caller
36
+ * decides whether that means "open read-only" or "pick another session".
37
+ */
38
+ export declare function acquireSessionLock(directory: string, host?: LockHost): SessionLock;
39
+ //# sourceMappingURL=lock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lock.d.ts","sourceRoot":"","sources":["../../src/sessions/lock.ts"],"names":[],"mappings":"AA0BA,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,qBAAa,kBAAmB,SAAQ,KAAK;IAGzC,QAAQ,CAAC,MAAM,EAAE,UAAU,GAAG,SAAS;gBADvC,OAAO,EAAE,MAAM,EACN,MAAM,EAAE,UAAU,GAAG,SAAS;CAI1C;AAED,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,qEAAqE;IACrE,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,GAAG,IAAI,IAAI,CAAC;CACb;AAED,eAAO,MAAM,UAAU,EAAE,QAcxB,CAAC;AAEF,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,wBAAgB,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAElD;AAqBD;;;;;;GAMG;AACH,wBAAgB,OAAO,CACrB,MAAM,EAAE,UAAU,GAAG,SAAS,EAC9B,IAAI,EAAE,QAAQ,GACb,OAAO,CAKT;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,MAAM,EACjB,IAAI,GAAE,QAAqB,GAC1B,WAAW,CA8Cb"}
@@ -0,0 +1,134 @@
1
+ // CLI-023 · One writer per session.
2
+ //
3
+ // Plan §20.13: a second process opening the same session must not interleave
4
+ // its events into the same file. The lock is advisory and deliberately simple
5
+ // - `wx` fails if the file exists, which is the atomic primitive every
6
+ // filesystem gives us without a dependency.
7
+ //
8
+ // The hard part is not taking the lock, it is deciding that an existing one is
9
+ // dead. A crashed process leaves its lock behind forever, and a CLI that then
10
+ // refuses to open your own conversation is worse than one that occasionally
11
+ // takes a lock it should not have. So a lock whose process is gone is
12
+ // reclaimed - and the check is narrow enough not to steal a live one.
13
+ import { openSync, closeSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
14
+ import { hostname } from "node:os";
15
+ import { join } from "node:path";
16
+ const LOCK_FILE = "session.lock";
17
+ const FILE_MODE = 0o600;
18
+ export class SessionLockedError extends Error {
19
+ holder;
20
+ constructor(message, holder) {
21
+ super(message);
22
+ this.holder = holder;
23
+ }
24
+ }
25
+ export const systemHost = {
26
+ pid: process.pid,
27
+ isAlive(pid) {
28
+ try {
29
+ // Signal 0 checks for existence without delivering anything.
30
+ process.kill(pid, 0);
31
+ return true;
32
+ }
33
+ catch (error) {
34
+ // EPERM means it exists but belongs to someone else: still alive.
35
+ return error.code === "EPERM";
36
+ }
37
+ },
38
+ host: hostname(),
39
+ now: () => new Date(),
40
+ };
41
+ export function lockPath(directory) {
42
+ return join(directory, LOCK_FILE);
43
+ }
44
+ function readRecord(path) {
45
+ try {
46
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
47
+ if (typeof parsed.pid !== "number")
48
+ return undefined;
49
+ return {
50
+ pid: parsed.pid,
51
+ started_at: typeof parsed.started_at === "string" ? parsed.started_at : "",
52
+ host: typeof parsed.host === "string" ? parsed.host : "",
53
+ };
54
+ }
55
+ catch {
56
+ // Unreadable or truncated: treat as stale rather than as a live holder,
57
+ // or a half-written lock would wedge the session permanently.
58
+ return undefined;
59
+ }
60
+ }
61
+ /**
62
+ * Is this lock safe to take over?
63
+ *
64
+ * Only when we can be sure the holder is gone. A lock from another machine is
65
+ * never reclaimed: the pid means nothing here, and a shared filesystem is
66
+ * exactly where a wrong answer corrupts someone else's transcript.
67
+ */
68
+ export function isStale(record, host) {
69
+ if (record === undefined)
70
+ return true; // Unreadable; nothing to respect.
71
+ if (record.host !== "" && record.host !== host.host)
72
+ return false;
73
+ if (record.pid === host.pid)
74
+ return true; // Ours, from an earlier open.
75
+ return !host.isAlive(record.pid);
76
+ }
77
+ /**
78
+ * Take the lock, reclaiming a dead one.
79
+ *
80
+ * Throws `SessionLockedError` when another live process holds it - the caller
81
+ * decides whether that means "open read-only" or "pick another session".
82
+ */
83
+ export function acquireSessionLock(directory, host = systemHost) {
84
+ const path = lockPath(directory);
85
+ const record = {
86
+ pid: host.pid,
87
+ started_at: host.now().toISOString(),
88
+ host: host.host,
89
+ };
90
+ for (let attempt = 0; attempt < 2; attempt += 1) {
91
+ try {
92
+ // `wx` is the atomic part: it fails if the file already exists.
93
+ const fd = openSync(path, "wx", FILE_MODE);
94
+ try {
95
+ writeFileSync(fd, `${JSON.stringify(record)}\n`);
96
+ }
97
+ finally {
98
+ closeSync(fd);
99
+ }
100
+ return { path, release: () => releaseIfOurs(path, record) };
101
+ }
102
+ catch (error) {
103
+ if (error.code !== "EEXIST")
104
+ throw error;
105
+ const holder = readRecord(path);
106
+ if (!isStale(holder, host)) {
107
+ throw new SessionLockedError(holder === undefined
108
+ ? "this session is open in another process"
109
+ : `this session is open in another process (pid ${holder.pid}${holder.host ? ` on ${holder.host}` : ""})`, holder);
110
+ }
111
+ // Stale: drop it and try once more. A second EEXIST means someone else
112
+ // won the race, and the next pass will report them as the holder.
113
+ try {
114
+ unlinkSync(path);
115
+ }
116
+ catch {
117
+ // Someone else cleaned it up first, which is fine.
118
+ }
119
+ }
120
+ }
121
+ throw new SessionLockedError("could not take the session lock", readRecord(path));
122
+ }
123
+ /** Only remove a lock we still own, so a reclaimed one is not deleted twice. */
124
+ function releaseIfOurs(path, ours) {
125
+ const current = readRecord(path);
126
+ if (current !== undefined && current.pid !== ours.pid)
127
+ return;
128
+ try {
129
+ unlinkSync(path);
130
+ }
131
+ catch {
132
+ // Already gone.
133
+ }
134
+ }
@@ -0,0 +1,45 @@
1
+ import type { ChatMessage } from "../agent/loop.js";
2
+ import { type SessionLock } from "./lock.js";
3
+ import { permissionsForResume, type ResumeBlocker } from "./resume.js";
4
+ import { SessionWriter, type SessionMetadata } from "./store.js";
5
+ export interface OpenForResumeInput {
6
+ readonly root: string;
7
+ readonly sessionId: string;
8
+ readonly systemPrompt: string;
9
+ /** Model ids the current key can call. */
10
+ readonly callable: readonly string[];
11
+ readonly agentCapable: readonly string[];
12
+ readonly allowedCommandsThisRun: readonly string[];
13
+ }
14
+ export type OpenResult = {
15
+ readonly kind: "missing";
16
+ } | {
17
+ readonly kind: "locked";
18
+ readonly reason: string;
19
+ } | {
20
+ readonly kind: "unreadable";
21
+ readonly reason: string;
22
+ } | {
23
+ readonly kind: "opened";
24
+ readonly metadata: SessionMetadata;
25
+ readonly messages: ChatMessage[];
26
+ readonly writer: SessionWriter;
27
+ readonly lock: SessionLock;
28
+ /** Empty when nothing stands in the way of continuing. */
29
+ readonly blockers: readonly ResumeBlocker[];
30
+ readonly omitted: number;
31
+ readonly toolRunsSummarised: number;
32
+ readonly permissions: ReturnType<typeof permissionsForResume>;
33
+ };
34
+ export declare function openForResume(input: OpenForResumeInput): OpenResult;
35
+ /**
36
+ * What to tell the user when a session is reopened.
37
+ *
38
+ * The cost sentence is the one that earns its place: opening was free, but the
39
+ * next message re-sends this history as input tokens, and someone who does not
40
+ * know that will be surprised by the bill rather than by the feature.
41
+ */
42
+ export declare function describeResume(result: Extract<OpenResult, {
43
+ kind: "opened";
44
+ }>): string;
45
+ //# sourceMappingURL=open.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"open.d.ts","sourceRoot":"","sources":["../../src/sessions/open.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAGL,KAAK,WAAW,EACjB,MAAM,WAAW,CAAC;AACnB,OAAO,EAEL,oBAAoB,EAGpB,KAAK,aAAa,EACnB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,aAAa,EAAe,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AAE9E,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,0CAA0C;IAC1C,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,QAAQ,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;IACzC,QAAQ,CAAC,sBAAsB,EAAE,SAAS,MAAM,EAAE,CAAC;CACpD;AAED,MAAM,MAAM,UAAU,GAClB;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;CAAE,GAC5B;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACpD;IAAE,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACxD;IACE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC;IACnC,QAAQ,CAAC,QAAQ,EAAE,WAAW,EAAE,CAAC;IACjC,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAC3B,0DAA0D;IAC1D,QAAQ,CAAC,QAAQ,EAAE,SAAS,aAAa,EAAE,CAAC;IAC5C,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,WAAW,EAAE,UAAU,CAAC,OAAO,oBAAoB,CAAC,CAAC;CAC/D,CAAC;AAEN,wBAAgB,aAAa,CAAC,KAAK,EAAE,kBAAkB,GAAG,UAAU,CA4DnE;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,OAAO,CAAC,UAAU,EAAE;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAC,GAC9C,MAAM,CAwBR"}