@xingjisoft/neutrino 0.1.4 → 0.1.5

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.
@@ -0,0 +1,377 @@
1
+ /**
2
+ * Tool-call risk assessment.
3
+ *
4
+ * The agent may run shell commands and touch files. Everything it does is
5
+ * classified here before it happens, so the caller can decide whether the user
6
+ * has to approve it:
7
+ *
8
+ * - `high` — commands that can escalate privileges, install software,
9
+ * rewrite history, talk to the network, or touch anything
10
+ * outside the working directory.
11
+ * - `extreme` — delete-class commands: they destroy data with no undo.
12
+ *
13
+ * Everything else returns `null` and runs unsupervised.
14
+ */
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ /** Wrappers that merely delegate to the command that follows them. */
18
+ const WRAPPERS = new Set([
19
+ "sudo",
20
+ "doas",
21
+ "runas",
22
+ "pkexec",
23
+ "command",
24
+ "builtin",
25
+ "exec",
26
+ "nohup",
27
+ "time",
28
+ "nice",
29
+ "ionice",
30
+ "stdbuf",
31
+ "xargs",
32
+ "env",
33
+ "watch",
34
+ "timeout",
35
+ "strace",
36
+ "ltrace",
37
+ "busybox",
38
+ ]);
39
+ /** Head commands that destroy data. */
40
+ const DELETION_HEADS = new Map([
41
+ ["rm", "deletes files or directories (rm)"],
42
+ ["rmdir", "removes directories (rmdir)"],
43
+ ["del", "deletes files (del)"],
44
+ ["erase", "deletes files (erase)"],
45
+ ["rd", "removes directories (rd)"],
46
+ ["shred", "overwrites and deletes files (shred)"],
47
+ ["wipe", "destroys data (wipe)"],
48
+ ["unlink", "removes a file (unlink)"],
49
+ ["mkfs", "formats a filesystem (mkfs)"],
50
+ ["format", "formats a disk (format)"],
51
+ ["fdisk", "repartitions a disk (fdisk)"],
52
+ ["diskpart", "repartitions a disk (diskpart)"],
53
+ ["srm", "securely deletes files (srm)"],
54
+ ["remove-item", "deletes files or directories (Remove-Item)"],
55
+ ["clear-content", "empties a file (Clear-Content)"],
56
+ ]);
57
+ /** Head commands that need a human (or a reviewer) to look at them. */
58
+ const HIGH_RISK_HEADS = new Map([
59
+ ["sudo", "escalates privileges (sudo)"],
60
+ ["su", "switches user (su)"],
61
+ ["doas", "escalates privileges (doas)"],
62
+ ["runas", "escalates privileges (runas)"],
63
+ ["pkexec", "escalates privileges (pkexec)"],
64
+ ["visudo", "edits the sudoers policy (visudo)"],
65
+ ["chmod", "changes file permissions (chmod)"],
66
+ ["chown", "changes file ownership (chown)"],
67
+ ["chgrp", "changes file group (chgrp)"],
68
+ ["setcap", "changes process capabilities (setcap)"],
69
+ ["systemctl", "controls system services (systemctl)"],
70
+ ["service", "controls system services (service)"],
71
+ ["launchctl", "controls system services (launchctl)"],
72
+ ["sc", "controls system services (sc)"],
73
+ ["reg", "edits the Windows registry (reg)"],
74
+ ["shutdown", "shuts the machine down (shutdown)"],
75
+ ["reboot", "reboots the machine (reboot)"],
76
+ ["poweroff", "powers the machine off (poweroff)"],
77
+ ["halt", "halts the machine (halt)"],
78
+ ["kill", "kills a process (kill)"],
79
+ ["pkill", "kills processes by name (pkill)"],
80
+ ["killall", "kills processes by name (killall)"],
81
+ ["taskkill", "kills processes (taskkill)"],
82
+ ["apt", "installs or removes system packages (apt)"],
83
+ ["apt-get", "installs or removes system packages (apt-get)"],
84
+ ["yum", "installs or removes system packages (yum)"],
85
+ ["dnf", "installs or removes system packages (dnf)"],
86
+ ["pacman", "installs or removes system packages (pacman)"],
87
+ ["apk", "installs or removes system packages (apk)"],
88
+ ["brew", "installs or removes system packages (brew)"],
89
+ ["snap", "installs or removes system packages (snap)"],
90
+ ["pip", "installs Python packages (pip)"],
91
+ ["pip3", "installs Python packages (pip3)"],
92
+ ["conda", "changes the Python environment (conda)"],
93
+ ["gem", "installs Ruby packages (gem)"],
94
+ ["npm", "changes Node packages (npm)"],
95
+ ["pnpm", "changes Node packages (pnpm)"],
96
+ ["yarn", "changes Node packages (yarn)"],
97
+ ["cargo", "changes Rust packages (cargo)"],
98
+ ["dotnet", "changes .NET packages (dotnet)"],
99
+ ["docker", "controls containers (docker)"],
100
+ ["podman", "controls containers (podman)"],
101
+ ["kubectl", "controls a cluster (kubectl)"],
102
+ ["helm", "controls a cluster (helm)"],
103
+ ["terraform", "changes infrastructure (terraform)"],
104
+ ["ansible", "changes remote machines (ansible)"],
105
+ ["crontab", "edits scheduled jobs (crontab)"],
106
+ ["schtasks", "edits scheduled jobs (schtasks)"],
107
+ ["ssh", "opens a remote shell (ssh)"],
108
+ ["scp", "copies files over the network (scp)"],
109
+ ["sftp", "transfers files over the network (sftp)"],
110
+ ["rsync", "mirrors files (rsync)"],
111
+ ["nc", "opens a raw network connection (nc)"],
112
+ ["ncat", "opens a raw network connection (ncat)"],
113
+ ["netcat", "opens a raw network connection (netcat)"],
114
+ ["telnet", "opens a raw network connection (telnet)"],
115
+ ["curl", "fetches data from the network (curl)"],
116
+ ["wget", "fetches data from the network (wget)"],
117
+ ["eval", "evaluates generated code (eval)"],
118
+ ["source", "runs another script in this shell (source)"],
119
+ ]);
120
+ /**
121
+ * Subcommands of otherwise risky tools that change nothing: `npm run build`
122
+ * is routine, `npm install` is not.
123
+ */
124
+ const SAFE_SUBCOMMANDS = new Map([
125
+ ["npm", /^(run|run-script|test|t|ls|list|view|info|outdated|audit|config|why|root|prefix|help|--version|-v)$/],
126
+ ["pnpm", /^(run|test|ls|list|why|audit|help|--version|-v)$/],
127
+ ["yarn", /^(run|test|ls|list|why|audit|help|--version|-v)$/],
128
+ ["bun", /^(run|test|--version|-v)$/],
129
+ ["pip", /^(list|show|freeze|help|--version|-v)$/],
130
+ ["pip3", /^(list|show|freeze|help|--version|-v)$/],
131
+ ["gem", /^(list|search|info|help|--version|-v)$/],
132
+ ["cargo", /^(build|check|test|bench|run|doc|fmt|clippy|tree|metadata|help|--version|-v)$/],
133
+ ["dotnet", /^(build|test|run|help|--version|-v)$/],
134
+ ["conda", /^(list|info|help|--version|-v)$/],
135
+ ["docker", /^(ps|images|logs|inspect|version|info|stats|history)$/],
136
+ ["podman", /^(ps|images|logs|inspect|version|info)$/],
137
+ ["kubectl", /^(get|describe|logs|version|top|explain|api-resources|config)$/],
138
+ ["helm", /^(list|status|version|search|show|template|history)$/],
139
+ ["terraform", /^(plan|validate|fmt|version|output|show|state\s+list)$/],
140
+ ["brew", /^(list|info|search|doctor|--version|-v)$/],
141
+ ["snap", /^(list|info|version|--version|-v)$/],
142
+ ]);
143
+ /** Interpreters that only become dangerous with an inline program. */
144
+ const INTERPRETERS = new Set(["node", "python", "python3", "perl", "ruby", "php", "bash", "sh", "zsh", "pwsh", "powershell"]);
145
+ /** File names that carry credentials: touching them is never routine. */
146
+ const SENSITIVE_PATHS = [/(^|[\\/])\.env(\.|$)/i, /(^|[\\/])id_(rsa|dsa|ecdsa|ed25519)(\.|$)/i, /(^|[\\/])\.ssh([\\/]|$)/i, /(^|[\\/])\.git[\\/](config|hooks)([\\/]|$)/i, /(^|[\\/])\.aws[\\/]credentials$/i];
147
+ /** Remove quoted strings so `rg "rm -rf" .` is not mistaken for a deletion. */
148
+ function stripQuoted(command) {
149
+ return command
150
+ .replace(/'(?:[^'\\]|\\.)*'/g, '""')
151
+ .replace(/"(?:[^"\\]|\\.)*"/g, '""');
152
+ }
153
+ /** Last path segment of a token, lowercased, without a `.exe` suffix. */
154
+ function base(token) {
155
+ const name = token.split(/[\\/]/).pop() ?? token;
156
+ return name.replace(/\.exe$/i, "").toLowerCase();
157
+ }
158
+ /** Split a command line into the tokens that actually run something. */
159
+ function segments(command) {
160
+ const out = [];
161
+ for (const part of stripQuoted(command).replace(/\r?\n/g, ";").split(/&&|\|\||[;|&]/)) {
162
+ const tokens = part.trim().split(/\s+/).filter(Boolean);
163
+ let i = 0;
164
+ // Skip `sudo`, `env`, `timeout 5`, `VAR=x` and friends: the real command
165
+ // hides behind them and is what we care about.
166
+ while (i < tokens.length) {
167
+ const head = base(tokens[i]);
168
+ if (WRAPPERS.has(head)) {
169
+ i++;
170
+ // `timeout 5 rm -rf x` — also skip the numeric argument.
171
+ if (head === "timeout" && i < tokens.length && /^\d/.test(tokens[i]))
172
+ i++;
173
+ continue;
174
+ }
175
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) {
176
+ i++;
177
+ continue;
178
+ }
179
+ break;
180
+ }
181
+ if (i < tokens.length)
182
+ out.push(tokens.slice(i));
183
+ }
184
+ return out;
185
+ }
186
+ /** `dd`/`truncate` are only destructive in specific shapes. */
187
+ function conditionalDeletion(head, argv) {
188
+ const joined = argv.join(" ");
189
+ if (head === "dd")
190
+ return /\bof=/.test(joined) ? "overwrites a device or file (dd)" : null;
191
+ if (head === "truncate")
192
+ return /(-s\s*0\b|--size[=\s]0\b)/.test(joined) ? "truncates a file to zero bytes (truncate)" : null;
193
+ if (head === "docker" || head === "podman") {
194
+ // Only the subcommand counts: `docker run --rm` deletes nothing.
195
+ const sub = (argv[1] ?? "").toLowerCase();
196
+ if (sub === "rm" || sub === "rmi")
197
+ return "removes containers or images";
198
+ if (sub === "system" && /prune/.test(joined))
199
+ return "prunes containers, images and volumes";
200
+ if (sub === "volume" && /\b(rm|prune)\b/.test(argv.slice(2).join(" ")))
201
+ return "removes volumes";
202
+ return null;
203
+ }
204
+ if (head === "terraform")
205
+ return /\bdestroy\b/.test(joined) ? "destroys managed infrastructure (terraform destroy)" : null;
206
+ return null;
207
+ }
208
+ /** Expand `~` and resolve a token against the working directory. */
209
+ function resolvePath(token, cwd) {
210
+ let p = token;
211
+ if (p === "~" || p.startsWith("~/") || p.startsWith("~\\"))
212
+ p = path.join(os.homedir(), p.slice(1));
213
+ return path.resolve(cwd, p);
214
+ }
215
+ function outsideCwd(token, cwd) {
216
+ try {
217
+ const rel = path.relative(cwd, resolvePath(token, cwd));
218
+ return rel.startsWith("..") || path.isAbsolute(rel);
219
+ }
220
+ catch {
221
+ return false;
222
+ }
223
+ }
224
+ function looksLikePath(token) {
225
+ const p = token.replace(/^[-@=+]+/, "").replace(/^["']|["']$/g, "");
226
+ if (!p || p.length > 4096)
227
+ return false;
228
+ if (p === ".." || p.startsWith("../") || p.startsWith("..\\"))
229
+ return true;
230
+ if (p.startsWith("~/") || p.startsWith("~\\"))
231
+ return true;
232
+ if (p.startsWith("/"))
233
+ return true;
234
+ if (/^[A-Za-z]:[\\/]/.test(p))
235
+ return true;
236
+ if (p.includes("/../") || p.includes("\\..\\"))
237
+ return true;
238
+ return false;
239
+ }
240
+ function sensitiveMatch(token) {
241
+ return SENSITIVE_PATHS.some((re) => re.test(token));
242
+ }
243
+ /** Score a shell command. */
244
+ export function assessCommand(command, cwd) {
245
+ const reasons = [];
246
+ let level = "high";
247
+ const plain = stripQuoted(command);
248
+ const lower = plain.toLowerCase();
249
+ for (const argv of segments(command)) {
250
+ const head = base(argv[0]);
251
+ const deletion = DELETION_HEADS.get(head);
252
+ if (deletion) {
253
+ reasons.push(deletion);
254
+ level = "extreme";
255
+ }
256
+ const conditional = conditionalDeletion(head, argv);
257
+ if (conditional) {
258
+ reasons.push(conditional);
259
+ level = "extreme";
260
+ }
261
+ const risky = HIGH_RISK_HEADS.get(head);
262
+ if (risky && !reasons.includes(risky)) {
263
+ const safe = SAFE_SUBCOMMANDS.get(head);
264
+ const sub = (argv[1] ?? "").toLowerCase();
265
+ if (!safe || !safe.test(sub))
266
+ reasons.push(risky);
267
+ }
268
+ if (INTERPRETERS.has(head) && argv.slice(1).some((a) => /^-[a-z]*[ce]$|^-c$|^-e$|^-Command$/i.test(a))) {
269
+ reasons.push(`runs an inline program (${head} -c/-e)`);
270
+ }
271
+ if (head === "powershell" || head === "pwsh") {
272
+ if (/-(c|command|encodedcommand)\b/i.test(argv.join(" ")))
273
+ reasons.push("runs an inline PowerShell program");
274
+ }
275
+ }
276
+ if (/\b(sudo|doas|runas|pkexec)\b/i.test(lower)) {
277
+ const reason = "escalates privileges";
278
+ if (!reasons.some((r) => r.includes("privileges")))
279
+ reasons.push(reason);
280
+ }
281
+ // Deletion patterns that are not a simple command head.
282
+ if (/\bgit\s+clean\b[^;|&]*\s-[a-z]*f/i.test(lower)) {
283
+ reasons.push("deletes untracked files (git clean -f)");
284
+ level = "extreme";
285
+ }
286
+ if (/\bgit\s+reset\s+--hard\b/i.test(lower)) {
287
+ reasons.push("discards uncommitted work (git reset --hard)");
288
+ level = "extreme";
289
+ }
290
+ if (/\bgit\s+rm\b/i.test(lower)) {
291
+ reasons.push("removes tracked files (git rm)");
292
+ level = "extreme";
293
+ }
294
+ if (/\bgit\s+branch\s+-D\b/i.test(lower)) {
295
+ reasons.push("force-deletes a branch (git branch -D)");
296
+ level = "extreme";
297
+ }
298
+ if (/\bfind\b[^;|&]*-delete\b/i.test(lower)) {
299
+ reasons.push("deletes every match of a find query (find -delete)");
300
+ level = "extreme";
301
+ }
302
+ if (/\b(drop\s+(table|database|schema)|truncate\s+table)\b/i.test(lower)) {
303
+ reasons.push("drops or empties a database object");
304
+ level = "extreme";
305
+ }
306
+ if (/\b(shred|dd)\b[^;|&]*(of=|if=)/i.test(lower) && !reasons.some((r) => r.includes("dd"))) {
307
+ reasons.push("writes raw bytes over a device or file");
308
+ level = "extreme";
309
+ }
310
+ // Destructive-but-recoverable and history rewriting.
311
+ if (/\bgit\s+push\b/i.test(lower))
312
+ reasons.push("publishes commits to a remote (git push)");
313
+ if (/\bgit\s+push\b[^;|&]*--force\b/i.test(lower))
314
+ reasons.push("force-pushes and can drop remote commits");
315
+ if (/\bgit\s+(reset|checkout|restore)\b/i.test(lower) && !reasons.some((r) => r.includes("reset --hard"))) {
316
+ reasons.push("can discard uncommitted changes (git reset/checkout/restore)");
317
+ }
318
+ if (/\bgit\s+(rebase|filter-branch)\b/i.test(lower))
319
+ reasons.push("rewrites history (git rebase/filter-branch)");
320
+ if (/(^|[^<])>{1,2}\s*\S/.test(plain))
321
+ reasons.push("redirects output into a file (>)");
322
+ if (/\bnpm\s+(publish|unpublish)\b/i.test(lower))
323
+ reasons.push("publishes a package to the registry");
324
+ // Anything pointing outside the working directory.
325
+ for (const token of plain.split(/\s+/)) {
326
+ if (!looksLikePath(token))
327
+ continue;
328
+ if (outsideCwd(token.replace(/^[-@=+]+/, ""), cwd)) {
329
+ const reason = `touches a path outside the working directory: ${token}`;
330
+ if (!reasons.includes(reason))
331
+ reasons.push(reason);
332
+ }
333
+ }
334
+ if (sensitiveMatch(command))
335
+ reasons.push("touches credentials or repository internals");
336
+ return { level, reasons };
337
+ }
338
+ function summarizeArgs(name, args) {
339
+ if (name === "run_command") {
340
+ const command = typeof args.command === "string" ? args.command : "";
341
+ return command.length > 400 ? `${command.slice(0, 397)}…` : command;
342
+ }
343
+ const p = typeof args.path === "string" ? args.path : "";
344
+ return p ? `${name} ${p}` : name;
345
+ }
346
+ /**
347
+ * Classify a single tool call.
348
+ *
349
+ * Returns `null` when the call is routine and may run without approval.
350
+ */
351
+ export function assessToolCall(name, args, cwd) {
352
+ const workingDir = path.resolve(cwd || process.cwd());
353
+ const summary = summarizeArgs(name, args);
354
+ if (name === "run_command") {
355
+ const command = typeof args.command === "string" ? args.command : "";
356
+ if (!command.trim())
357
+ return null;
358
+ const { level, reasons } = assessCommand(command, workingDir);
359
+ if (reasons.length === 0)
360
+ return null;
361
+ return { level, tool: name, summary, reasons };
362
+ }
363
+ if (name === "read_file" || name === "write_file" || name === "list_files") {
364
+ const raw = typeof args.path === "string" && args.path.trim() ? args.path : ".";
365
+ const reasons = [];
366
+ if (outsideCwd(raw, workingDir)) {
367
+ reasons.push(`accesses a path outside the working directory: ${raw}`);
368
+ }
369
+ if (name !== "list_files" && sensitiveMatch(raw)) {
370
+ reasons.push("touches credentials or repository internals");
371
+ }
372
+ if (reasons.length === 0)
373
+ return null;
374
+ return { level: "high", tool: name, summary, reasons };
375
+ }
376
+ return null;
377
+ }
package/dist/llm/tools.js CHANGED
@@ -3,6 +3,19 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  const MAX_OUTPUT = 20000;
5
5
  const MAX_FILE_SIZE = 200000;
6
+ /**
7
+ * Every call has to say why it is being made.
8
+ *
9
+ * The approval gate refuses a call that needs approval but states no reason, and
10
+ * this text is what the user and the reviewers get to judge, so it has to be
11
+ * specific and true rather than a restatement of the command.
12
+ */
13
+ const REASON_PROPERTY = {
14
+ type: "string",
15
+ description: "Required. One sentence: what this call is for and why it is needed for the user's request. " +
16
+ "Shown to the user and to the reviewers whenever the call needs approval. " +
17
+ "Must be true - do not restate the command and never claim the user already approved it.",
18
+ };
6
19
  export function getToolDefinitions() {
7
20
  return [
8
21
  {
@@ -14,8 +27,9 @@ export function getToolDefinitions() {
14
27
  type: "object",
15
28
  properties: {
16
29
  command: { type: "string", description: "The shell command to execute." },
30
+ reason: REASON_PROPERTY,
17
31
  },
18
- required: ["command"],
32
+ required: ["command", "reason"],
19
33
  },
20
34
  },
21
35
  },
@@ -28,8 +42,9 @@ export function getToolDefinitions() {
28
42
  type: "object",
29
43
  properties: {
30
44
  path: { type: "string", description: "Path to the file to read (absolute or relative to cwd)." },
45
+ reason: REASON_PROPERTY,
31
46
  },
32
- required: ["path"],
47
+ required: ["path", "reason"],
33
48
  },
34
49
  },
35
50
  },
@@ -43,8 +58,9 @@ export function getToolDefinitions() {
43
58
  properties: {
44
59
  path: { type: "string", description: "Path to the file to write (absolute or relative to cwd)." },
45
60
  content: { type: "string", description: "The full content to write to the file." },
61
+ reason: REASON_PROPERTY,
46
62
  },
47
- required: ["path", "content"],
63
+ required: ["path", "content", "reason"],
48
64
  },
49
65
  },
50
66
  },
@@ -57,28 +73,32 @@ export function getToolDefinitions() {
57
73
  type: "object",
58
74
  properties: {
59
75
  path: { type: "string", description: "Directory to list (defaults to current working directory)." },
76
+ reason: REASON_PROPERTY,
60
77
  },
61
- required: [],
78
+ required: ["reason"],
62
79
  },
63
80
  },
64
81
  },
65
82
  ];
66
83
  }
67
- export async function executeTool(name, args) {
84
+ export async function executeTool(name, args, cwd = process.cwd()) {
85
+ // Tools always operate inside the directory the conversation belongs to, which
86
+ // is not necessarily the directory the current process was started in.
87
+ const base = cwd || process.cwd();
68
88
  let result;
69
89
  try {
70
90
  switch (name) {
71
91
  case "run_command":
72
- result = await runCommand(String(args.command ?? ""));
92
+ result = await runCommand(String(args.command ?? ""), base);
73
93
  break;
74
94
  case "read_file":
75
- result = readFile(String(args.path ?? ""));
95
+ result = readFile(String(args.path ?? ""), base);
76
96
  break;
77
97
  case "write_file":
78
- result = writeFile(String(args.path ?? ""), String(args.content ?? ""));
98
+ result = writeFile(String(args.path ?? ""), String(args.content ?? ""), base);
79
99
  break;
80
100
  case "list_files":
81
- result = listFiles(String(args.path ?? ""));
101
+ result = listFiles(String(args.path ?? ""), base);
82
102
  break;
83
103
  default:
84
104
  result = `[Error: unknown tool '${name}']`;
@@ -89,13 +109,13 @@ export async function executeTool(name, args) {
89
109
  }
90
110
  return result.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
91
111
  }
92
- function runCommand(command) {
112
+ function runCommand(command, cwd) {
93
113
  return new Promise((resolve) => {
94
114
  if (!command.trim()) {
95
115
  resolve("[Error: empty command]");
96
116
  return;
97
117
  }
98
- exec(command, { cwd: process.cwd(), timeout: 120000, maxBuffer: MAX_OUTPUT * 4 }, (error, stdout, stderr) => {
118
+ exec(command, { cwd, timeout: 120000, maxBuffer: MAX_OUTPUT * 4 }, (error, stdout, stderr) => {
99
119
  const parts = [];
100
120
  if (stdout)
101
121
  parts.push(stdout);
@@ -116,8 +136,8 @@ function runCommand(command) {
116
136
  });
117
137
  });
118
138
  }
119
- function readFile(p) {
120
- const resolved = path.isAbsolute(p) ? p : path.join(process.cwd(), p);
139
+ function readFile(p, cwd) {
140
+ const resolved = path.isAbsolute(p) ? p : path.join(cwd, p);
121
141
  if (!fs.existsSync(resolved))
122
142
  return `[Error: file not found: ${resolved}]`;
123
143
  if (fs.statSync(resolved).isDirectory())
@@ -132,8 +152,8 @@ function readFile(p) {
132
152
  return `[Error: ${e instanceof Error ? e.message : String(e)}]`;
133
153
  }
134
154
  }
135
- function writeFile(p, content) {
136
- const resolved = path.isAbsolute(p) ? p : path.join(process.cwd(), p);
155
+ function writeFile(p, content, cwd) {
156
+ const resolved = path.isAbsolute(p) ? p : path.join(cwd, p);
137
157
  try {
138
158
  fs.mkdirSync(path.dirname(resolved), { recursive: true });
139
159
  fs.writeFileSync(resolved, content, "utf-8");
@@ -143,12 +163,8 @@ function writeFile(p, content) {
143
163
  return `[Error: ${e instanceof Error ? e.message : String(e)}]`;
144
164
  }
145
165
  }
146
- function listFiles(p) {
147
- const resolved = p.trim()
148
- ? path.isAbsolute(p)
149
- ? p
150
- : path.join(process.cwd(), p)
151
- : process.cwd();
166
+ function listFiles(p, cwd) {
167
+ const resolved = p.trim() ? (path.isAbsolute(p) ? p : path.join(cwd, p)) : cwd;
152
168
  if (!fs.existsSync(resolved))
153
169
  return `[Error: directory not found: ${resolved}]`;
154
170
  if (!fs.statSync(resolved).isDirectory())
@@ -0,0 +1,51 @@
1
+ /** Synchronous session checkpoints, shared by terminal and browser sessions. */
2
+ const checkpoints = new Set();
3
+ /** Bound how much streamed output a hard kill can lose, without writing per token. */
4
+ export const CHECKPOINT_MS = 1000;
5
+ export class SessionCheckpoint {
6
+ write;
7
+ timer = null;
8
+ constructor(write) {
9
+ this.write = write;
10
+ }
11
+ register() {
12
+ checkpoints.add(this);
13
+ }
14
+ /** A trailing checkpoint still runs if the provider stalls after one chunk. */
15
+ schedule() {
16
+ this.register();
17
+ if (this.timer)
18
+ return;
19
+ this.timer = setTimeout(() => this.flush(), CHECKPOINT_MS);
20
+ this.timer.unref();
21
+ }
22
+ flush() {
23
+ if (this.timer)
24
+ clearTimeout(this.timer);
25
+ this.timer = null;
26
+ return this.write();
27
+ }
28
+ dispose() {
29
+ try {
30
+ return this.flush();
31
+ }
32
+ finally {
33
+ checkpoints.delete(this);
34
+ }
35
+ }
36
+ }
37
+ /** No promises: exit handlers cannot wait for asynchronous disk writes. */
38
+ export function flushSessionCheckpoints() {
39
+ let saved = true;
40
+ for (const checkpoint of checkpoints) {
41
+ try {
42
+ if (!checkpoint.flush())
43
+ saved = false;
44
+ }
45
+ catch {
46
+ // One broken session must not prevent the others from being saved.
47
+ saved = false;
48
+ }
49
+ }
50
+ return saved;
51
+ }
@@ -1,7 +1,7 @@
1
1
  import { Screen } from "../terminal/screen.js";
2
2
  import { C } from "../colors.js";
3
3
  import { drawHeader } from "../header.js";
4
- import { truncateByWidth } from "../width.js";
4
+ import { ellipsizeStart, truncateByWidth } from "../width.js";
5
5
  import { drawHint } from "../ui.js";
6
6
  import { listAgentSessions, loadAgentMessages, } from "../agents.js";
7
7
  import { AgentConversationScreen } from "./agentConversation.js";
@@ -16,13 +16,16 @@ export class AgentHistoryScreen extends Screen {
16
16
  lastHoverY = -1;
17
17
  rows = [];
18
18
  itemBounds = [];
19
+ cwd = process.cwd();
19
20
  constructor(agent, label) {
20
21
  super();
21
22
  this.agent = agent;
22
23
  this.label = label;
23
24
  }
24
25
  onActivate() {
25
- this.sessions = listAgentSessions(this.agent);
26
+ // Only sessions started in the current working directory are listed.
27
+ this.cwd = process.cwd();
28
+ this.sessions = listAgentSessions(this.agent, this.cwd);
26
29
  this.selectedIdx = 0;
27
30
  this.scrollTop = 0;
28
31
  this.rows = [];
@@ -116,11 +119,11 @@ export class AgentHistoryScreen extends Screen {
116
119
  const w = buf.width;
117
120
  const h = buf.height;
118
121
  buf.centerText(2, this.label, { fg: C.accent, bold: true });
119
- buf.centerText(3, "Select a session to view", { fg: C.dim });
122
+ buf.centerText(3, `Sessions in ${ellipsizeStart(this.cwd, Math.max(8, w - 14))}`, { fg: C.dim });
120
123
  const listBottom = h - 2;
121
124
  const viewH = Math.max(1, listBottom - LIST_TOP);
122
125
  if (this.sessions.length === 0) {
123
- buf.centerText(LIST_TOP + 1, "No sessions yet", { fg: C.dim });
126
+ buf.centerText(LIST_TOP + 1, "No sessions in this directory", { fg: C.dim });
124
127
  drawHint(buf, listBottom, "Esc back");
125
128
  return;
126
129
  }