@mafdet/cli 0.4.1 → 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.
- package/CHANGELOG.md +34 -0
- package/README.md +61 -0
- package/README.zh-CN.md +52 -0
- package/dist/agent/loop.d.ts +14 -0
- package/dist/agent/loop.d.ts.map +1 -1
- package/dist/agent/loop.js +16 -3
- package/dist/agent/session.d.ts +5 -0
- package/dist/agent/session.d.ts.map +1 -1
- package/dist/agent/session.js +1 -1
- package/dist/agent/tools/read-only.d.ts.map +1 -1
- package/dist/agent/tools/read-only.js +6 -0
- package/dist/api/account.d.ts +43 -0
- package/dist/api/account.d.ts.map +1 -1
- package/dist/api/account.js +40 -0
- package/dist/commands/sessions.d.ts +5 -0
- package/dist/commands/sessions.d.ts.map +1 -0
- package/dist/commands/sessions.js +379 -0
- package/dist/config/paths.d.ts +7 -0
- package/dist/config/paths.d.ts.map +1 -1
- package/dist/config/paths.js +24 -0
- package/dist/filesystem/protected-dirs.d.ts +26 -0
- package/dist/filesystem/protected-dirs.d.ts.map +1 -0
- package/dist/filesystem/protected-dirs.js +78 -0
- package/dist/filesystem/workspace.d.ts +12 -1
- package/dist/filesystem/workspace.d.ts.map +1 -1
- package/dist/filesystem/workspace.js +24 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/security/redact.d.ts +26 -3
- package/dist/security/redact.d.ts.map +1 -1
- package/dist/security/redact.js +48 -3
- package/dist/sessions/index-file.d.ts +38 -0
- package/dist/sessions/index-file.d.ts.map +1 -0
- package/dist/sessions/index-file.js +174 -0
- package/dist/sessions/lock.d.ts +39 -0
- package/dist/sessions/lock.d.ts.map +1 -0
- package/dist/sessions/lock.js +134 -0
- package/dist/sessions/open.d.ts +45 -0
- package/dist/sessions/open.d.ts.map +1 -0
- package/dist/sessions/open.js +96 -0
- package/dist/sessions/policy.d.ts +32 -0
- package/dist/sessions/policy.d.ts.map +1 -0
- package/dist/sessions/policy.js +57 -0
- package/dist/sessions/reconcile.d.ts +49 -0
- package/dist/sessions/reconcile.d.ts.map +1 -0
- package/dist/sessions/reconcile.js +115 -0
- package/dist/sessions/recorder.d.ts +42 -0
- package/dist/sessions/recorder.d.ts.map +1 -0
- package/dist/sessions/recorder.js +147 -0
- package/dist/sessions/resume.d.ts +67 -0
- package/dist/sessions/resume.d.ts.map +1 -0
- package/dist/sessions/resume.js +100 -0
- package/dist/sessions/store.d.ts +141 -0
- package/dist/sessions/store.d.ts.map +1 -0
- package/dist/sessions/store.js +272 -0
- package/dist/sessions/usage.d.ts +35 -0
- package/dist/sessions/usage.d.ts.map +1 -0
- package/dist/sessions/usage.js +157 -0
- package/dist/tui/agent-surface.d.ts +3 -0
- package/dist/tui/agent-surface.d.ts.map +1 -1
- package/dist/tui/agent-surface.js +16 -1
- package/dist/tui/app.d.ts.map +1 -1
- package/dist/tui/app.js +18 -1
- package/dist/tui/route.d.ts.map +1 -1
- package/dist/tui/route.js +19 -2
- package/dist/tui/session-centre.d.ts +15 -0
- package/dist/tui/session-centre.d.ts.map +1 -0
- package/dist/tui/session-centre.js +59 -0
- package/dist/tui/start-agent.d.ts +9 -0
- package/dist/tui/start-agent.d.ts.map +1 -1
- package/dist/tui/start-agent.js +122 -0
- 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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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":"
|
|
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"}
|
package/dist/security/redact.js
CHANGED
|
@@ -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
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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"}
|