@psnext/slingcli 2.4.20260601-1 → 2.5.20260602-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 (96) hide show
  1. package/node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler/package.json +4 -4
  2. package/node_modules/@aws-sdk/credential-provider-node/dist-cjs/index.js +12 -1
  3. package/node_modules/@aws-sdk/credential-provider-node/dist-es/runtime/memoize-chain.js +12 -1
  4. package/node_modules/@aws-sdk/credential-provider-node/package.json +1 -1
  5. package/node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler/package.json +4 -4
  6. package/node_modules/@earendil-works/pi-agent-core/dist/harness/agent-harness.js +81 -18
  7. package/node_modules/@earendil-works/pi-agent-core/dist/harness/compaction/branch-summarization.js +1 -0
  8. package/node_modules/@earendil-works/pi-agent-core/dist/harness/compaction/compaction.js +1 -0
  9. package/node_modules/@earendil-works/pi-agent-core/dist/harness/session/session.js +14 -1
  10. package/node_modules/@earendil-works/pi-agent-core/package.json +3 -3
  11. package/node_modules/@earendil-works/pi-ai/dist/models.generated.js +197 -190
  12. package/node_modules/@earendil-works/pi-ai/dist/providers/amazon-bedrock.js +84 -21
  13. package/node_modules/@earendil-works/pi-ai/dist/providers/anthropic.js +24 -14
  14. package/node_modules/@earendil-works/pi-ai/dist/providers/azure-openai-responses.js +5 -9
  15. package/node_modules/@earendil-works/pi-ai/dist/providers/google-vertex.js +1 -1
  16. package/node_modules/@earendil-works/pi-ai/dist/providers/google.js +5 -3
  17. package/node_modules/@earendil-works/pi-ai/dist/providers/images/openrouter.js +2 -3
  18. package/node_modules/@earendil-works/pi-ai/dist/providers/mistral.js +2 -3
  19. package/node_modules/@earendil-works/pi-ai/dist/providers/openai-codex-responses.js +130 -62
  20. package/node_modules/@earendil-works/pi-ai/dist/providers/openai-completions.js +18 -12
  21. package/node_modules/@earendil-works/pi-ai/dist/providers/openai-responses-shared.js +4 -1
  22. package/node_modules/@earendil-works/pi-ai/dist/providers/openai-responses.js +5 -9
  23. package/node_modules/@earendil-works/pi-ai/dist/providers/simple-options.js +1 -0
  24. package/node_modules/@earendil-works/pi-ai/dist/stream.js +14 -2
  25. package/node_modules/@earendil-works/pi-ai/dist/utils/abort-signals.js +34 -0
  26. package/node_modules/@earendil-works/pi-ai/dist/utils/oauth/device-code.js +8 -7
  27. package/node_modules/@earendil-works/pi-ai/dist/utils/oauth/github-copilot.js +1 -1
  28. package/node_modules/@earendil-works/pi-ai/dist/utils/oauth/index.js +1 -1
  29. package/node_modules/@earendil-works/pi-ai/dist/utils/oauth/openai-codex.js +179 -79
  30. package/node_modules/@earendil-works/pi-ai/package.json +2 -2
  31. package/node_modules/@earendil-works/pi-coding-agent/dist/cli/args.js +27 -0
  32. package/node_modules/@earendil-works/pi-coding-agent/dist/config.js +9 -1
  33. package/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session-services.js +1 -0
  34. package/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js +29 -5
  35. package/node_modules/@earendil-works/pi-coding-agent/dist/core/extensions/runner.js +21 -3
  36. package/node_modules/@earendil-works/pi-coding-agent/dist/core/footer-data-provider.js +29 -1
  37. package/node_modules/@earendil-works/pi-coding-agent/dist/core/model-registry.js +65 -13
  38. package/node_modules/@earendil-works/pi-coding-agent/dist/core/model-resolver.js +1 -1
  39. package/node_modules/@earendil-works/pi-coding-agent/dist/core/resolve-config-value.js +134 -11
  40. package/node_modules/@earendil-works/pi-coding-agent/dist/core/sdk.js +12 -6
  41. package/node_modules/@earendil-works/pi-coding-agent/dist/core/session-manager.js +167 -96
  42. package/node_modules/@earendil-works/pi-coding-agent/dist/core/settings-manager.js +14 -9
  43. package/node_modules/@earendil-works/pi-coding-agent/dist/core/system-prompt.js +0 -3
  44. package/node_modules/@earendil-works/pi-coding-agent/dist/core/tools/edit.js +7 -10
  45. package/node_modules/@earendil-works/pi-coding-agent/dist/core/tools/ls.js +5 -7
  46. package/node_modules/@earendil-works/pi-coding-agent/dist/core/tools/read.js +6 -7
  47. package/node_modules/@earendil-works/pi-coding-agent/dist/core/tools/render-utils.js +17 -1
  48. package/node_modules/@earendil-works/pi-coding-agent/dist/core/tools/write.js +5 -6
  49. package/node_modules/@earendil-works/pi-coding-agent/dist/index.js +2 -0
  50. package/node_modules/@earendil-works/pi-coding-agent/dist/main.js +69 -16
  51. package/node_modules/@earendil-works/pi-coding-agent/dist/migrations.js +118 -1
  52. package/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/config-selector.js +1 -1
  53. package/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/login-dialog.js +2 -4
  54. package/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js +61 -6
  55. package/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/theme.js +10 -0
  56. package/node_modules/@earendil-works/pi-coding-agent/dist/modes/print-mode.js +1 -0
  57. package/node_modules/@earendil-works/pi-coding-agent/dist/modes/rpc/rpc-mode.js +4 -1
  58. package/node_modules/@earendil-works/pi-coding-agent/dist/utils/deprecation.js +13 -0
  59. package/node_modules/@earendil-works/pi-coding-agent/dist/utils/json.js +7 -0
  60. package/node_modules/@earendil-works/pi-coding-agent/npm-shrinkwrap.json +71 -56
  61. package/node_modules/@earendil-works/pi-coding-agent/package.json +7 -7
  62. package/node_modules/@earendil-works/pi-tui/dist/components/editor.js +9 -53
  63. package/node_modules/@earendil-works/pi-tui/dist/components/input.js +6 -54
  64. package/node_modules/@earendil-works/pi-tui/dist/terminal-image.js +34 -7
  65. package/node_modules/@earendil-works/pi-tui/dist/terminal.js +172 -37
  66. package/node_modules/@earendil-works/pi-tui/dist/tui.js +166 -22
  67. package/node_modules/@earendil-works/pi-tui/dist/utils.js +8 -3
  68. package/node_modules/@earendil-works/pi-tui/dist/word-navigation.js +96 -0
  69. package/node_modules/@earendil-works/pi-tui/package.json +2 -2
  70. package/node_modules/@mariozechner/clipboard/Cargo.toml +3 -3
  71. package/node_modules/@mariozechner/clipboard/index.d.ts +34 -20
  72. package/node_modules/@mariozechner/clipboard/index.js +546 -257
  73. package/node_modules/@mariozechner/clipboard/package.json +14 -14
  74. package/node_modules/@mariozechner/clipboard/src/lib.rs +4 -9
  75. package/node_modules/@mariozechner/clipboard-linux-x64-gnu/clipboard.linux-x64-gnu.node +0 -0
  76. package/node_modules/@mariozechner/clipboard-linux-x64-gnu/package.json +2 -2
  77. package/node_modules/@smithy/core/dist-cjs/submodules/retry/index.browser.js +39 -22
  78. package/node_modules/@smithy/core/dist-cjs/submodules/retry/index.js +39 -22
  79. package/node_modules/@smithy/core/dist-es/submodules/retry/middleware-retry/configurations.js +19 -6
  80. package/node_modules/@smithy/core/dist-es/submodules/retry/middleware-retry/retryMiddleware.js +4 -5
  81. package/node_modules/@smithy/core/dist-es/submodules/retry/service-error-classification/constants.js +1 -1
  82. package/node_modules/@smithy/core/dist-es/submodules/retry/util-retry/ConfiguredRetryStrategy.js +4 -5
  83. package/node_modules/@smithy/core/dist-es/submodules/retry/util-retry/DefaultRetryToken.js +3 -0
  84. package/node_modules/@smithy/core/dist-es/submodules/retry/util-retry/StandardRetryStrategy.js +9 -5
  85. package/node_modules/@smithy/core/dist-types/submodules/retry/middleware-retry/configurations.d.ts +4 -1
  86. package/node_modules/@smithy/core/dist-types/submodules/retry/util-retry/ConfiguredRetryStrategy.d.ts +1 -2
  87. package/node_modules/@smithy/core/dist-types/submodules/retry/util-retry/DefaultRetryToken.d.ts +3 -0
  88. package/node_modules/@smithy/core/dist-types/submodules/retry/util-retry/StandardRetryStrategy.d.ts +1 -1
  89. package/node_modules/@smithy/core/package.json +2 -2
  90. package/node_modules/@smithy/credential-provider-imds/package.json +3 -3
  91. package/node_modules/@smithy/fetch-http-handler/package.json +4 -4
  92. package/node_modules/@smithy/signature-v4/package.json +3 -3
  93. package/node_modules/@smithy/types/dist-types/retry.d.ts +25 -0
  94. package/node_modules/@smithy/types/package.json +1 -1
  95. package/package.json +6 -6
  96. package/slingshot/index.js +231 -230
@@ -1,8 +1,10 @@
1
1
  import { uuidv7 } from "@earendil-works/pi-agent-core";
2
2
  import { randomUUID } from "crypto";
3
- import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, statSync, writeFileSync, } from "fs";
4
- import { readdir, readFile, stat } from "fs/promises";
3
+ import { appendFileSync, closeSync, createReadStream, existsSync, mkdirSync, openSync, readdirSync, readSync, statSync, writeFileSync, } from "fs";
4
+ import { readdir, stat } from "fs/promises";
5
5
  import { join, resolve } from "path";
6
+ import { createInterface } from "readline";
7
+ import { StringDecoder } from "string_decoder";
6
8
  import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.js";
7
9
  import { normalizePath, resolvePath } from "../utils/paths.js";
8
10
  import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage, } from "./messages.js";
@@ -10,6 +12,11 @@ export const CURRENT_SESSION_VERSION = 3;
10
12
  function createSessionId() {
11
13
  return uuidv7();
12
14
  }
15
+ export function assertValidSessionId(id) {
16
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(id)) {
17
+ throw new Error("Session id must be non-empty, contain only alphanumeric characters, '-', '_', and '.', and start and end with an alphanumeric character");
18
+ }
19
+ }
13
20
  /** Generate a unique short ID (8 hex chars, collision-checked) */
14
21
  function generateId(byId) {
15
22
  for (let i = 0; i < 100; i++) {
@@ -210,34 +217,65 @@ export function buildSessionContext(entries, leafId, byId) {
210
217
  * Compute the default session directory for a cwd.
211
218
  * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/.
212
219
  */
213
- export function getDefaultSessionDir(cwd, agentDir = getDefaultAgentDir()) {
220
+ function getDefaultSessionDirPath(cwd, agentDir = getDefaultAgentDir()) {
214
221
  const resolvedCwd = resolvePath(cwd);
215
222
  const resolvedAgentDir = resolvePath(agentDir);
216
223
  const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
217
- const sessionDir = join(resolvedAgentDir, "sessions", safePath);
224
+ return join(resolvedAgentDir, "sessions", safePath);
225
+ }
226
+ export function getDefaultSessionDir(cwd, agentDir = getDefaultAgentDir()) {
227
+ const sessionDir = getDefaultSessionDirPath(cwd, agentDir);
218
228
  if (!existsSync(sessionDir)) {
219
229
  mkdirSync(sessionDir, { recursive: true });
220
230
  }
221
231
  return sessionDir;
222
232
  }
233
+ const SESSION_READ_BUFFER_SIZE = 1024 * 1024;
234
+ function parseSessionEntryLine(line) {
235
+ if (!line.trim())
236
+ return null;
237
+ try {
238
+ return JSON.parse(line);
239
+ }
240
+ catch {
241
+ // Skip malformed lines
242
+ return null;
243
+ }
244
+ }
223
245
  /** Exported for testing */
224
246
  export function loadEntriesFromFile(filePath) {
225
247
  const resolvedFilePath = normalizePath(filePath);
226
248
  if (!existsSync(resolvedFilePath))
227
249
  return [];
228
- const content = readFileSync(resolvedFilePath, "utf8");
229
250
  const entries = [];
230
- const lines = content.trim().split("\n");
231
- for (const line of lines) {
232
- if (!line.trim())
233
- continue;
234
- try {
235
- const entry = JSON.parse(line);
236
- entries.push(entry);
237
- }
238
- catch {
239
- // Skip malformed lines
251
+ const fd = openSync(resolvedFilePath, "r");
252
+ try {
253
+ const decoder = new StringDecoder("utf8");
254
+ const buffer = Buffer.allocUnsafe(SESSION_READ_BUFFER_SIZE);
255
+ let pending = "";
256
+ while (true) {
257
+ const bytesRead = readSync(fd, buffer, 0, buffer.length, null);
258
+ if (bytesRead === 0)
259
+ break;
260
+ pending += decoder.write(buffer.subarray(0, bytesRead));
261
+ let lineStart = 0;
262
+ let newlineIndex = pending.indexOf("\n", lineStart);
263
+ while (newlineIndex !== -1) {
264
+ const entry = parseSessionEntryLine(pending.slice(lineStart, newlineIndex));
265
+ if (entry)
266
+ entries.push(entry);
267
+ lineStart = newlineIndex + 1;
268
+ newlineIndex = pending.indexOf("\n", lineStart);
269
+ }
270
+ pending = pending.slice(lineStart);
240
271
  }
272
+ pending += decoder.end();
273
+ const finalEntry = parseSessionEntryLine(pending);
274
+ if (finalEntry)
275
+ entries.push(finalEntry);
276
+ }
277
+ finally {
278
+ closeSync(fd);
241
279
  }
242
280
  // Validate session header
243
281
  if (entries.length === 0)
@@ -248,7 +286,7 @@ export function loadEntriesFromFile(filePath) {
248
286
  }
249
287
  return entries;
250
288
  }
251
- function isValidSessionFile(filePath) {
289
+ function readSessionHeader(filePath) {
252
290
  try {
253
291
  const fd = openSync(filePath, "r");
254
292
  const buffer = Buffer.alloc(512);
@@ -256,23 +294,36 @@ function isValidSessionFile(filePath) {
256
294
  closeSync(fd);
257
295
  const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n")[0];
258
296
  if (!firstLine)
259
- return false;
297
+ return null;
260
298
  const header = JSON.parse(firstLine);
261
- return header.type === "session" && typeof header.id === "string";
299
+ if (header.type !== "session" || typeof header.id !== "string") {
300
+ return null;
301
+ }
302
+ return header;
262
303
  }
263
304
  catch {
264
- return false;
305
+ return null;
265
306
  }
266
307
  }
308
+ function getSessionHeaderCwd(header) {
309
+ const cwd = header.cwd;
310
+ return typeof cwd === "string" ? cwd : undefined;
311
+ }
312
+ function sessionCwdMatches(cwd, resolvedCwd) {
313
+ return cwd !== undefined && cwd !== "" && resolvePath(cwd) === resolvedCwd;
314
+ }
267
315
  /** Exported for testing */
268
- export function findMostRecentSession(sessionDir) {
316
+ export function findMostRecentSession(sessionDir, cwd) {
269
317
  const resolvedSessionDir = normalizePath(sessionDir);
318
+ const resolvedCwd = cwd ? resolvePath(cwd) : undefined;
270
319
  try {
271
320
  const files = readdirSync(resolvedSessionDir)
272
321
  .filter((f) => f.endsWith(".jsonl"))
273
322
  .map((f) => join(resolvedSessionDir, f))
274
- .filter(isValidSessionFile)
275
- .map((path) => ({ path, mtime: statSync(path).mtime }))
323
+ .map((path) => ({ path, header: readSessionHeader(path) }))
324
+ .filter((file) => file.header !== null &&
325
+ (!resolvedCwd || sessionCwdMatches(getSessionHeaderCwd(file.header), resolvedCwd)))
326
+ .map(({ path }) => ({ path, mtime: statSync(path).mtime }))
276
327
  .sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
277
328
  return files[0]?.path || null;
278
329
  }
@@ -293,73 +344,53 @@ function extractTextContent(message) {
293
344
  .map((block) => block.text)
294
345
  .join(" ");
295
346
  }
296
- function getLastActivityTime(entries) {
297
- let lastActivityTime;
298
- for (const entry of entries) {
299
- if (entry.type !== "message")
300
- continue;
301
- const message = entry.message;
302
- if (!isMessageWithContent(message))
303
- continue;
304
- if (message.role !== "user" && message.role !== "assistant")
305
- continue;
306
- const msgTimestamp = message.timestamp;
307
- if (typeof msgTimestamp === "number") {
308
- lastActivityTime = Math.max(lastActivityTime ?? 0, msgTimestamp);
309
- continue;
310
- }
311
- const entryTimestamp = entry.timestamp;
312
- if (typeof entryTimestamp === "string") {
313
- const t = new Date(entryTimestamp).getTime();
314
- if (!Number.isNaN(t)) {
315
- lastActivityTime = Math.max(lastActivityTime ?? 0, t);
316
- }
317
- }
318
- }
319
- return lastActivityTime;
320
- }
321
- function getSessionModifiedDate(entries, header, statsMtime) {
322
- const lastActivityTime = getLastActivityTime(entries);
323
- if (typeof lastActivityTime === "number" && lastActivityTime > 0) {
324
- return new Date(lastActivityTime);
347
+ function getMessageActivityTime(entry) {
348
+ const message = entry.message;
349
+ if (!isMessageWithContent(message))
350
+ return undefined;
351
+ if (message.role !== "user" && message.role !== "assistant")
352
+ return undefined;
353
+ const msgTimestamp = message.timestamp;
354
+ if (typeof msgTimestamp === "number") {
355
+ return msgTimestamp;
325
356
  }
326
- const headerTime = typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : NaN;
327
- return !Number.isNaN(headerTime) ? new Date(headerTime) : statsMtime;
357
+ const t = new Date(entry.timestamp).getTime();
358
+ return Number.isNaN(t) ? undefined : t;
328
359
  }
329
360
  async function buildSessionInfo(filePath) {
330
361
  try {
331
- const content = await readFile(filePath, "utf8");
332
- const entries = [];
333
- const lines = content.trim().split("\n");
334
- for (const line of lines) {
335
- if (!line.trim())
336
- continue;
337
- try {
338
- entries.push(JSON.parse(line));
339
- }
340
- catch {
341
- // Skip malformed lines
342
- }
343
- }
344
- if (entries.length === 0)
345
- return null;
346
- const header = entries[0];
347
- if (header.type !== "session")
348
- return null;
349
362
  const stats = await stat(filePath);
363
+ let header = null;
350
364
  let messageCount = 0;
351
365
  let firstMessage = "";
352
366
  const allMessages = [];
353
367
  let name;
354
- for (const entry of entries) {
368
+ let lastActivityTime;
369
+ const rl = createInterface({
370
+ input: createReadStream(filePath, { encoding: "utf8" }),
371
+ crlfDelay: Infinity,
372
+ });
373
+ for await (const line of rl) {
374
+ const entry = parseSessionEntryLine(line);
375
+ if (!entry)
376
+ continue;
377
+ if (!header) {
378
+ if (entry.type !== "session")
379
+ return null;
380
+ header = entry;
381
+ continue;
382
+ }
355
383
  // Extract session name (use latest, including explicit clears)
356
384
  if (entry.type === "session_info") {
357
- const infoEntry = entry;
358
- name = infoEntry.name?.trim() || undefined;
385
+ name = entry.name?.trim() || undefined;
359
386
  }
360
387
  if (entry.type !== "message")
361
388
  continue;
362
389
  messageCount++;
390
+ const activityTime = getMessageActivityTime(entry);
391
+ if (typeof activityTime === "number") {
392
+ lastActivityTime = Math.max(lastActivityTime ?? 0, activityTime);
393
+ }
363
394
  const message = entry.message;
364
395
  if (!isMessageWithContent(message))
365
396
  continue;
@@ -373,9 +404,16 @@ async function buildSessionInfo(filePath) {
373
404
  firstMessage = textContent;
374
405
  }
375
406
  }
407
+ if (!header)
408
+ return null;
376
409
  const cwd = typeof header.cwd === "string" ? header.cwd : "";
377
410
  const parentSessionPath = header.parentSession;
378
- const modified = getSessionModifiedDate(entries, header, stats.mtime);
411
+ const headerTime = typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : NaN;
412
+ const modified = typeof lastActivityTime === "number" && lastActivityTime > 0
413
+ ? new Date(lastActivityTime)
414
+ : !Number.isNaN(headerTime)
415
+ ? new Date(headerTime)
416
+ : stats.mtime;
379
417
  return {
380
418
  path: filePath,
381
419
  id: header.id,
@@ -475,7 +513,7 @@ export class SessionManager {
475
513
  labelsById = new Map();
476
514
  labelTimestampsById = new Map();
477
515
  leafId = null;
478
- constructor(cwd, sessionDir, sessionFile, persist) {
516
+ constructor(cwd, sessionDir, sessionFile, persist, newSessionOptions) {
479
517
  this.cwd = resolvePath(cwd);
480
518
  this.sessionDir = normalizePath(sessionDir);
481
519
  this.persist = persist;
@@ -486,7 +524,7 @@ export class SessionManager {
486
524
  this.setSessionFile(sessionFile);
487
525
  }
488
526
  else {
489
- this.newSession();
527
+ this.newSession(newSessionOptions);
490
528
  }
491
529
  }
492
530
  /** Switch to a different session file (used for resume and branching) */
@@ -519,6 +557,9 @@ export class SessionManager {
519
557
  }
520
558
  }
521
559
  newSession(options) {
560
+ if (options?.id !== undefined) {
561
+ assertValidSessionId(options.id);
562
+ }
522
563
  this.sessionId = options?.id ?? createSessionId();
523
564
  const timestamp = new Date().toISOString();
524
565
  const header = {
@@ -565,8 +606,15 @@ export class SessionManager {
565
606
  _rewriteFile() {
566
607
  if (!this.persist || !this.sessionFile)
567
608
  return;
568
- const content = `${this.fileEntries.map((e) => JSON.stringify(e)).join("\n")}\n`;
569
- writeFileSync(this.sessionFile, content);
609
+ const fd = openSync(this.sessionFile, "w");
610
+ try {
611
+ for (const entry of this.fileEntries) {
612
+ writeFileSync(fd, `${JSON.stringify(entry)}\n`);
613
+ }
614
+ }
615
+ finally {
616
+ closeSync(fd);
617
+ }
570
618
  }
571
619
  isPersisted() {
572
620
  return this.persist;
@@ -577,6 +625,9 @@ export class SessionManager {
577
625
  getSessionDir() {
578
626
  return this.sessionDir;
579
627
  }
628
+ usesDefaultSessionDir() {
629
+ return this.sessionDir === getDefaultSessionDirPath(this.cwd);
630
+ }
580
631
  getSessionId() {
581
632
  return this.sessionId;
582
633
  }
@@ -588,13 +639,24 @@ export class SessionManager {
588
639
  return;
589
640
  const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
590
641
  if (!hasAssistant) {
591
- // Mark as not flushed so when assistant arrives, all entries get written
592
- this.flushed = false;
642
+ if (this.flushed) {
643
+ appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
644
+ }
645
+ else {
646
+ // Mark as not flushed so when assistant arrives, all entries get written
647
+ this.flushed = false;
648
+ }
593
649
  return;
594
650
  }
595
651
  if (!this.flushed) {
596
- for (const e of this.fileEntries) {
597
- appendFileSync(this.sessionFile, `${JSON.stringify(e)}\n`);
652
+ const fd = openSync(this.sessionFile, "wx");
653
+ try {
654
+ for (const e of this.fileEntries) {
655
+ writeFileSync(fd, `${JSON.stringify(e)}\n`);
656
+ }
657
+ }
658
+ finally {
659
+ closeSync(fd);
598
660
  }
599
661
  this.flushed = true;
600
662
  }
@@ -1004,9 +1066,9 @@ export class SessionManager {
1004
1066
  * @param cwd Working directory (stored in session header)
1005
1067
  * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).
1006
1068
  */
1007
- static create(cwd, sessionDir) {
1069
+ static create(cwd, sessionDir, options) {
1008
1070
  const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
1009
- return new SessionManager(cwd, dir, undefined, true);
1071
+ return new SessionManager(cwd, dir, undefined, true, options);
1010
1072
  }
1011
1073
  /**
1012
1074
  * Open a specific session file.
@@ -1031,7 +1093,8 @@ export class SessionManager {
1031
1093
  */
1032
1094
  static continueRecent(cwd, sessionDir) {
1033
1095
  const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
1034
- const mostRecent = findMostRecentSession(dir);
1096
+ const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd);
1097
+ const mostRecent = findMostRecentSession(dir, filterCwd ? cwd : undefined);
1035
1098
  if (mostRecent) {
1036
1099
  return new SessionManager(cwd, dir, mostRecent, true);
1037
1100
  }
@@ -1048,7 +1111,7 @@ export class SessionManager {
1048
1111
  * @param targetCwd Target working directory (where the new session will be stored)
1049
1112
  * @param sessionDir Optional session directory. If omitted, uses default for targetCwd.
1050
1113
  */
1051
- static forkFrom(sourcePath, targetCwd, sessionDir) {
1114
+ static forkFrom(sourcePath, targetCwd, sessionDir, options) {
1052
1115
  const resolvedSourcePath = resolvePath(sourcePath);
1053
1116
  const resolvedTargetCwd = resolvePath(targetCwd);
1054
1117
  const sourceEntries = loadEntriesFromFile(resolvedSourcePath);
@@ -1064,7 +1127,10 @@ export class SessionManager {
1064
1127
  mkdirSync(dir, { recursive: true });
1065
1128
  }
1066
1129
  // Create new session file with new ID but forked content
1067
- const newSessionId = createSessionId();
1130
+ if (options?.id !== undefined) {
1131
+ assertValidSessionId(options.id);
1132
+ }
1133
+ const newSessionId = options?.id ?? createSessionId();
1068
1134
  const timestamp = new Date().toISOString();
1069
1135
  const fileTimestamp = timestamp.replace(/[:.]/g, "-");
1070
1136
  const newSessionFile = join(dir, `${fileTimestamp}_${newSessionId}.jsonl`);
@@ -1077,7 +1143,7 @@ export class SessionManager {
1077
1143
  cwd: resolvedTargetCwd,
1078
1144
  parentSession: resolvedSourcePath,
1079
1145
  };
1080
- appendFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`);
1146
+ writeFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`, { flag: "wx" });
1081
1147
  // Copy all non-header entries from source
1082
1148
  for (const entry of sourceEntries) {
1083
1149
  if (entry.type !== "session") {
@@ -1094,15 +1160,20 @@ export class SessionManager {
1094
1160
  */
1095
1161
  static async list(cwd, sessionDir, onProgress) {
1096
1162
  const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
1097
- const sessions = await listSessionsFromDir(dir, onProgress);
1163
+ const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd);
1164
+ const resolvedCwd = resolvePath(cwd);
1165
+ const sessions = (await listSessionsFromDir(dir, onProgress)).filter((session) => !filterCwd || sessionCwdMatches(session.cwd, resolvedCwd));
1098
1166
  sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
1099
1167
  return sessions;
1100
1168
  }
1101
- /**
1102
- * List all sessions across all project directories.
1103
- * @param onProgress Optional callback for progress updates (loaded, total)
1104
- */
1105
- static async listAll(onProgress) {
1169
+ static async listAll(sessionDirOrOnProgress, onProgress) {
1170
+ const customSessionDir = typeof sessionDirOrOnProgress === "string" ? normalizePath(sessionDirOrOnProgress) : undefined;
1171
+ const progress = typeof sessionDirOrOnProgress === "function" ? sessionDirOrOnProgress : onProgress;
1172
+ if (customSessionDir) {
1173
+ const sessions = await listSessionsFromDir(customSessionDir, progress);
1174
+ sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
1175
+ return sessions;
1176
+ }
1106
1177
  const sessionsDir = getSessionsDir();
1107
1178
  try {
1108
1179
  if (!existsSync(sessionsDir)) {
@@ -1129,7 +1200,7 @@ export class SessionManager {
1129
1200
  const allFiles = dirFiles.flat();
1130
1201
  const results = await buildSessionInfosWithConcurrency(allFiles, () => {
1131
1202
  loaded++;
1132
- onProgress?.(loaded, totalFiles);
1203
+ progress?.(loaded, totalFiles);
1133
1204
  });
1134
1205
  for (const info of results) {
1135
1206
  if (info) {
@@ -29,6 +29,16 @@ function deepMergeSettings(base, overrides) {
29
29
  }
30
30
  return result;
31
31
  }
32
+ function parseTimeoutSetting(value, settingName) {
33
+ const timeoutMs = parseHttpIdleTimeoutMs(value);
34
+ if (timeoutMs !== undefined) {
35
+ return timeoutMs;
36
+ }
37
+ if (value !== undefined) {
38
+ throw new Error(`Invalid ${settingName} setting: ${String(value)}`);
39
+ }
40
+ return undefined;
41
+ }
32
42
  export class FileSettingsStorage {
33
43
  globalSettingsPath;
34
44
  projectSettingsPath;
@@ -494,15 +504,7 @@ export class SettingsManager {
494
504
  };
495
505
  }
496
506
  getHttpIdleTimeoutMs() {
497
- const value = this.settings.httpIdleTimeoutMs;
498
- const timeoutMs = parseHttpIdleTimeoutMs(value);
499
- if (timeoutMs !== undefined) {
500
- return timeoutMs;
501
- }
502
- if (value !== undefined) {
503
- throw new Error(`Invalid httpIdleTimeoutMs setting: ${String(value)}`);
504
- }
505
- return DEFAULT_HTTP_IDLE_TIMEOUT_MS;
507
+ return parseTimeoutSetting(this.settings.httpIdleTimeoutMs, "httpIdleTimeoutMs") ?? DEFAULT_HTTP_IDLE_TIMEOUT_MS;
506
508
  }
507
509
  setHttpIdleTimeoutMs(timeoutMs) {
508
510
  if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
@@ -519,6 +521,9 @@ export class SettingsManager {
519
521
  maxRetryDelayMs: this.settings.retry?.provider?.maxRetryDelayMs ?? 60000,
520
522
  };
521
523
  }
524
+ getWebSocketConnectTimeoutMs() {
525
+ return parseTimeoutSetting(this.settings.websocketConnectTimeoutMs, "websocketConnectTimeoutMs");
526
+ }
522
527
  getHideThinkingBlock() {
523
528
  return this.settings.hideThinkingBlock ?? false;
524
529
  }
@@ -68,9 +68,6 @@ export function buildSystemPrompt(options) {
68
68
  if (hasBash && !hasGrep && !hasFind && !hasLs) {
69
69
  addGuideline("Use bash for file operations like ls, rg, find");
70
70
  }
71
- else if (hasBash && (hasGrep || hasFind || hasLs)) {
72
- addGuideline("Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)");
73
- }
74
71
  for (const guideline of promptGuidelines ?? []) {
75
72
  const normalized = guideline.trim();
76
73
  if (normalized.length > 0) {
@@ -6,7 +6,7 @@ import { renderDiff } from "../../modes/interactive/components/diff.js";
6
6
  import { applyEditsToNormalizedContent, computeEditsDiff, detectLineEnding, generateDiffString, generateUnifiedPatch, normalizeToLF, restoreLineEndings, stripBom, } from "./edit-diff.js";
7
7
  import { withFileMutationQueue } from "./file-mutation-queue.js";
8
8
  import { resolveToCwd } from "./path-utils.js";
9
- import { invalidArgText, shortenPath, str } from "./render-utils.js";
9
+ import { renderToolPath, str } from "./render-utils.js";
10
10
  import { wrapToolDefinition } from "./tool-definition-wrapper.js";
11
11
  const replaceEditSchema = Type.Object({
12
12
  oldText: Type.String({
@@ -93,11 +93,8 @@ function getRenderablePreviewInput(args) {
93
93
  }
94
94
  return null;
95
95
  }
96
- function formatEditCall(args, theme) {
97
- const invalidArg = invalidArgText(theme);
98
- const rawPath = str(args?.file_path ?? args?.path);
99
- const path = rawPath !== null ? shortenPath(rawPath) : null;
100
- const pathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
96
+ function formatEditCall(args, theme, cwd) {
97
+ const pathDisplay = renderToolPath(str(args?.file_path ?? args?.path), theme, cwd);
101
98
  return `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
102
99
  }
103
100
  function formatEditResult(args, preview, result, theme, isError) {
@@ -132,10 +129,10 @@ function getEditHeaderBg(preview, settledError, theme) {
132
129
  }
133
130
  return (text) => theme.bg("toolPendingBg", text);
134
131
  }
135
- function buildEditCallComponent(component, args, theme) {
132
+ function buildEditCallComponent(component, args, theme, cwd) {
136
133
  component.setBgFn(getEditHeaderBg(component.preview, component.settledError, theme));
137
134
  component.clear();
138
- component.addChild(new Text(formatEditCall(args, theme), 0, 0));
135
+ component.addChild(new Text(formatEditCall(args, theme, cwd), 0, 0));
139
136
  if (!component.preview) {
140
137
  return component;
141
138
  }
@@ -245,7 +242,7 @@ export function createEditToolDefinition(cwd, options) {
245
242
  }
246
243
  });
247
244
  }
248
- return buildEditCallComponent(component, args, theme);
245
+ return buildEditCallComponent(component, args, theme, context.cwd);
249
246
  },
250
247
  renderResult(result, _options, theme, context) {
251
248
  const callComponent = context.state.callComponent;
@@ -266,7 +263,7 @@ export function createEditToolDefinition(cwd, options) {
266
263
  changed = true;
267
264
  }
268
265
  if (changed) {
269
- buildEditCallComponent(callComponent, context.args, theme);
266
+ buildEditCallComponent(callComponent, context.args, theme, context.cwd);
270
267
  }
271
268
  }
272
269
  const output = formatEditResult(context.args, callComponent?.preview, typedResult, theme, context.isError);
@@ -4,7 +4,7 @@ import nodePath from "path";
4
4
  import { Type } from "typebox";
5
5
  import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
6
6
  import { pathExists, resolveToCwd } from "./path-utils.js";
7
- import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.js";
7
+ import { getTextOutput, renderToolPath, str } from "./render-utils.js";
8
8
  import { wrapToolDefinition } from "./tool-definition-wrapper.js";
9
9
  import { DEFAULT_MAX_BYTES, formatSize, truncateHead } from "./truncate.js";
10
10
  const lsSchema = Type.Object({
@@ -17,12 +17,10 @@ const defaultLsOperations = {
17
17
  stat: fsStat,
18
18
  readdir: fsReaddir,
19
19
  };
20
- function formatLsCall(args, theme) {
21
- const rawPath = str(args?.path);
22
- const path = rawPath !== null ? shortenPath(rawPath || ".") : null;
20
+ function formatLsCall(args, theme, cwd) {
23
21
  const limit = args?.limit;
24
- const invalidArg = invalidArgText(theme);
25
- let text = `${theme.fg("toolTitle", theme.bold("ls"))} ${path === null ? invalidArg : theme.fg("accent", path)}`;
22
+ const pathDisplay = renderToolPath(str(args?.path), theme, cwd, { emptyFallback: "." });
23
+ let text = `${theme.fg("toolTitle", theme.bold("ls"))} ${pathDisplay}`;
26
24
  if (limit !== undefined) {
27
25
  text += theme.fg("toolOutput", ` (limit ${limit})`);
28
26
  }
@@ -153,7 +151,7 @@ export function createLsToolDefinition(cwd, options) {
153
151
  },
154
152
  renderCall(args, theme, context) {
155
153
  const text = context.lastComponent ?? new Text("", 0, 0);
156
- text.setText(formatLsCall(args, theme));
154
+ text.setText(formatLsCall(args, theme, context.cwd));
157
155
  return text;
158
156
  },
159
157
  renderResult(result, options, theme, context) {
@@ -10,7 +10,7 @@ import { formatDimensionNote, resizeImage } from "../../utils/image-resize.js";
10
10
  import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js";
11
11
  import { formatPathRelativeToCwdOrAbsolute } from "../../utils/paths.js";
12
12
  import { resolveReadPathAsync, resolveToCwd } from "./path-utils.js";
13
- import { getTextOutput, invalidArgText, replaceTabs, shortenPath, str } from "./render-utils.js";
13
+ import { getTextOutput, renderToolPath, replaceTabs, str } from "./render-utils.js";
14
14
  import { wrapToolDefinition } from "./tool-definition-wrapper.js";
15
15
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "./truncate.js";
16
16
  const readSchema = Type.Object({
@@ -31,11 +31,8 @@ function formatReadLineRange(args, theme) {
31
31
  const endLine = args.limit !== undefined ? startLine + args.limit - 1 : "";
32
32
  return theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
33
33
  }
34
- function formatReadCall(args, theme) {
35
- const rawPath = str(args?.file_path ?? args?.path);
36
- const path = rawPath !== null ? shortenPath(rawPath) : null;
37
- const invalidArg = invalidArgText(theme);
38
- const pathDisplay = path === null ? invalidArg : path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
34
+ function formatReadCall(args, theme, cwd) {
35
+ const pathDisplay = renderToolPath(str(args?.file_path ?? args?.path), theme, cwd);
39
36
  return `${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}${formatReadLineRange(args, theme)}`;
40
37
  }
41
38
  function trimTrailingEmptyLines(lines) {
@@ -274,7 +271,9 @@ export function createReadToolDefinition(cwd, options) {
274
271
  renderCall(args, theme, context) {
275
272
  const text = context.lastComponent ?? new Text("", 0, 0);
276
273
  const classification = !context.expanded ? getCompactReadClassification(args, context.cwd) : undefined;
277
- text.setText(classification ? formatCompactReadCall(classification, args, theme) : formatReadCall(args, theme));
274
+ text.setText(classification
275
+ ? formatCompactReadCall(classification, args, theme)
276
+ : formatReadCall(args, theme, context.cwd));
278
277
  return text;
279
278
  },
280
279
  renderResult(result, options, theme, context) {
@@ -1,6 +1,8 @@
1
1
  import * as os from "node:os";
2
- import { getCapabilities, getImageDimensions, imageFallback } from "@earendil-works/pi-tui";
2
+ import { pathToFileURL } from "node:url";
3
+ import { getCapabilities, getImageDimensions, hyperlink, imageFallback } from "@earendil-works/pi-tui";
3
4
  import { stripAnsi } from "../../utils/ansi.js";
5
+ import { resolvePath } from "../../utils/paths.js";
4
6
  import { sanitizeBinaryOutput } from "../../utils/shell.js";
5
7
  export function shortenPath(path) {
6
8
  if (typeof path !== "string")
@@ -11,6 +13,12 @@ export function shortenPath(path) {
11
13
  }
12
14
  return path;
13
15
  }
16
+ export function linkPath(styledText, rawPath, cwd) {
17
+ if (!getCapabilities().hyperlinks)
18
+ return styledText;
19
+ const absolutePath = resolvePath(rawPath, cwd);
20
+ return hyperlink(styledText, pathToFileURL(absolutePath).href);
21
+ }
14
22
  export function str(value) {
15
23
  if (typeof value === "string")
16
24
  return value;
@@ -46,4 +54,12 @@ export function getTextOutput(result, showImages) {
46
54
  export function invalidArgText(theme) {
47
55
  return theme.fg("error", "[invalid arg]");
48
56
  }
57
+ export function renderToolPath(rawPath, theme, cwd, options) {
58
+ if (rawPath === null)
59
+ return invalidArgText(theme);
60
+ const value = rawPath || options?.emptyFallback;
61
+ if (!value)
62
+ return theme.fg("toolOutput", "...");
63
+ return linkPath(theme.fg("accent", shortenPath(value)), value, cwd);
64
+ }
49
65
  //# sourceMappingURL=render-utils.js.map