@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.
- package/CHANGELOG.md +44 -0
- package/dist/cli.js +3553 -3540
- package/dist/types/cli/update-cli.d.ts +1 -0
- package/dist/types/cli/update-cli.test.d.ts +1 -0
- package/dist/types/commands/update.d.ts +5 -0
- package/dist/types/config/config-file.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +10 -0
- package/dist/types/dap/client.d.ts +9 -1
- package/dist/types/edit/file-snapshot-store.d.ts +9 -1
- package/dist/types/edit/hashline/execute.d.ts +1 -1
- package/dist/types/edit/hashline/params.d.ts +3 -6
- package/dist/types/edit/renderer.d.ts +1 -0
- package/dist/types/extensibility/plugins/parser.d.ts +2 -1
- package/dist/types/hindsight/client.d.ts +15 -11
- package/dist/types/hindsight/client.test.d.ts +1 -0
- package/dist/types/mcp/smithery-registry.d.ts +1 -0
- package/dist/types/mcp/smithery-registry.test.d.ts +1 -0
- package/dist/types/modes/components/advisor-message.d.ts +4 -2
- package/dist/types/modes/components/tool-execution.d.ts +9 -6
- package/dist/types/modes/components/transcript-container.d.ts +1 -0
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/session/indexed-session-storage.d.ts +2 -2
- package/dist/types/session/session-storage.d.ts +31 -3
- package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +1 -0
- package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +1 -0
- package/dist/types/ssh/connection-manager.d.ts +19 -0
- package/dist/types/ssh/sshfs-mount.d.ts +10 -1
- package/dist/types/subprocess/worker-client.d.ts +20 -6
- package/dist/types/tools/bash.d.ts +27 -0
- package/dist/types/tools/renderers.d.ts +13 -0
- package/dist/types/tools/ssh.d.ts +2 -0
- package/dist/types/utils/fetch-timeout.d.ts +4 -0
- package/dist/types/web/search/providers/base.d.ts +1 -0
- package/dist/types/web/search/providers/gemini.d.ts +1 -0
- package/package.json +12 -12
- package/scripts/generate-legacy-pi-bundled-registry.ts +8 -2
- package/src/cli/models-cli.ts +19 -0
- package/src/cli/update-cli.test.ts +28 -0
- package/src/cli/update-cli.ts +35 -8
- package/src/cli/usage-cli.ts +34 -5
- package/src/commands/update.ts +8 -2
- package/src/config/config-file.ts +6 -6
- package/src/config/settings-schema.ts +10 -0
- package/src/dap/client.ts +134 -36
- package/src/edit/file-snapshot-store.ts +12 -1
- package/src/edit/hashline/diff.ts +4 -13
- package/src/edit/hashline/execute.ts +1 -1
- package/src/edit/hashline/params.ts +5 -12
- package/src/edit/renderer.ts +4 -2
- package/src/edit/streaming.ts +15 -5
- package/src/export/html/tool-views.generated.js +2 -2
- package/src/extensibility/plugins/installer.ts +12 -3
- package/src/extensibility/plugins/manager.ts +32 -8
- package/src/extensibility/plugins/parser.ts +7 -5
- package/src/extensibility/tool-event-input.ts +1 -1
- package/src/hindsight/client.test.ts +33 -0
- package/src/hindsight/client.ts +42 -22
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/main.ts +7 -1
- package/src/mcp/oauth-flow.ts +93 -4
- package/src/mcp/smithery-auth.ts +3 -0
- package/src/mcp/smithery-connect.ts +9 -0
- package/src/mcp/smithery-registry.test.ts +51 -0
- package/src/mcp/smithery-registry.ts +27 -4
- package/src/modes/components/advisor-message.ts +13 -10
- package/src/modes/components/status-line/component.ts +11 -4
- package/src/modes/components/tool-execution.ts +74 -8
- package/src/modes/components/transcript-container.ts +26 -0
- package/src/modes/controllers/event-controller.ts +7 -3
- package/src/modes/controllers/tool-args-reveal.ts +1 -1
- package/src/modes/interactive-mode.ts +1 -0
- package/src/modes/rpc/rpc-client.ts +29 -16
- package/src/modes/theme/shimmer.ts +49 -15
- package/src/modes/theme/theme.ts +7 -0
- package/src/session/agent-session.ts +166 -30
- package/src/session/indexed-session-storage.ts +40 -3
- package/src/session/session-manager.ts +83 -14
- package/src/session/session-storage.ts +112 -26
- package/src/slash-commands/helpers/usage-report.ts +6 -1
- package/src/ssh/__tests__/connection-manager-timeout.test.ts +61 -0
- package/src/ssh/__tests__/sshfs-mount.test.ts +13 -0
- package/src/ssh/connection-manager.ts +44 -11
- package/src/ssh/sshfs-mount.ts +27 -4
- package/src/subprocess/worker-client.ts +161 -10
- package/src/tools/bash.ts +30 -1
- package/src/tools/grep.ts +21 -1
- package/src/tools/read.ts +14 -4
- package/src/tools/renderers.ts +13 -0
- package/src/tools/ssh.ts +8 -0
- package/src/utils/clipboard.ts +49 -12
- package/src/utils/fetch-timeout.ts +10 -0
- package/src/web/search/index.ts +8 -0
- package/src/web/search/providers/base.ts +1 -0
- 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
|
|
559
|
-
*
|
|
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
|
|
580
|
+
const startEpoch = this.#diskEpoch;
|
|
565
581
|
await this.#scheduleDiskWork(
|
|
566
582
|
async () => {
|
|
567
|
-
await this.#
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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.#
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
320
|
+
this.renameSync(targetPath, backupPath);
|
|
267
321
|
} catch (moveAsideError) {
|
|
268
322
|
if (isEnoent(moveAsideError)) {
|
|
269
|
-
|
|
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
|
-
|
|
350
|
+
this.renameSync(tempPath, targetPath);
|
|
276
351
|
} catch (replaceError) {
|
|
277
352
|
try {
|
|
278
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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.
|
|
129
|
-
stderr: result.stderr.
|
|
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");
|
package/src/ssh/sshfs-mount.ts
CHANGED
|
@@ -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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|