@oh-my-pi/pi-coding-agent 16.3.2 → 16.3.4

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 (94) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/cli.js +3553 -3540
  3. package/dist/types/cli/update-cli.d.ts +1 -0
  4. package/dist/types/cli/update-cli.test.d.ts +1 -0
  5. package/dist/types/commands/update.d.ts +5 -0
  6. package/dist/types/config/config-file.d.ts +1 -1
  7. package/dist/types/config/settings-schema.d.ts +10 -0
  8. package/dist/types/dap/client.d.ts +9 -1
  9. package/dist/types/edit/file-snapshot-store.d.ts +9 -1
  10. package/dist/types/edit/hashline/execute.d.ts +1 -1
  11. package/dist/types/edit/hashline/params.d.ts +3 -6
  12. package/dist/types/edit/renderer.d.ts +1 -0
  13. package/dist/types/extensibility/plugins/parser.d.ts +2 -1
  14. package/dist/types/hindsight/client.d.ts +15 -11
  15. package/dist/types/hindsight/client.test.d.ts +1 -0
  16. package/dist/types/mcp/smithery-registry.d.ts +1 -0
  17. package/dist/types/mcp/smithery-registry.test.d.ts +1 -0
  18. package/dist/types/modes/components/advisor-message.d.ts +4 -2
  19. package/dist/types/modes/components/tool-execution.d.ts +9 -6
  20. package/dist/types/modes/components/transcript-container.d.ts +1 -0
  21. package/dist/types/modes/theme/theme.d.ts +1 -1
  22. package/dist/types/session/indexed-session-storage.d.ts +2 -2
  23. package/dist/types/session/session-storage.d.ts +31 -3
  24. package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +1 -0
  25. package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +1 -0
  26. package/dist/types/ssh/connection-manager.d.ts +19 -0
  27. package/dist/types/ssh/sshfs-mount.d.ts +10 -1
  28. package/dist/types/subprocess/worker-client.d.ts +20 -6
  29. package/dist/types/tools/bash.d.ts +27 -0
  30. package/dist/types/tools/renderers.d.ts +13 -0
  31. package/dist/types/tools/ssh.d.ts +2 -0
  32. package/dist/types/utils/fetch-timeout.d.ts +4 -0
  33. package/dist/types/web/search/providers/base.d.ts +1 -0
  34. package/dist/types/web/search/providers/gemini.d.ts +1 -0
  35. package/package.json +12 -12
  36. package/scripts/generate-legacy-pi-bundled-registry.ts +8 -2
  37. package/src/cli/models-cli.ts +19 -0
  38. package/src/cli/update-cli.test.ts +28 -0
  39. package/src/cli/update-cli.ts +35 -8
  40. package/src/cli/usage-cli.ts +34 -5
  41. package/src/commands/update.ts +8 -2
  42. package/src/config/config-file.ts +6 -6
  43. package/src/config/settings-schema.ts +10 -0
  44. package/src/dap/client.ts +134 -36
  45. package/src/edit/file-snapshot-store.ts +12 -1
  46. package/src/edit/hashline/diff.ts +4 -13
  47. package/src/edit/hashline/execute.ts +1 -1
  48. package/src/edit/hashline/params.ts +5 -12
  49. package/src/edit/renderer.ts +4 -2
  50. package/src/edit/streaming.ts +15 -5
  51. package/src/export/html/tool-views.generated.js +2 -2
  52. package/src/extensibility/plugins/installer.ts +12 -3
  53. package/src/extensibility/plugins/manager.ts +32 -8
  54. package/src/extensibility/plugins/parser.ts +7 -5
  55. package/src/extensibility/tool-event-input.ts +1 -1
  56. package/src/hindsight/client.test.ts +33 -0
  57. package/src/hindsight/client.ts +42 -22
  58. package/src/internal-urls/docs-index.generated.txt +1 -1
  59. package/src/main.ts +7 -1
  60. package/src/mcp/oauth-flow.ts +93 -4
  61. package/src/mcp/smithery-auth.ts +3 -0
  62. package/src/mcp/smithery-connect.ts +9 -0
  63. package/src/mcp/smithery-registry.test.ts +51 -0
  64. package/src/mcp/smithery-registry.ts +27 -4
  65. package/src/modes/components/advisor-message.ts +13 -10
  66. package/src/modes/components/status-line/component.ts +11 -4
  67. package/src/modes/components/tool-execution.ts +74 -8
  68. package/src/modes/components/transcript-container.ts +26 -0
  69. package/src/modes/controllers/event-controller.ts +7 -3
  70. package/src/modes/controllers/tool-args-reveal.ts +1 -1
  71. package/src/modes/interactive-mode.ts +1 -0
  72. package/src/modes/rpc/rpc-client.ts +29 -16
  73. package/src/modes/theme/shimmer.ts +49 -15
  74. package/src/modes/theme/theme.ts +7 -0
  75. package/src/session/agent-session.ts +166 -30
  76. package/src/session/indexed-session-storage.ts +40 -3
  77. package/src/session/session-manager.ts +83 -14
  78. package/src/session/session-storage.ts +112 -26
  79. package/src/slash-commands/helpers/usage-report.ts +6 -1
  80. package/src/ssh/__tests__/connection-manager-timeout.test.ts +61 -0
  81. package/src/ssh/__tests__/sshfs-mount.test.ts +13 -0
  82. package/src/ssh/connection-manager.ts +44 -11
  83. package/src/ssh/sshfs-mount.ts +27 -4
  84. package/src/subprocess/worker-client.ts +161 -10
  85. package/src/tools/bash.ts +30 -1
  86. package/src/tools/grep.ts +21 -1
  87. package/src/tools/read.ts +14 -4
  88. package/src/tools/renderers.ts +13 -0
  89. package/src/tools/ssh.ts +8 -0
  90. package/src/utils/clipboard.ts +49 -12
  91. package/src/utils/fetch-timeout.ts +10 -0
  92. package/src/web/search/index.ts +8 -0
  93. package/src/web/search/providers/base.ts +1 -0
  94. package/src/web/search/providers/gemini.ts +23 -6
@@ -383,6 +383,17 @@ export class SessionManager {
383
383
  #diskFailureLogged = false;
384
384
  /** Bumped on every sync rewrite / chain reset so stale queued tasks become no-ops. */
385
385
  #diskEpoch = 0;
386
+ /**
387
+ * Epoch of the in-flight atomic rewrite, or `null` when no rewrite is running.
388
+ * The fence in {@link #appendToSessionFile} only applies while this matches
389
+ * `#diskEpoch`: once a synchronous rewrite (`flushSync` → `#rewriteSynchronously`)
390
+ * bumps the epoch, the pending atomic publish is guaranteed to abandon via
391
+ * its `commitGuard`, and appends can safely take the hot path against the
392
+ * freshly-published file.
393
+ */
394
+ #atomicRewriteFenceEpoch: number | null = null;
395
+ /** Set by synchronous appends that land while an atomic replacement is active. */
396
+ #atomicRewriteDirty = false;
386
397
 
387
398
  #artifactManager: ArtifactManager | null = null;
388
399
  #artifactManagerSessionFile: string | null = null;
@@ -555,27 +566,65 @@ export class SessionManager {
555
566
 
556
567
  /**
557
568
  * Rewrite the whole file atomically (temp-write + rename, EPERM-safe) on the
558
- * disk chain. The body is serialized inside the task after the writer is
559
- * closed so entries appended before the task runs are included.
569
+ * disk chain. The body is serialized after the writer is closed. The fence
570
+ * is enabled BEFORE `#closeWriterHandle()` and stays active until the last
571
+ * atomic publish returns, so a sync append landing in the close-yield window
572
+ * cannot open a fresh writer that the pending replacement would then detach
573
+ * from the current JSONL path. A `commitGuard` also prevents a superseding
574
+ * synchronous rewrite from being overwritten by the stale body serialized
575
+ * before it ran.
560
576
  */
561
577
  async #rewriteAtomically(): Promise<void> {
562
578
  if (!this.#persist || !this.#sessionFile) return;
563
579
 
564
- const epoch = this.#diskEpoch;
580
+ const startEpoch = this.#diskEpoch;
565
581
  await this.#scheduleDiskWork(
566
582
  async () => {
567
- await this.#closeWriterHandle();
568
- const sessionFile = this.#sessionFile;
569
- if (!sessionFile) return;
570
- await this.#storage.writeTextAtomic(sessionFile, this.#fileBody());
571
- this.#fileIsCurrent = true;
572
- this.#rewriteRequired = false;
573
- this.#hasTitleSlot = true;
583
+ if (await this.#runFencedAtomicRewrite(startEpoch)) {
584
+ this.#fileIsCurrent = true;
585
+ this.#rewriteRequired = false;
586
+ this.#hasTitleSlot = true;
587
+ }
574
588
  },
575
- { epoch },
589
+ { epoch: startEpoch },
576
590
  );
577
591
  }
578
592
 
593
+ /**
594
+ * Shared fenced atomic-rewrite loop used by `#rewriteAtomically` and the
595
+ * `#persistTitleChangeEntry` fallback. Holds `#atomicRewriteActive` across
596
+ * the writer close and the full-file replace, and loops on
597
+ * `#atomicRewriteDirty` so any fenced append that lands during the rewrite
598
+ * is captured before the task resolves. Returns `false` when the disk epoch
599
+ * moved (a superseding synchronous rewrite has taken over) so callers skip
600
+ * their post-publish state updates.
601
+ */
602
+ async #runFencedAtomicRewrite(epoch: number): Promise<boolean> {
603
+ this.#atomicRewriteFenceEpoch = epoch;
604
+ try {
605
+ do {
606
+ this.#atomicRewriteDirty = false;
607
+ await this.#closeWriterHandle();
608
+ const sessionFile = this.#sessionFile;
609
+ if (!sessionFile) return false;
610
+ if (this.#diskEpoch !== epoch) return false;
611
+ await this.#storage.writeTextAtomic(sessionFile, this.#fileBody(), {
612
+ commitGuard: () => this.#diskEpoch === epoch,
613
+ });
614
+ if (this.#diskEpoch !== epoch) return false;
615
+ } while (this.#atomicRewriteDirty);
616
+ return true;
617
+ } finally {
618
+ // Only relinquish the fence if we still own it. A superseding
619
+ // synchronous rewrite (`flushSync` → `#rewriteSynchronously`) may
620
+ // have reset `#diskTail`, scheduled a fresh atomic task at the new
621
+ // epoch, and that task may have taken ownership of the fence while
622
+ // this stale rewrite was still awaiting storage. Clearing it here
623
+ // unconditionally would strand appends during the newer publish.
624
+ if (this.#atomicRewriteFenceEpoch === epoch) this.#atomicRewriteFenceEpoch = null;
625
+ }
626
+ }
627
+
579
628
  #appendToSessionFile(entry: SessionEntry): void {
580
629
  if (!this.#persist || !this.#sessionFile) return;
581
630
  if (this.#diskFailure) throw this.#diskFailure;
@@ -588,6 +637,19 @@ export class SessionManager {
588
637
  return;
589
638
  }
590
639
 
640
+ // Atomic replacement window: the old path may be moved aside underneath
641
+ // any newly-opened append handle (Windows EPERM fallback). Do not open a
642
+ // writer here; the active rewrite loops and serializes a fresh full body.
643
+ // A superseding synchronous rewrite bumps `#diskEpoch`, at which point
644
+ // the pending atomic publish is guaranteed to abandon via its
645
+ // `commitGuard`, so appends can (and must) take the hot path so they
646
+ // don't strand in memory while `close()` returns without a rewrite.
647
+ if (this.#atomicRewriteFenceEpoch !== null && this.#atomicRewriteFenceEpoch === this.#diskEpoch) {
648
+ this.#fileIsCurrent = false;
649
+ this.#rewriteRequired = true;
650
+ this.#atomicRewriteDirty = true;
651
+ return;
652
+ }
591
653
  // Cold/divergent: not on disk yet, or in-memory entries diverged from the
592
654
  // file → rewrite the whole file synchronously and keep going.
593
655
  if (!this.#fileIsCurrent || this.#rewriteRequired) {
@@ -637,10 +699,9 @@ export class SessionManager {
637
699
  try {
638
700
  await this.#appendWriter().append(line);
639
701
  await this.#storage.updateSessionTitle(sessionFile, update);
640
- this.#fileIsCurrent = true;
702
+ if (this.#diskEpoch === epoch) this.#fileIsCurrent = true;
641
703
  } catch {
642
- await this.#closeWriterHandle();
643
- await this.#storage.writeTextAtomic(sessionFile, this.#fileBody());
704
+ if (!(await this.#runFencedAtomicRewrite(epoch))) return;
644
705
  this.#clearDiskError();
645
706
  this.#fileIsCurrent = true;
646
707
  this.#rewriteRequired = false;
@@ -1049,6 +1110,10 @@ export class SessionManager {
1049
1110
  await this.#scheduleDiskWork(async () => {
1050
1111
  if (this.#writer?.isOpen()) await this.#writer.flush();
1051
1112
  });
1113
+ // Drain any fire-and-forget backing writes (e.g. `writeTextSync` queued
1114
+ // on IndexedSessionStorage during `flushSync`) so callers relying on
1115
+ // flush() see the write durably visible to readers.
1116
+ await this.#storage.drain();
1052
1117
  if (this.#diskFailure) throw this.#diskFailure;
1053
1118
  }
1054
1119
 
@@ -1078,6 +1143,10 @@ export class SessionManager {
1078
1143
  if (hadWriter || (this.#sessionFile && this.#storage.existsSync(this.#sessionFile)))
1079
1144
  this.#fileIsCurrent = true;
1080
1145
  });
1146
+ // Wait for any queued backing writes (IndexedSessionStorage per-path
1147
+ // tail) to become durable so a graceful shutdown does not exit while
1148
+ // a fire-and-forget publish is still on the wire.
1149
+ await this.#storage.drain();
1081
1150
  if (this.#diskFailure) throw this.#diskFailure;
1082
1151
  }
1083
1152
 
@@ -28,6 +28,18 @@ export interface SessionStorageWriter {
28
28
  getError(): Error | undefined;
29
29
  }
30
30
 
31
+ /**
32
+ * Optional guard applied by {@link SessionStorage.writeTextAtomic}. The
33
+ * backend MUST call `commitGuard()` synchronously immediately before it makes
34
+ * the staged content visible at `path`. If it returns `false`, the staged
35
+ * write is discarded and the target is left untouched. Backends MUST NOT
36
+ * yield between calling the guard and publishing the write, so a concurrent
37
+ * synchronous rewrite that took over cannot be overwritten by a stale body.
38
+ */
39
+ export interface WriteTextAtomicOptions {
40
+ commitGuard?: () => boolean;
41
+ }
42
+
31
43
  export interface SessionStorage {
32
44
  ensureDirSync(dir: string): void;
33
45
  existsSync(path: string): boolean;
@@ -48,11 +60,20 @@ export interface SessionStorage {
48
60
  /** Read the requested UTF-8 byte windows from the head and tail of the file. */
49
61
  readTextSlices(path: string, prefixBytes: number, suffixBytes: number): Promise<[string, string]>;
50
62
  writeText(path: string, content: string): Promise<void>;
51
- writeTextAtomic(path: string, content: string): Promise<void>;
63
+ writeTextAtomic(path: string, content: string, options?: WriteTextAtomicOptions): Promise<void>;
52
64
  rename(path: string, nextPath: string): Promise<void>;
53
65
  unlink(path: string): Promise<void>;
54
66
  deleteSessionWithArtifacts(sessionPath: string): Promise<void>;
55
67
  openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter;
68
+ /**
69
+ * Wait for every backing write scheduled by this storage to become durably
70
+ * visible. Sync backends (file, memory) return immediately because their
71
+ * writes complete in-body; async backends (Redis/SQL via
72
+ * {@link IndexedSessionStorage}) await their per-path queues so a caller
73
+ * driving a graceful shutdown does not exit while a fire-and-forget
74
+ * `writeTextSync` publish is still on the wire.
75
+ */
76
+ drain(): Promise<void>;
56
77
  }
57
78
 
58
79
  // FinalizationRegistry to clean up leaked file descriptors
@@ -229,53 +250,107 @@ export class FileSessionStorage implements SessionStorage {
229
250
  await Bun.write(path, content, { createPath: true });
230
251
  }
231
252
 
232
- async writeTextAtomic(fpath: string, content: string): Promise<void> {
253
+ async writeTextAtomic(fpath: string, content: string, options?: WriteTextAtomicOptions): Promise<void> {
233
254
  const dir = path.resolve(fpath, "..");
234
255
  const tempPath = path.join(dir, `.${path.basename(fpath)}.${Snowflake.next()}.tmp`);
235
256
  await fs.promises.mkdir(dir, { recursive: true });
236
257
  try {
237
258
  await fs.promises.writeFile(tempPath, content);
259
+ } catch (err) {
260
+ this.#discardTemp(tempPath, fpath);
261
+ throw toError(err);
262
+ }
263
+ // Guard-check + rename MUST NOT be separated by an await. A concurrent
264
+ // synchronous rewrite (flushSync -> #rewriteSynchronously) can otherwise
265
+ // publish a fresh body between the check and the rename, and this stale
266
+ // staged body would overwrite it. Sync rename closes that window.
267
+ if (options?.commitGuard && !options.commitGuard()) {
268
+ this.#discardTemp(tempPath, fpath);
269
+ return;
270
+ }
271
+ try {
272
+ this.renameSync(tempPath, fpath);
273
+ return;
274
+ } catch (err) {
275
+ if (!hasFsCode(err, "EPERM")) {
276
+ this.#discardTemp(tempPath, fpath);
277
+ throw toError(err);
278
+ }
238
279
  try {
239
- await this.rename(tempPath, fpath);
240
- return;
241
- } catch (err) {
242
- if (!hasFsCode(err, "EPERM")) throw toError(err);
243
- await this.#replaceSessionFileAfterEperm(tempPath, fpath, err);
244
- return;
280
+ this.#replaceSessionFileAfterEpermSync(tempPath, fpath, err, options?.commitGuard);
281
+ } catch (fallbackErr) {
282
+ this.#discardTemp(tempPath, fpath);
283
+ throw fallbackErr;
245
284
  }
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Sync rename hook. Split from `rename` so `writeTextAtomic` can perform its
290
+ * guard-then-publish step without a yield, and so tests can inject
291
+ * Windows-style EPERM at the sync layer used by the atomic path.
292
+ */
293
+ renameSync(source: string, target: string): void {
294
+ fs.renameSync(source, target);
295
+ }
296
+
297
+ #discardTemp(tempPath: string, targetPath: string): void {
298
+ try {
299
+ fs.unlinkSync(tempPath);
246
300
  } catch (err) {
247
- try {
248
- await this.unlink(tempPath);
249
- } catch (cleanupErr) {
250
- if (!isEnoent(cleanupErr)) {
251
- logger.warn("Failed to remove session rewrite temp file", {
252
- sessionFile: fpath,
253
- tempPath,
254
- error: toError(cleanupErr).message,
255
- });
256
- }
301
+ if (!isEnoent(err)) {
302
+ logger.warn("Failed to remove session rewrite temp file", {
303
+ sessionFile: targetPath,
304
+ tempPath,
305
+ error: toError(err).message,
306
+ });
257
307
  }
258
- throw toError(err);
259
308
  }
260
309
  }
261
310
 
262
- async #replaceSessionFileAfterEperm(tempPath: string, targetPath: string, renameError: unknown): Promise<void> {
311
+ #replaceSessionFileAfterEpermSync(
312
+ tempPath: string,
313
+ targetPath: string,
314
+ renameError: unknown,
315
+ commitGuard?: () => boolean,
316
+ ): void {
263
317
  const dir = path.resolve(targetPath, "..");
264
318
  const backupPath = path.join(dir, `${path.basename(targetPath)}.${Snowflake.next()}.bak`);
265
319
  try {
266
- await this.rename(targetPath, backupPath);
320
+ this.renameSync(targetPath, backupPath);
267
321
  } catch (moveAsideError) {
268
322
  if (isEnoent(moveAsideError)) {
269
- await this.rename(tempPath, targetPath);
323
+ if (commitGuard && !commitGuard()) {
324
+ this.#discardTemp(tempPath, targetPath);
325
+ return;
326
+ }
327
+ this.renameSync(tempPath, targetPath);
270
328
  return;
271
329
  }
272
330
  throw toError(renameError);
273
331
  }
332
+ if (commitGuard && !commitGuard()) {
333
+ // A concurrent synchronous rewrite published a fresh body between the
334
+ // move-aside and this point. Restore the moved-aside file so we do
335
+ // not overwrite it with our staged (stale) body, and drop the temp
336
+ // so `writeTextAtomic`'s "discard on abandon" contract holds.
337
+ try {
338
+ this.renameSync(backupPath, targetPath);
339
+ } catch (restoreErr) {
340
+ logger.warn("Failed to restore backup after commitGuard rejection", {
341
+ sessionFile: targetPath,
342
+ backupPath,
343
+ error: toError(restoreErr).message,
344
+ });
345
+ }
346
+ this.#discardTemp(tempPath, targetPath);
347
+ return;
348
+ }
274
349
  try {
275
- await this.rename(tempPath, targetPath);
350
+ this.renameSync(tempPath, targetPath);
276
351
  } catch (replaceError) {
277
352
  try {
278
- await this.rename(backupPath, targetPath);
353
+ this.renameSync(backupPath, targetPath);
279
354
  } catch (rollbackErr) {
280
355
  const rollbackError = toError(rollbackErr);
281
356
  throw new Error(
@@ -288,7 +363,7 @@ export class FileSessionStorage implements SessionStorage {
288
363
  throw toError(replaceError);
289
364
  }
290
365
  try {
291
- await this.unlink(backupPath);
366
+ fs.unlinkSync(backupPath);
292
367
  } catch (err) {
293
368
  if (!isEnoent(err)) {
294
369
  logger.warn("Failed to remove session rewrite backup", {
@@ -312,6 +387,12 @@ export class FileSessionStorage implements SessionStorage {
312
387
  return fs.promises.unlink(path);
313
388
  }
314
389
 
390
+ drain(): Promise<void> {
391
+ // File writes complete synchronously in-body via fs.writeFileSync /
392
+ // fs.renameSync, so there is no queued work to await.
393
+ return Promise.resolve();
394
+ }
395
+
315
396
  openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter {
316
397
  return new FileSessionStorageWriter(path, options);
317
398
  }
@@ -621,7 +702,8 @@ export class MemorySessionStorage implements SessionStorage {
621
702
  return Promise.resolve();
622
703
  }
623
704
 
624
- writeTextAtomic(path: string, content: string): Promise<void> {
705
+ writeTextAtomic(path: string, content: string, options?: WriteTextAtomicOptions): Promise<void> {
706
+ if (options?.commitGuard && !options.commitGuard()) return Promise.resolve();
625
707
  this.writeTextSync(path, content);
626
708
  return Promise.resolve();
627
709
  }
@@ -642,6 +724,10 @@ export class MemorySessionStorage implements SessionStorage {
642
724
  return Promise.resolve();
643
725
  }
644
726
 
727
+ drain(): Promise<void> {
728
+ return Promise.resolve();
729
+ }
730
+
645
731
  openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter {
646
732
  return new MemorySessionStorageWriter(this, path, options);
647
733
  }
@@ -100,7 +100,12 @@ function renderUsageReports(
100
100
  for (let index = 0; index < report.limits.length; index++) {
101
101
  const limit = report.limits[index]!;
102
102
  const window = limit.window?.label ?? limit.scope.windowId;
103
- const tier = limit.scope.tier ? ` (${limit.scope.tier})` : "";
103
+ // Skip the tier suffix when the label already names it (e.g. Anthropic's
104
+ // "Claude 7 Day (Fable)" with scope.tier "fable") — mirrors limitTitle in usage-cli.
105
+ const tier =
106
+ limit.scope.tier && !limit.label.toLowerCase().includes(limit.scope.tier.toLowerCase())
107
+ ? ` (${limit.scope.tier})`
108
+ : "";
104
109
  lines.push(`- ${limit.label}${tier}${window ? ` — ${window}` : ""}`);
105
110
  lines.push(
106
111
  ` ${formatUsageReportAccount(report, limit, index)}: ${formatUsageAmount(limit)}${inUse ? " ← in use by this session" : ""}`,
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Regression for #4232: `runSshSync` / `runSshCaptureSync` sit on the
3
+ * `ensureHostInfo` → `probeHostInfo` / `ensureConnection` path that runs before
4
+ * `SshTool.execute` applies the user's command timeout. Previously they invoked
5
+ * `ssh` through `$`ssh ${args}`.quiet().nothrow()` with no timeout and no
6
+ * abort signal, so an unreachable host or wedged control-master hung forever.
7
+ *
8
+ * The contract now is: each helper is bounded by `timeoutMs`, aborts a stalled
9
+ * child, and returns a failure result (`exitCode !== 0`, non-empty
10
+ * `stderr`) instead of throwing or blocking.
11
+ */
12
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
13
+ import * as fs from "node:fs/promises";
14
+ import * as os from "node:os";
15
+ import * as path from "node:path";
16
+ import { _sshHelpersForTests } from "../connection-manager";
17
+
18
+ const { runSshSync, runSshCaptureSync } = _sshHelpersForTests;
19
+
20
+ let binDir: string;
21
+ let originalPath: string | undefined;
22
+
23
+ beforeAll(async () => {
24
+ binDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-ssh-timeout-"));
25
+ // Fake `ssh` that traps SIGTERM and sleeps far past any test bound.
26
+ // Simulates a wedged control-master / unreachable host.
27
+ const fake = path.join(binDir, "ssh");
28
+ await fs.writeFile(fake, "#!/usr/bin/env bash\ntrap '' TERM\nsleep 300\n", { mode: 0o755 });
29
+ originalPath = process.env.PATH;
30
+ process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`;
31
+ });
32
+
33
+ afterAll(async () => {
34
+ if (originalPath === undefined) delete process.env.PATH;
35
+ else process.env.PATH = originalPath;
36
+ await fs.rm(binDir, { recursive: true, force: true });
37
+ });
38
+
39
+ describe("SSH pre-command helpers bound their own runtime (#4232)", () => {
40
+ it("runSshSync returns a failure result within the timeout on a wedged host", async () => {
41
+ const timeoutMs = 200;
42
+ const started = Date.now();
43
+ const result = await runSshSync(["-o", "BatchMode=yes", "unreachable", "true"], timeoutMs);
44
+ const elapsed = Date.now() - started;
45
+
46
+ expect(elapsed).toBeLessThan(5_000);
47
+ // timeout → aborted child, so exit code is null (aborted) or non-zero.
48
+ expect(result.exitCode).not.toBe(0);
49
+ }, 10_000);
50
+
51
+ it("runSshCaptureSync returns a failure result within the timeout on a wedged host", async () => {
52
+ const timeoutMs = 200;
53
+ const started = Date.now();
54
+ const result = await runSshCaptureSync(["-o", "BatchMode=yes", "unreachable", "true"], timeoutMs);
55
+ const elapsed = Date.now() - started;
56
+
57
+ expect(elapsed).toBeLessThan(5_000);
58
+ expect(result.exitCode).not.toBe(0);
59
+ expect(result.stdout).toBe("");
60
+ }, 10_000);
61
+ });
@@ -0,0 +1,13 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import * as path from "node:path";
3
+ import { isMounted } from "../sshfs-mount";
4
+
5
+ describe("isMounted", () => {
6
+ it("detects a macOS mount point when mountpoint is unavailable", async () => {
7
+ const parentPath = import.meta.dir;
8
+ const mountPath = path.join(parentPath, "mounted");
9
+ const stat = async (filePath: string) => ({ dev: filePath === mountPath ? 2 : 1 });
10
+
11
+ await expect(isMounted(mountPath, { platform: "darwin", stat, which: () => null })).resolves.toBe(true);
12
+ });
13
+ });
@@ -1,7 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { $which, getRemoteHostDir, getSshControlDir, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
4
- import { $ } from "bun";
3
+ import { $which, getRemoteHostDir, getSshControlDir, isEnoent, logger, postmortem, ptree } from "@oh-my-pi/pi-utils";
5
4
  import { buildSshTarget, sanitizeHostName } from "./utils";
6
5
 
7
6
  export interface SSHConnectionTarget {
@@ -116,20 +115,54 @@ function buildCommonArgs(host: SSHConnectionTarget, options?: SSHArgsOptions): s
116
115
  return args;
117
116
  }
118
117
 
119
- async function runSshSync(args: string[]): Promise<{ exitCode: number | null; stderr: string }> {
120
- const result = await $`ssh ${args}`.quiet().nothrow();
121
- return { exitCode: result.exitCode, stderr: result.stderr.toString().trim() };
122
- }
123
-
124
- async function runSshCaptureSync(args: string[]): Promise<{ exitCode: number | null; stdout: string; stderr: string }> {
125
- const result = await $`ssh ${args}`.quiet().nothrow();
118
+ /**
119
+ * Per-call timeout for the pre-command SSH setup/probe helpers. These sit on
120
+ * the `ensureHostInfo` `probeHostInfo` / `ensureConnection` path that runs
121
+ * *before* `SshTool.execute` applies the user-provided command timeout, so an
122
+ * unreachable host or wedged control-master would otherwise hang forever
123
+ * (#4232). `allowNonZero`/`allowAbort` keep the "return a failure result"
124
+ * contract that these helpers had under `.quiet().nothrow()`.
125
+ */
126
+ const SSH_HELPER_TIMEOUT_MS = 30_000;
127
+
128
+ async function runSshSync(
129
+ args: string[],
130
+ timeoutMs = SSH_HELPER_TIMEOUT_MS,
131
+ ): Promise<{ exitCode: number | null; stderr: string }> {
132
+ const result = await ptree.exec(["ssh", ...args], {
133
+ timeout: timeoutMs,
134
+ allowNonZero: true,
135
+ allowAbort: true,
136
+ stderr: "full",
137
+ });
138
+ return { exitCode: result.exitCode, stderr: result.stderr.trim() };
139
+ }
140
+
141
+ async function runSshCaptureSync(
142
+ args: string[],
143
+ timeoutMs = SSH_HELPER_TIMEOUT_MS,
144
+ ): Promise<{ exitCode: number | null; stdout: string; stderr: string }> {
145
+ const result = await ptree.exec(["ssh", ...args], {
146
+ timeout: timeoutMs,
147
+ allowNonZero: true,
148
+ allowAbort: true,
149
+ stderr: "full",
150
+ });
126
151
  return {
127
152
  exitCode: result.exitCode,
128
- stdout: result.stdout.toString().trim(),
129
- stderr: result.stderr.toString().trim(),
153
+ stdout: result.stdout.trim(),
154
+ stderr: result.stderr.trim(),
130
155
  };
131
156
  }
132
157
 
158
+ /**
159
+ * Test-only surface for exercising the pre-command SSH helpers against a
160
+ * fake `ssh` binary with a shortened timeout. External code MUST NOT depend
161
+ * on this — call `ensureConnection` / `ensureHostInfo` instead.
162
+ * @internal
163
+ */
164
+ export const _sshHelpersForTests = { runSshSync, runSshCaptureSync };
165
+
133
166
  function ensureSshBinary(): void {
134
167
  if (!$which("ssh")) {
135
168
  throw new Error("ssh binary not found on PATH");
@@ -16,6 +16,16 @@ const CONTROL_PATH = getControlPathTemplate();
16
16
 
17
17
  const mountedPaths = new Set<string>();
18
18
 
19
+ type MountPointStatReader = (filePath: string) => Promise<{ dev: number }>;
20
+
21
+ interface MountCheckOptions {
22
+ platform?: NodeJS.Platform;
23
+ stat?: MountPointStatReader;
24
+ which?: (command: string) => string | null;
25
+ }
26
+
27
+ const readMountPointStats: MountPointStatReader = async filePath => fs.promises.stat(filePath);
28
+
19
29
  async function ensureDir(path: string, mode = 0o700): Promise<void> {
20
30
  try {
21
31
  await fs.promises.mkdir(path, { recursive: true, mode });
@@ -79,10 +89,23 @@ export function hasSshfs(): boolean {
79
89
  return $which("sshfs") !== null;
80
90
  }
81
91
 
82
- export async function isMounted(path: string): Promise<boolean> {
83
- const mountpoint = $which("mountpoint");
84
- if (!mountpoint) return false;
85
- const result = await $`${mountpoint} -q ${path}`.quiet().nothrow();
92
+ async function isMountedByDeviceBoundary(mountPath: string, stat = readMountPointStats): Promise<boolean> {
93
+ try {
94
+ const [mountStats, parentStats] = await Promise.all([stat(mountPath), stat(path.dirname(mountPath))]);
95
+ return mountStats.dev !== parentStats.dev;
96
+ } catch {
97
+ return false;
98
+ }
99
+ }
100
+
101
+ export async function isMounted(mountPath: string, options: MountCheckOptions = {}): Promise<boolean> {
102
+ const which = options.which ?? $which;
103
+ const mountpoint = which("mountpoint");
104
+ if (!mountpoint) {
105
+ const platform = options.platform ?? process.platform;
106
+ return platform === "darwin" ? isMountedByDeviceBoundary(mountPath, options.stat) : false;
107
+ }
108
+ const result = await $`${mountpoint} -q ${mountPath}`.quiet().nothrow();
86
109
  return result.exitCode === 0;
87
110
  }
88
111