@evandu/super-pi 0.1.137 → 0.1.139
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/agents/super-pi-knowledge/index.ts +11 -13
- package/agents/super-pi-memory/index.ts +11 -5
- package/bin/super-pi.js +7 -11
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +6 -6
- package/dist/client/assets/{CodeMirrorEditor-CkA_5D5r.js → CodeMirrorEditor-H7SHGhdS.js} +2 -2
- package/dist/client/assets/{dist-DcdzfM-n.js → dist-2XXLO-IZ.js} +1 -1
- package/dist/client/assets/{dist-ufRHdQ8G.js → dist-BEM6tttY.js} +1 -1
- package/dist/client/assets/{dist-9Fm8XKu4.js → dist-BfUeRJKr.js} +1 -1
- package/dist/client/assets/{dist-CrxmpY93.js → dist-BokzcFLE.js} +1 -1
- package/dist/client/assets/{dist-BfEmujEC.js → dist-BoucVzKA.js} +1 -1
- package/dist/client/assets/{dist-B02Erd0t.js → dist-BxTGjU84.js} +1 -1
- package/dist/client/assets/{dist-DGhdkud4.js → dist-C-MUXK_t.js} +1 -1
- package/dist/client/assets/{dist-k6bOz9RS.js → dist-C8NllalC.js} +1 -1
- package/dist/client/assets/{dist-C2tSd-AY.js → dist-CSdzvQnf.js} +1 -1
- package/dist/client/assets/{dist-BySjislr.js → dist-CUNctZ5v.js} +1 -1
- package/dist/client/assets/{dist-B3Mfuv1D.js → dist-CZ9Krsno.js} +1 -1
- package/dist/client/assets/{dist-DbovsYeH.js → dist-C_ftUQZN.js} +1 -1
- package/dist/client/assets/{dist-j9X7j381.js → dist-CuMnZOGE.js} +1 -1
- package/dist/client/assets/{dist-D-LtG92p.js → dist-CukPKrEn.js} +1 -1
- package/dist/client/assets/{dist-b9CfAgzG.js → dist-D3Ta0y4e.js} +1 -1
- package/dist/client/assets/{dist-BsqzrZ5R.js → dist-DJ8SwQl_.js} +1 -1
- package/dist/client/assets/{dist-BqGjijI6.js → dist-DLKVFZhO.js} +1 -1
- package/dist/client/assets/{dist-MC2XuZgq.js → dist-Jw-GQ-re.js} +1 -1
- package/dist/client/assets/{dist-D_umYm0o.js → dist-NlopeJ5R.js} +1 -1
- package/dist/client/assets/{dist-CsNytPug.js → dist-POoQ4j29.js} +1 -1
- package/dist/client/assets/{dist-DdVs8Y0X.js → dist-cP2vnTP_.js} +1 -1
- package/dist/client/assets/{dist-DL4Q05ts.js → dist-zUJqdbfo.js} +1 -1
- package/dist/client/assets/{index-DVmE8H4Y.js → index-B32VZVYx.js} +13 -13
- package/dist/client/assets/index-Bdym8BPo.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/conversation-service.d.ts +13 -4
- package/dist/server/application/conversation-service.js +112 -61
- package/dist/server/application/run-service.d.ts +13 -0
- package/dist/server/application/run-service.js +255 -69
- package/dist/server/application/scheduled-task-service.js +8 -1
- package/dist/server/application/workspace-service.js +16 -1
- package/dist/server/feishu-cli.js +15 -4
- package/dist/server/file-index.d.ts +30 -0
- package/dist/server/file-index.js +49 -4
- package/dist/server/files.d.ts +21 -0
- package/dist/server/files.js +38 -0
- package/dist/server/http-server.js +1455 -1412
- package/dist/server/index.js +3 -4
- package/dist/server/logging.js +56 -3
- package/dist/server/pi-session-pool.d.ts +2 -0
- package/dist/server/pi-session-pool.js +1 -0
- package/dist/server/pi-session.d.ts +118 -0
- package/dist/server/pi-session.js +132 -10
- package/dist/server/runtime.d.ts +47 -0
- package/dist/server/runtime.js +183 -84
- package/dist/server/store.d.ts +44 -1
- package/dist/server/store.js +114 -12
- package/dist/server/subagent-runs.js +18 -7
- package/dist/server/subagents.js +7 -0
- package/dist/server/tool-runner.js +13 -4
- package/dist/server/types.d.ts +27 -0
- package/dist/server/ws-server.d.ts +34 -5
- package/dist/server/ws-server.js +170 -46
- package/package.json +15 -1
- package/scripts/diagnose.mjs +161 -58
- package/dist/client/assets/index-FxTrUapG.css +0 -1
|
@@ -219,7 +219,14 @@ export class ScheduledTaskService {
|
|
|
219
219
|
if (this.stopped)
|
|
220
220
|
return;
|
|
221
221
|
const now = Date.now();
|
|
222
|
-
|
|
222
|
+
// READ THROUGH THE NARROW READER, NOT THROUGH A CLONE OF THE WHOLE APPLICATION.
|
|
223
|
+
//
|
|
224
|
+
// This sweep runs every fifteen seconds for the whole life of the server. It used to read its
|
|
225
|
+
// tasks through `store.snapshot()` - a deep clone of every workspace, every conversation and every
|
|
226
|
+
// message of every conversation - to find the zero-to-three tasks that are due. On a server
|
|
227
|
+
// holding a long transcript that is a full copy of the app four times a minute, for a list that is
|
|
228
|
+
// usually empty. The narrow reader returns the tasks themselves and nothing else.
|
|
229
|
+
const due = this.store.scheduledTasks().filter((task) => task.enabled && task.nextRunAt !== undefined && task.nextRunAt <= now);
|
|
223
230
|
for (const task of due) {
|
|
224
231
|
if (this.inFlight.has(task.id))
|
|
225
232
|
continue;
|
|
@@ -108,7 +108,22 @@ export class WorkspaceService {
|
|
|
108
108
|
agentToStore = agent.id;
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
|
-
|
|
111
|
+
// THE MODEL RULES HOLD THE MODEL THE USER JUST SET. The check is on `selection` ALONE, and
|
|
112
|
+
// `selection` is undefined when the update did not touch the model at all - which is a
|
|
113
|
+
// DIFFERENT thing from 「the model is Pi's default」.
|
|
114
|
+
//
|
|
115
|
+
// It used to read `selection ?? (await this.runtime.catalog()).defaultModel`, so changing the
|
|
116
|
+
// THINKING LEVEL - a setting that has nothing to do with models - was checked against Pi's own
|
|
117
|
+
// default model, and a workspace whose rules allow only its own default could not change its
|
|
118
|
+
// thinking level at all. The refusal even named a model the user had never chosen: 「当前模型
|
|
119
|
+
// 「Pi 默认 (prov/model-zzz)」不匹配允许的模型正则」.
|
|
120
|
+
//
|
|
121
|
+
// What the rules have to hold is the model this workspace's conversations will RUN ON, and the
|
|
122
|
+
// only model the user could have set here is `selection`. When the update does not name a model,
|
|
123
|
+
// the workspace's existing default (or the absence of one) is what stands, and that is checked
|
|
124
|
+
// where it is stamped onto a conversation - at birth, in `conversation-service.ts`.
|
|
125
|
+
if (selection)
|
|
126
|
+
assertModelAllowed(this.store.modelFilterRules(), workspace, selection);
|
|
112
127
|
await this.store.setWorkspaceDefaults(workspaceId, {
|
|
113
128
|
model: selection,
|
|
114
129
|
thinkingLevel: settings.thinkingLevel ?? undefined,
|
|
@@ -60,8 +60,15 @@ const defaultSpawn = (command, args, options) => new Promise((resolve, reject) =
|
|
|
60
60
|
});
|
|
61
61
|
let stdout = "";
|
|
62
62
|
let stderr = "";
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
// A PIPE CARRIES BYTES, AND A READ BOUNDARY CAN LAND INSIDE A CHARACTER. A Chinese character is
|
|
64
|
+
// three bytes, so decoding one chunk at a time turns every character that straddles a boundary
|
|
65
|
+
// into U+FFFD - which for this CLI means a JSON envelope whose text is damaged, and an envelope
|
|
66
|
+
// that may not parse at all. The stream decodes instead, and Node keeps an incomplete
|
|
67
|
+
// character until the rest of its bytes arrive.
|
|
68
|
+
child.stdout?.setEncoding("utf8");
|
|
69
|
+
child.stderr?.setEncoding("utf8");
|
|
70
|
+
child.stdout?.on("data", (chunk) => { stdout += chunk; });
|
|
71
|
+
child.stderr?.on("data", (chunk) => { stderr += chunk; });
|
|
65
72
|
const timer = setTimeout(() => { child.kill(); }, options.timeoutMs);
|
|
66
73
|
child.once("error", (error) => {
|
|
67
74
|
clearTimeout(timer);
|
|
@@ -91,8 +98,12 @@ const defaultBackgroundSpawn = (command, args, options) => {
|
|
|
91
98
|
});
|
|
92
99
|
return {
|
|
93
100
|
onOutput: (listener) => {
|
|
94
|
-
|
|
95
|
-
|
|
101
|
+
// The same rule as the one-shot spawn: the stream decodes, so a character that straddles a
|
|
102
|
+
// read boundary stays whole.
|
|
103
|
+
child.stdout?.setEncoding("utf8");
|
|
104
|
+
child.stderr?.setEncoding("utf8");
|
|
105
|
+
child.stdout?.on("data", (chunk) => listener(chunk));
|
|
106
|
+
child.stderr?.on("data", (chunk) => listener(chunk));
|
|
96
107
|
},
|
|
97
108
|
done: new Promise((resolve) => {
|
|
98
109
|
child.once("close", () => resolve());
|
|
@@ -46,6 +46,19 @@ export declare class FileIndex {
|
|
|
46
46
|
start(root: string): void;
|
|
47
47
|
stop(): void;
|
|
48
48
|
snapshot(since?: number): FileIndexSnapshot;
|
|
49
|
+
/**
|
|
50
|
+
* ONE FILE, REFRESHED.
|
|
51
|
+
*
|
|
52
|
+
* The path arrives from a MODEL - from the arguments of the `write`/`edit` tool - so it may be
|
|
53
|
+
* absolute, and an absolute path inside the workspace is the same file as its relative spelling.
|
|
54
|
+
* `toWorkspaceRelative` translates that spelling; the guard then decides containment, exactly as it
|
|
55
|
+
* does for every other path in this module.
|
|
56
|
+
*
|
|
57
|
+
* A FILE THAT IS GONE IS NOT A FAILURE. `ENOENT` means the agent deleted it, and the index says so
|
|
58
|
+
* by removing it. Anything else is a real problem and is thrown to the caller - which is why the
|
|
59
|
+
* callers must handle it (see `refreshWorkspaceIndex` in `run-service.ts`): a background index
|
|
60
|
+
* refresh must never take a turn down with it.
|
|
61
|
+
*/
|
|
49
62
|
refresh(path: string): Promise<void>;
|
|
50
63
|
/** Record metadata already obtained by opening, saving, or polling an open file. No second stat: the
|
|
51
64
|
* caller's file API operation has already passed the sole containment guard and read this metadata. */
|
|
@@ -54,3 +67,20 @@ export declare class FileIndex {
|
|
|
54
67
|
private scan;
|
|
55
68
|
private publish;
|
|
56
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* REFRESH THE WORKSPACE'S FILE INDEX AFTER THE AGENT WROTE SOMETHING - AND NEVER AT THE COST OF THE
|
|
72
|
+
* TURN.
|
|
73
|
+
*
|
|
74
|
+
* WHY THIS IS A FUNCTION AND NOT A `void` CALL. `FileIndex.refresh` refuses a path it cannot contain,
|
|
75
|
+
* and a refusal is a REJECTED PROMISE. Calling it with `void` and nothing else leaves that rejection
|
|
76
|
+
* without a handler, and Node reports an unhandled rejection - which this server used to log as a
|
|
77
|
+
* `fatal` line. That is exactly what happened in the field: one absolute-path write per agent edit
|
|
78
|
+
* produced one `[fatal] unhandledRejection: FilesError` in the server log - a log that reads like a
|
|
79
|
+
* crash report and is not one - while the Files panel went on showing the file the agent had just
|
|
80
|
+
* written as if it had never been touched.
|
|
81
|
+
*
|
|
82
|
+
* A REFUSED PATH IS A FACT ABOUT THE PATH, NOT A FAULT OF THE TURN. So the refusal is caught here,
|
|
83
|
+
* said out loud, and the turn carries on. The index is a convenience; it must never be able to end a
|
|
84
|
+
* conversation.
|
|
85
|
+
*/
|
|
86
|
+
export declare function refreshWorkspaceIndex(indexes: Map<string, FileIndex> | undefined, workspaceId: string, path: string): void;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readdir, stat } from "node:fs/promises";
|
|
2
|
-
import { SEARCH_SKIP_DIRS, createRootGuard, fileMeta, isTextName, toRelative } from "./files.js";
|
|
2
|
+
import { SEARCH_SKIP_DIRS, createRootGuard, fileMeta, isTextName, toRelative, toWorkspaceRelative } from "./files.js";
|
|
3
3
|
const MAX_DIRECTORIES = 4000;
|
|
4
4
|
const MAX_FILES = 30000;
|
|
5
5
|
const MAX_MS = 4000;
|
|
@@ -41,14 +41,35 @@ export class FileIndex {
|
|
|
41
41
|
changed: valid ? deltas.flatMap((item) => item.changed) : [...this.entries.values()],
|
|
42
42
|
removed: valid ? deltas.flatMap((item) => item.removed) : [] };
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* ONE FILE, REFRESHED.
|
|
46
|
+
*
|
|
47
|
+
* The path arrives from a MODEL - from the arguments of the `write`/`edit` tool - so it may be
|
|
48
|
+
* absolute, and an absolute path inside the workspace is the same file as its relative spelling.
|
|
49
|
+
* `toWorkspaceRelative` translates that spelling; the guard then decides containment, exactly as it
|
|
50
|
+
* does for every other path in this module.
|
|
51
|
+
*
|
|
52
|
+
* A FILE THAT IS GONE IS NOT A FAILURE. `ENOENT` means the agent deleted it, and the index says so
|
|
53
|
+
* by removing it. Anything else is a real problem and is thrown to the caller - which is why the
|
|
54
|
+
* callers must handle it (see `refreshWorkspaceIndex` in `run-service.ts`): a background index
|
|
55
|
+
* refresh must never take a turn down with it.
|
|
56
|
+
*/
|
|
44
57
|
async refresh(path) {
|
|
45
58
|
if (!this.root)
|
|
46
59
|
return;
|
|
47
60
|
const guard = createRootGuard(this.root);
|
|
61
|
+
const requested = toWorkspaceRelative(this.root, path);
|
|
62
|
+
// The key the index stores paths under. It starts as the translated path and is replaced by the
|
|
63
|
+
// canonical relative form once the guard has resolved it, so a removed file is removed under the
|
|
64
|
+
// name the index actually knows it by.
|
|
65
|
+
let key = requested;
|
|
48
66
|
try {
|
|
49
|
-
const absolute = await guard(
|
|
50
|
-
|
|
67
|
+
const absolute = await guard(requested);
|
|
68
|
+
// The canonical relative name is settled BEFORE the stat, so a file that is already gone is
|
|
69
|
+
// still removed under the name the index knows it by (`src/a.ts`, not `src\a.ts`).
|
|
51
70
|
const rel = toRelative(this.root, absolute);
|
|
71
|
+
key = rel;
|
|
72
|
+
const info = await stat(absolute);
|
|
52
73
|
if (info.isDirectory())
|
|
53
74
|
this.publish([{ path: rel, kind: "directory", size: null, mtimeMs: null, etag: null, text: null }], []);
|
|
54
75
|
else {
|
|
@@ -59,7 +80,7 @@ export class FileIndex {
|
|
|
59
80
|
catch (error) {
|
|
60
81
|
const code = error.code;
|
|
61
82
|
if (code === "ENOENT" || code === "ENOTDIR")
|
|
62
|
-
this.remove(
|
|
83
|
+
this.remove(key);
|
|
63
84
|
else
|
|
64
85
|
throw error;
|
|
65
86
|
}
|
|
@@ -148,3 +169,27 @@ export class FileIndex {
|
|
|
148
169
|
this.broadcast(event);
|
|
149
170
|
}
|
|
150
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* REFRESH THE WORKSPACE'S FILE INDEX AFTER THE AGENT WROTE SOMETHING - AND NEVER AT THE COST OF THE
|
|
174
|
+
* TURN.
|
|
175
|
+
*
|
|
176
|
+
* WHY THIS IS A FUNCTION AND NOT A `void` CALL. `FileIndex.refresh` refuses a path it cannot contain,
|
|
177
|
+
* and a refusal is a REJECTED PROMISE. Calling it with `void` and nothing else leaves that rejection
|
|
178
|
+
* without a handler, and Node reports an unhandled rejection - which this server used to log as a
|
|
179
|
+
* `fatal` line. That is exactly what happened in the field: one absolute-path write per agent edit
|
|
180
|
+
* produced one `[fatal] unhandledRejection: FilesError` in the server log - a log that reads like a
|
|
181
|
+
* crash report and is not one - while the Files panel went on showing the file the agent had just
|
|
182
|
+
* written as if it had never been touched.
|
|
183
|
+
*
|
|
184
|
+
* A REFUSED PATH IS A FACT ABOUT THE PATH, NOT A FAULT OF THE TURN. So the refusal is caught here,
|
|
185
|
+
* said out loud, and the turn carries on. The index is a convenience; it must never be able to end a
|
|
186
|
+
* conversation.
|
|
187
|
+
*/
|
|
188
|
+
export function refreshWorkspaceIndex(indexes, workspaceId, path) {
|
|
189
|
+
const index = indexes?.get(workspaceId);
|
|
190
|
+
if (!index)
|
|
191
|
+
return;
|
|
192
|
+
void index.refresh(path).catch((error) => {
|
|
193
|
+
console.warn(`Super Pi: the file index did not refresh ${path}: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
194
|
+
});
|
|
195
|
+
}
|
package/dist/server/files.d.ts
CHANGED
|
@@ -158,6 +158,27 @@ export type DirListing = {
|
|
|
158
158
|
};
|
|
159
159
|
/** The `/`-separated relative form used on the wire and in every comparison. */
|
|
160
160
|
export declare function toRelative(root: string, absolute: string): string;
|
|
161
|
+
/**
|
|
162
|
+
* A PATH A MODEL WROTE, AS THE WORKSPACE SPELLS IT.
|
|
163
|
+
*
|
|
164
|
+
* WHO SPELLS PATHS THIS WAY. Pi's `write`/`edit` tools carry whatever path the MODEL produced, and a
|
|
165
|
+
* model that has been told where the workspace is writes an absolute path: `/home/me/project/src/a.ts`
|
|
166
|
+
* is the same file as `src/a.ts`. The model is not trying to escape anything - it is repeating the
|
|
167
|
+
* workspace root it was given.
|
|
168
|
+
*
|
|
169
|
+
* WHY THIS IS HERE AND NOT IN THE CALLER. Two layers need the same translation and neither may own
|
|
170
|
+
* it: the file-change tracker (which records what the agent wrote) and the file index (which refreshes
|
|
171
|
+
* what the agent wrote). Before this function existed, the index refused an absolute path outright -
|
|
172
|
+
* and because the refusal was thrown out of a promise nobody was awaiting, one absolute-path write
|
|
173
|
+
* produced one `[fatal] unhandledRejection` in the server log and left the Files panel showing a file
|
|
174
|
+
* the agent had just written as if it had never been touched.
|
|
175
|
+
*
|
|
176
|
+
* AN ABSOLUTE PATH OUTSIDE THE WORKSPACE IS REFUSED, EXACTLY AS `../` IS REFUSED. The containment rule
|
|
177
|
+
* belongs to this module and has exactly one owner; this function does not get a second, weaker copy of
|
|
178
|
+
* it. It translates a spelling and then hands the result to the guard, which is what actually decides
|
|
179
|
+
* containment - including the symlink case, which a string comparison can never see.
|
|
180
|
+
*/
|
|
181
|
+
export declare function toWorkspaceRelative(root: string, requested: string): string;
|
|
161
182
|
/** One directory's contents. Directories sort before files, then case-insensitively by name with numeric
|
|
162
183
|
* ordering, so `src2` lands between `src` and `src10` rather than after every `srcN`. */
|
|
163
184
|
export declare function listDirectory(root: string, requested: string | null | undefined): Promise<DirListing>;
|
package/dist/server/files.js
CHANGED
|
@@ -396,6 +396,44 @@ export function isDocumentName(name) {
|
|
|
396
396
|
export function toRelative(root, absolute) {
|
|
397
397
|
return relative(root, absolute).split(sep).join("/");
|
|
398
398
|
}
|
|
399
|
+
/**
|
|
400
|
+
* A PATH A MODEL WROTE, AS THE WORKSPACE SPELLS IT.
|
|
401
|
+
*
|
|
402
|
+
* WHO SPELLS PATHS THIS WAY. Pi's `write`/`edit` tools carry whatever path the MODEL produced, and a
|
|
403
|
+
* model that has been told where the workspace is writes an absolute path: `/home/me/project/src/a.ts`
|
|
404
|
+
* is the same file as `src/a.ts`. The model is not trying to escape anything - it is repeating the
|
|
405
|
+
* workspace root it was given.
|
|
406
|
+
*
|
|
407
|
+
* WHY THIS IS HERE AND NOT IN THE CALLER. Two layers need the same translation and neither may own
|
|
408
|
+
* it: the file-change tracker (which records what the agent wrote) and the file index (which refreshes
|
|
409
|
+
* what the agent wrote). Before this function existed, the index refused an absolute path outright -
|
|
410
|
+
* and because the refusal was thrown out of a promise nobody was awaiting, one absolute-path write
|
|
411
|
+
* produced one `[fatal] unhandledRejection` in the server log and left the Files panel showing a file
|
|
412
|
+
* the agent had just written as if it had never been touched.
|
|
413
|
+
*
|
|
414
|
+
* AN ABSOLUTE PATH OUTSIDE THE WORKSPACE IS REFUSED, EXACTLY AS `../` IS REFUSED. The containment rule
|
|
415
|
+
* belongs to this module and has exactly one owner; this function does not get a second, weaker copy of
|
|
416
|
+
* it. It translates a spelling and then hands the result to the guard, which is what actually decides
|
|
417
|
+
* containment - including the symlink case, which a string comparison can never see.
|
|
418
|
+
*/
|
|
419
|
+
export function toWorkspaceRelative(root, requested) {
|
|
420
|
+
const raw = requested.trim();
|
|
421
|
+
// A RELATIVE PATH IS ALREADY THE WORKSPACE'S SPELLING. It is passed through untouched so that the
|
|
422
|
+
// guard sees exactly what the caller asked for - including `../`, which the guard is there to refuse.
|
|
423
|
+
if (!isAbsolute(raw))
|
|
424
|
+
return raw;
|
|
425
|
+
const base = resolve(root);
|
|
426
|
+
const rel = relative(base, resolve(raw));
|
|
427
|
+
if (rel === "")
|
|
428
|
+
return "";
|
|
429
|
+
// `relative` answers "what is left to walk from base to target". When the target is on another drive
|
|
430
|
+
// (Windows) or outside the root (anywhere), the answer is an absolute path or a chain of `..`, and
|
|
431
|
+
// both mean the same thing: this path is not inside the workspace.
|
|
432
|
+
if (isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`) || rel.startsWith("../")) {
|
|
433
|
+
throw new FilesError(403, "拒绝绝对路径:请给出相对于工作区根目录的路径", "outside_workspace");
|
|
434
|
+
}
|
|
435
|
+
return rel.split(sep).join("/");
|
|
436
|
+
}
|
|
399
437
|
/** One directory's contents. Directories sort before files, then case-insensitively by name with numeric
|
|
400
438
|
* ordering, so `src2` lands between `src` and `src10` rather than after every `srcN`. */
|
|
401
439
|
export async function listDirectory(root, requested) {
|