pi-squire 0.11.0 → 0.13.0
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 +42 -1
- package/README.md +94 -4
- package/package.json +3 -1
- package/src/index.ts +129 -35
- package/src/navigator.ts +382 -0
- package/src/new-delegate.ts +438 -0
- package/src/poll.ts +111 -0
- package/src/popups.ts +49 -0
- package/src/render.ts +2 -2
- package/src/resume-checks.ts +159 -0
- package/src/resume-picker.ts +50 -0
- package/src/resume.ts +69 -104
- package/src/tmux.ts +278 -38
- package/src/tracking.ts +76 -131
- package/src/window-placement.ts +31 -0
- package/src/windows.ts +24 -0
- package/src/workspace.ts +1 -1
package/src/tracking.ts
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
import { statSync } from "node:fs";
|
|
6
6
|
|
|
7
7
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import { Type } from "typebox";
|
|
8
|
+
import { type Static, Type } from "typebox";
|
|
9
9
|
|
|
10
10
|
import { type Mark, readMarks } from "./limits.ts";
|
|
11
|
+
import { createPoll } from "./poll.ts";
|
|
12
|
+
import type { Popup } from "./popups.ts";
|
|
11
13
|
import { attemptLines, cwdLine, modelLabel, snapshotLines, workspaceLine } from "./render.ts";
|
|
12
14
|
import {
|
|
13
15
|
type DelegationState,
|
|
@@ -19,7 +21,6 @@ import {
|
|
|
19
21
|
recordedName,
|
|
20
22
|
type StartedRecorded,
|
|
21
23
|
sessionIdOf,
|
|
22
|
-
type WindowState,
|
|
23
24
|
type WorkspaceAttempt,
|
|
24
25
|
} from "./results.ts";
|
|
25
26
|
import { inspectWorkspace, type WorkspaceSnapshot, WorkspaceSnapshotSchema } from "./snapshot.ts";
|
|
@@ -27,25 +28,30 @@ import type { TmuxClient, TmuxWindow } from "./tmux.ts";
|
|
|
27
28
|
import { AttemptSchema } from "./tracking-attempt.ts";
|
|
28
29
|
import { ago, autoExitLine, stateLine } from "./tracking-text.ts";
|
|
29
30
|
import type { ReadingError } from "./usage.ts";
|
|
31
|
+
import { listWindows, ownWindow, windowState } from "./windows.ts";
|
|
30
32
|
import { WorkspaceSchema } from "./workspace.ts";
|
|
31
33
|
|
|
32
|
-
|
|
33
|
-
const SCAN = "message:scan";
|
|
34
|
-
/** How often the parent checks its running delegations. */
|
|
35
|
-
const POLL_MS = 5000;
|
|
34
|
+
export { listWindows, ownWindow, windowState };
|
|
36
35
|
|
|
37
36
|
/** What `src/index.ts` needs from tracking. */
|
|
38
37
|
export type Tracking = {
|
|
39
38
|
/** Register `delegation_status` and `delegation_close`. */
|
|
40
39
|
registerTools(): void;
|
|
41
|
-
/** Start the poll when something is running. */
|
|
40
|
+
/** Start the poll when something is running or a popup is open. */
|
|
42
41
|
startPolling(): void;
|
|
42
|
+
/** Remember a popup the parent opened; the poll closes it once its window is gone. Returns the call that forgets it when its process ends. */
|
|
43
|
+
trackPopup(popup: Popup): () => void;
|
|
44
|
+
/** Close one delegation as `delegation_close` does; throws as the tool does. */
|
|
45
|
+
close(id: string): Promise<CloseResult>;
|
|
43
46
|
/** On `session_start`: rebuild the records and restart the poll. */
|
|
44
47
|
restore(ctx: ExtensionContext): void;
|
|
45
48
|
/** On `session_shutdown`: stop the poll, and declare nothing more. */
|
|
46
49
|
stop(): void;
|
|
47
50
|
};
|
|
48
51
|
|
|
52
|
+
/** What closing one delegation did. */
|
|
53
|
+
export type CloseResult = { text: string; details: Static<typeof CloseSchema> };
|
|
54
|
+
|
|
49
55
|
/** The one result every tool returns. `details` doubles as `structuredContent`, so it must match the tool's `outputSchema`; Pi does not check it. */
|
|
50
56
|
export function toolResult(
|
|
51
57
|
payload: string,
|
|
@@ -74,6 +80,9 @@ const StartedSchema = Type.Object({
|
|
|
74
80
|
resumedFrom: Type.Optional(Type.String({ description: "The delegation whose session this one reopened." })),
|
|
75
81
|
windowId: Type.String(),
|
|
76
82
|
windowName: Type.String(),
|
|
83
|
+
windowSession: Type.Optional(
|
|
84
|
+
Type.String({ description: "The tmux session its window is in now; absent when the window is gone." }),
|
|
85
|
+
),
|
|
77
86
|
cwd: Type.Optional(
|
|
78
87
|
Type.String({ description: "The directory its window opened in; absent for records from before it was kept." }),
|
|
79
88
|
),
|
|
@@ -132,27 +141,6 @@ export type ToolDef = {
|
|
|
132
141
|
execute: (...args: never[]) => Promise<ReturnType<typeof toolResult>>;
|
|
133
142
|
};
|
|
134
143
|
|
|
135
|
-
/** Every window on the server, asked once; a failed ask is `undefined`, never an empty list. */
|
|
136
|
-
export async function listWindows(tmux: TmuxClient): Promise<TmuxWindow[] | undefined> {
|
|
137
|
-
try {
|
|
138
|
-
return await tmux.listWindows();
|
|
139
|
-
} catch {
|
|
140
|
-
return undefined;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** The window a delegation owns: one with both its recorded id and its recorded name, since ids restart with every tmux server. */
|
|
145
|
-
export function ownWindow(windows: TmuxWindow[], record: StartedRecorded): TmuxWindow | undefined {
|
|
146
|
-
return windows.find((window) => window.id === record.windowId && window.name === record.windowName);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** What the listing says about a delegation's window; a matching window whose program exited counts as gone. */
|
|
150
|
-
export function windowState(windows: TmuxWindow[] | undefined, record: StartedRecorded): WindowState {
|
|
151
|
-
if (windows === undefined) return "unknown";
|
|
152
|
-
const own = ownWindow(windows, record);
|
|
153
|
-
return own !== undefined && !own.exited ? "open" : "gone";
|
|
154
|
-
}
|
|
155
|
-
|
|
156
144
|
/** One delegation as `delegation_status` reports it; its text and details both read from this. */
|
|
157
145
|
type View = {
|
|
158
146
|
record: StartedRecorded;
|
|
@@ -165,6 +153,8 @@ type View = {
|
|
|
165
153
|
snapshot: WorkspaceSnapshot;
|
|
166
154
|
/** The delegation that resumed this one, if any. */
|
|
167
155
|
resumedAs: string | undefined;
|
|
156
|
+
/** The session its window is in now, looked up; undefined when the window is gone or unknown. */
|
|
157
|
+
windowSession: string | undefined;
|
|
168
158
|
};
|
|
169
159
|
|
|
170
160
|
/** The view of a delegation: its state and the fields the text and details share. */
|
|
@@ -185,11 +175,12 @@ function view(
|
|
|
185
175
|
}
|
|
186
176
|
}
|
|
187
177
|
const snapshot = inspectWorkspace(record.workspace?.cwd, record.workspace?.git);
|
|
188
|
-
|
|
178
|
+
const windowSession = windows === undefined ? undefined : ownWindow(windows, record)?.session;
|
|
179
|
+
return { record, state, name: recordedName(record), sessionPath, lastActive, snapshot, resumedAs, windowSession };
|
|
189
180
|
}
|
|
190
181
|
|
|
191
182
|
/** The text block for one delegation. */
|
|
192
|
-
function viewText({ record, state, name, sessionPath, lastActive, snapshot, resumedAs }: View): string {
|
|
183
|
+
function viewText({ record, state, name, sessionPath, lastActive, snapshot, resumedAs, windowSession }: View): string {
|
|
193
184
|
const [checkout, ...categories] = snapshotLines(snapshot);
|
|
194
185
|
const seen = lastActive === undefined ? "" : ` (last active ${ago(Date.now() - lastActive.getTime())})`;
|
|
195
186
|
const lines = [
|
|
@@ -198,7 +189,7 @@ function viewText({ record, state, name, sessionPath, lastActive, snapshot, resu
|
|
|
198
189
|
...(record.resumedFrom === undefined ? [] : [` resumed from ${record.resumedFrom}`]),
|
|
199
190
|
...(resumedAs === undefined ? [] : [` resumed as ${resumedAs}`]),
|
|
200
191
|
` state: ${stateLine(state, record)}${seen}`,
|
|
201
|
-
` window: ${record.windowName} (${record.windowId})`,
|
|
192
|
+
` window: ${record.windowName} (${record.windowId})${windowSession === undefined || windowSession === "" ? "" : ` in session ${windowSession}`}`,
|
|
202
193
|
` cwd: ${cwdLine(record.cwd)}`,
|
|
203
194
|
...(record.workspace === undefined ? [] : [` workspace: ${workspaceLine(record.workspace)}`]),
|
|
204
195
|
` checkout now: ${checkout}`,
|
|
@@ -211,7 +202,15 @@ function viewText({ record, state, name, sessionPath, lastActive, snapshot, resu
|
|
|
211
202
|
}
|
|
212
203
|
|
|
213
204
|
/** The structured details for one delegation. */
|
|
214
|
-
function viewDetails({
|
|
205
|
+
function viewDetails({
|
|
206
|
+
record,
|
|
207
|
+
state,
|
|
208
|
+
name,
|
|
209
|
+
sessionPath,
|
|
210
|
+
lastActive,
|
|
211
|
+
snapshot,
|
|
212
|
+
windowSession,
|
|
213
|
+
}: View): Record<string, unknown> {
|
|
215
214
|
return {
|
|
216
215
|
kind: "started",
|
|
217
216
|
id: record.id,
|
|
@@ -224,6 +223,7 @@ function viewDetails({ record, state, name, sessionPath, lastActive, snapshot }:
|
|
|
224
223
|
...(record.resumedFrom === undefined ? {} : { resumedFrom: record.resumedFrom }),
|
|
225
224
|
windowId: record.windowId,
|
|
226
225
|
windowName: record.windowName,
|
|
226
|
+
...(windowSession === undefined || windowSession === "" ? {} : { windowSession }),
|
|
227
227
|
...(record.cwd === undefined ? {} : { cwd: record.cwd }),
|
|
228
228
|
...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
|
|
229
229
|
...(record.workspace === undefined ? {} : { workspace: record.workspace }),
|
|
@@ -273,70 +273,6 @@ function selectRecords(results: Results, id: string | undefined): Recorded[] {
|
|
|
273
273
|
return records;
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
-
/** The poll over the records and tmux. `arm` readies it for a new session; `stop` ends it for good. */
|
|
277
|
-
function createPoll(
|
|
278
|
-
pi: ExtensionAPI,
|
|
279
|
-
results: Results,
|
|
280
|
-
tmux: TmuxClient,
|
|
281
|
-
): { start(): void; stop(): void; arm(): void } {
|
|
282
|
-
/** The poll timer; set only while some delegation is running. */
|
|
283
|
-
let poll: ReturnType<typeof setInterval> | undefined;
|
|
284
|
-
let ticking = false;
|
|
285
|
-
/** False once the session shuts down, so a tick still awaiting tmux declares nothing. */
|
|
286
|
-
let live = true;
|
|
287
|
-
|
|
288
|
-
function stopPolling(): void {
|
|
289
|
-
if (poll !== undefined) clearInterval(poll);
|
|
290
|
-
poll = undefined;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/** Record gone each running delegation whose window is missing or exited; a failed list declares nothing. */
|
|
294
|
-
function declareGone(windows: TmuxWindow[] | undefined): void {
|
|
295
|
-
if (windows === undefined) return;
|
|
296
|
-
for (const record of results.running()) {
|
|
297
|
-
if (windowState(windows, record) !== "gone" || !live) continue;
|
|
298
|
-
// A reply may have landed while the windows before this one were checked.
|
|
299
|
-
pi.events.emit(SCAN, {});
|
|
300
|
-
results.recordGone(record.id);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/** Claim waiting replies first, then declare gone every delegation whose window is missing or exited with still no result. */
|
|
305
|
-
async function tick(): Promise<void> {
|
|
306
|
-
if (ticking) return;
|
|
307
|
-
ticking = true;
|
|
308
|
-
try {
|
|
309
|
-
if (results.running().length === 0) return stopPolling();
|
|
310
|
-
const scan: { scanned?: boolean } = {};
|
|
311
|
-
pi.events.emit(SCAN, scan);
|
|
312
|
-
if (scan.scanned !== true) return; // no message:* provider: a waiting reply cannot be ruled out
|
|
313
|
-
declareGone(await listWindows(tmux)); // one ask per tick
|
|
314
|
-
if (results.running().length === 0) stopPolling();
|
|
315
|
-
} finally {
|
|
316
|
-
ticking = false;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/** Start the timer when something is running; it never keeps the process alive. */
|
|
321
|
-
function startPolling(): void {
|
|
322
|
-
if (poll !== undefined || results.running().length === 0) return;
|
|
323
|
-
poll = setInterval(() => void tick(), POLL_MS);
|
|
324
|
-
poll.unref?.();
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
return {
|
|
328
|
-
start: startPolling,
|
|
329
|
-
stop(): void {
|
|
330
|
-
live = false;
|
|
331
|
-
stopPolling();
|
|
332
|
-
},
|
|
333
|
-
arm(): void {
|
|
334
|
-
stopPolling();
|
|
335
|
-
live = true;
|
|
336
|
-
},
|
|
337
|
-
};
|
|
338
|
-
}
|
|
339
|
-
|
|
340
276
|
/** The `delegation_status` tool. */
|
|
341
277
|
function statusTool(results: Results, tmux: TmuxClient, readingErrors: () => ReadingError[]): ToolDef {
|
|
342
278
|
return {
|
|
@@ -394,6 +330,46 @@ function statusTool(results: Results, tmux: TmuxClient, readingErrors: () => Rea
|
|
|
394
330
|
};
|
|
395
331
|
}
|
|
396
332
|
|
|
333
|
+
/** Close one delegation: kill its window when it is open and record the close; throws when it cannot. */
|
|
334
|
+
export async function closeDelegation(results: Results, tmux: TmuxClient, id: string): Promise<CloseResult> {
|
|
335
|
+
const [record] = selectRecords(results, id);
|
|
336
|
+
if (!isStarted(record)) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
`delegation ${record.id} is a workspace attempt (${record.attempt.phase}): no verified window id was recorded; a window may still exist, so close is unavailable`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
const closed = (alreadyClosed: boolean, killed: boolean): CloseResult["details"] => ({
|
|
342
|
+
id: record.id,
|
|
343
|
+
windowId: record.windowId,
|
|
344
|
+
windowName: record.windowName,
|
|
345
|
+
closed: true as const,
|
|
346
|
+
alreadyClosed,
|
|
347
|
+
killed,
|
|
348
|
+
});
|
|
349
|
+
if (record.closed === true)
|
|
350
|
+
return { text: `Delegation ${record.id} is already closed.`, details: closed(true, false) };
|
|
351
|
+
let windows: TmuxWindow[];
|
|
352
|
+
try {
|
|
353
|
+
windows = await tmux.listWindows();
|
|
354
|
+
} catch (err) {
|
|
355
|
+
throw new Error(`could not ask tmux about window ${record.windowName}: ${(err as Error).message}`);
|
|
356
|
+
}
|
|
357
|
+
// Only a window with the recorded id and name is ours, exited program or not.
|
|
358
|
+
const alive = ownWindow(windows, record) !== undefined;
|
|
359
|
+
if (alive) {
|
|
360
|
+
try {
|
|
361
|
+
await tmux.kill(record.windowId);
|
|
362
|
+
} catch (err) {
|
|
363
|
+
throw new Error(`could not kill window ${record.windowName} (${record.windowId}): ${(err as Error).message}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
results.recordClose(record.id);
|
|
367
|
+
const how = alive
|
|
368
|
+
? `killed window ${record.windowName} (${record.windowId})`
|
|
369
|
+
: `its window ${record.windowName} was already gone`;
|
|
370
|
+
return { text: `Closed delegation ${record.id}: ${how}.`, details: closed(false, alive) };
|
|
371
|
+
}
|
|
372
|
+
|
|
397
373
|
/** The `delegation_close` tool. */
|
|
398
374
|
function closeTool(results: Results, tmux: TmuxClient): ToolDef {
|
|
399
375
|
return {
|
|
@@ -414,41 +390,8 @@ function closeTool(results: Results, tmux: TmuxClient): ToolDef {
|
|
|
414
390
|
_onUpdate: unknown,
|
|
415
391
|
_ctx: ExtensionContext,
|
|
416
392
|
): Promise<ReturnType<typeof toolResult>> {
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
throw new Error(
|
|
420
|
-
`delegation ${record.id} is a workspace attempt (${record.attempt.phase}): no verified window id was recorded; a window may still exist, so close is unavailable`,
|
|
421
|
-
);
|
|
422
|
-
}
|
|
423
|
-
const closed = (alreadyClosed: boolean, killed: boolean) => ({
|
|
424
|
-
id: record.id,
|
|
425
|
-
windowId: record.windowId,
|
|
426
|
-
windowName: record.windowName,
|
|
427
|
-
closed: true,
|
|
428
|
-
alreadyClosed,
|
|
429
|
-
killed,
|
|
430
|
-
});
|
|
431
|
-
if (record.closed === true) return toolResult(`Delegation ${record.id} is already closed.`, closed(true, false));
|
|
432
|
-
let windows: TmuxWindow[];
|
|
433
|
-
try {
|
|
434
|
-
windows = await tmux.listWindows();
|
|
435
|
-
} catch (err) {
|
|
436
|
-
throw new Error(`could not ask tmux about window ${record.windowName}: ${(err as Error).message}`);
|
|
437
|
-
}
|
|
438
|
-
// Only a window with the recorded id and name is ours, exited program or not.
|
|
439
|
-
const alive = ownWindow(windows, record) !== undefined;
|
|
440
|
-
if (alive) {
|
|
441
|
-
try {
|
|
442
|
-
await tmux.kill(record.windowId);
|
|
443
|
-
} catch (err) {
|
|
444
|
-
throw new Error(`could not kill window ${record.windowName} (${record.windowId}): ${(err as Error).message}`);
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
results.recordClose(record.id);
|
|
448
|
-
const how = alive
|
|
449
|
-
? `killed window ${record.windowName} (${record.windowId})`
|
|
450
|
-
: `its window ${record.windowName} was already gone`;
|
|
451
|
-
return toolResult(`Closed delegation ${record.id}: ${how}.`, closed(false, alive));
|
|
393
|
+
const { text, details } = await closeDelegation(results, tmux, params.id);
|
|
394
|
+
return toolResult(text, details);
|
|
452
395
|
},
|
|
453
396
|
};
|
|
454
397
|
}
|
|
@@ -467,6 +410,8 @@ export function createTracking(
|
|
|
467
410
|
pi.registerTool(closeTool(results, tmux) as never);
|
|
468
411
|
},
|
|
469
412
|
startPolling: poll.start,
|
|
413
|
+
trackPopup: poll.track,
|
|
414
|
+
close: (id: string): Promise<CloseResult> => closeDelegation(results, tmux, id),
|
|
470
415
|
restore(ctx: ExtensionContext): void {
|
|
471
416
|
poll.arm();
|
|
472
417
|
results.restore(ctx);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Call-time choice of where delegate windows open. `companion` (the default)
|
|
2
|
+
// opens them in a session of their own, `<parent's session>-squire`; `current`
|
|
3
|
+
// opens them in the parent's own session, as before. Read from
|
|
4
|
+
// `<agent dir>/squire/settings.json` at every launch, like `worktreeRoot`.
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
export type WindowPlacement = "companion" | "current";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The placement for this call. An unknown value refuses with the file named.
|
|
12
|
+
* A file that cannot be read or parsed is left to the worktree settings,
|
|
13
|
+
* which report it when a launch needs them: it does not stop the launches
|
|
14
|
+
* that never read it, so those keep the default.
|
|
15
|
+
*/
|
|
16
|
+
export function windowPlacement(agentDir: string): WindowPlacement {
|
|
17
|
+
const file = join(agentDir, "squire", "settings.json");
|
|
18
|
+
let parsed: unknown;
|
|
19
|
+
try {
|
|
20
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
21
|
+
} catch {
|
|
22
|
+
return "companion";
|
|
23
|
+
}
|
|
24
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return "companion";
|
|
25
|
+
const value = (parsed as Record<string, unknown>).windowPlacement;
|
|
26
|
+
if (value === undefined || value === "companion") return "companion";
|
|
27
|
+
if (value === "current") return "current";
|
|
28
|
+
throw new Error(
|
|
29
|
+
`invalid window placement settings ${file}: windowPlacement must be "companion" or "current", not ${JSON.stringify(value)}`,
|
|
30
|
+
);
|
|
31
|
+
}
|
package/src/windows.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Reading tmux's window list against the delegation records.
|
|
2
|
+
import type { StartedRecorded, WindowState } from "./results.ts";
|
|
3
|
+
import type { TmuxClient, TmuxWindow } from "./tmux.ts";
|
|
4
|
+
|
|
5
|
+
/** Every window on the server, asked once; a failed ask is `undefined`, never an empty list. */
|
|
6
|
+
export async function listWindows(tmux: TmuxClient): Promise<TmuxWindow[] | undefined> {
|
|
7
|
+
try {
|
|
8
|
+
return await tmux.listWindows();
|
|
9
|
+
} catch {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The window a delegation owns: one with both its recorded id and its recorded name, since ids restart with every tmux server. */
|
|
15
|
+
export function ownWindow(windows: TmuxWindow[], record: StartedRecorded): TmuxWindow | undefined {
|
|
16
|
+
return windows.find((window) => window.id === record.windowId && window.name === record.windowName);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** What the listing says about a delegation's window; a matching window whose program exited counts as gone. */
|
|
20
|
+
export function windowState(windows: TmuxWindow[] | undefined, record: StartedRecorded): WindowState {
|
|
21
|
+
if (windows === undefined) return "unknown";
|
|
22
|
+
const own = ownWindow(windows, record);
|
|
23
|
+
return own !== undefined && !own.exited ? "open" : "gone";
|
|
24
|
+
}
|
package/src/workspace.ts
CHANGED
|
@@ -119,7 +119,7 @@ export function normalizeWorkspaceValue(value: unknown, name: string): string |
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
/** The real path of an existing, accessible directory named by `value`, relative to `base`. */
|
|
122
|
-
function resolveDirectory(value: string, base: string): string {
|
|
122
|
+
export function resolveDirectory(value: string, base: string): string {
|
|
123
123
|
// Only a leading `~` or `~/`: no variables, globs or `~user`.
|
|
124
124
|
let expanded = value;
|
|
125
125
|
if (value === "~") expanded = homedir();
|