@assuredloop/orca-bot-kit 0.26.1 → 0.27.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/README.md +20 -26
- package/package.json +1 -1
- package/rules/mail.md +5 -5
- package/src/book.js +55 -16
- package/src/cli.js +40 -102
- package/src/commands.js +1 -3
- package/src/conversations.js +0 -37
- package/src/health.js +62 -5
- package/src/hooks.js +35 -63
- package/src/launch.js +20 -3
- package/src/message.js +42 -334
- package/src/name.js +1 -1
- package/src/orca.js +19 -35
- package/src/processes.js +392 -0
- package/src/retire.js +43 -40
- package/src/skills.js +1 -1
- package/src/up.js +13 -2
- package/src/unread.js +0 -160
package/src/health.js
CHANGED
|
@@ -18,22 +18,23 @@
|
|
|
18
18
|
// is, the one thing to go and look at, and a sentence naming it — so that
|
|
19
19
|
// somebody who cannot read code knows what was found and where.
|
|
20
20
|
|
|
21
|
-
import { existsSync, readdirSync, realpathSync } from 'node:fs';
|
|
21
|
+
import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
|
|
24
24
|
import { bookFile, readBook, sessionIdsIn, tabIdsIn } from './book.js';
|
|
25
25
|
import { botDir, botNames, botsDir, readBot, tempRoles, unknownKeys } from './bot.js';
|
|
26
26
|
import { transcriptsIn } from './conversations.js';
|
|
27
27
|
import { hookTrouble } from './hooks.js';
|
|
28
|
-
import { APPROVALS, bypassFlags, harnessOf, HARNESSES, isAddressOf, ownCli, sessionTrouble, SHELL_ENV, shellWord } from './launch.js';
|
|
28
|
+
import { APPROVALS, bypassFlags, harnessOf, HARNESSES, isAddressOf, ownCli, sessionTrouble, SHELL_ENV, shellWord, workDirOf } from './launch.js';
|
|
29
29
|
import { frontOfTab, orcaDefaultArgs, projects, runMissing, tabs, wordsOfProcess } from './orca.js';
|
|
30
30
|
import { permissionsTrouble } from './permissions.js';
|
|
31
|
+
import { processesIn } from './processes.js';
|
|
31
32
|
import { TAB_ENV } from './record.js';
|
|
32
33
|
import { agentsTrouble, rulesStamp } from './rules.js';
|
|
33
34
|
import { settingsInUse } from './settings.js';
|
|
34
35
|
import { skillsTrouble } from './skills.js';
|
|
35
36
|
import { readSources, sourcesDir } from './sources.js';
|
|
36
|
-
import { BOT_FATHER, botsNamed, promptPath } from './up.js';
|
|
37
|
+
import { BOT_FATHER, botsNamed, oldPromptPath, promptPath } from './up.js';
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* Everything wrong with the setup at `bots`, in one list: what Orca's own
|
|
@@ -141,6 +142,9 @@ function leftBeside(bots) {
|
|
|
141
142
|
try {
|
|
142
143
|
for (const session of readBot(botDir(bots, name), name).sessions) {
|
|
143
144
|
owned.add(promptPath(bots, name, session.name));
|
|
145
|
+
// Where a live session's prompt was before #534: nothing reads it once
|
|
146
|
+
// the session starts again, and its retire removes it.
|
|
147
|
+
owned.add(oldPromptPath(bots, name, session.name));
|
|
144
148
|
}
|
|
145
149
|
} catch {
|
|
146
150
|
known = false;
|
|
@@ -148,8 +152,7 @@ function leftBeside(bots) {
|
|
|
148
152
|
}
|
|
149
153
|
|
|
150
154
|
if (known) {
|
|
151
|
-
for (const
|
|
152
|
-
const at = path.join(`${bots}.prompts`, file);
|
|
155
|
+
for (const at of promptFilesIn(`${bots}.prompts`)) {
|
|
153
156
|
if (owned.has(at)) continue;
|
|
154
157
|
found.push(finding('leftover', at, `${at} is a start prompt the kit wrote beside your bots folder, and no session of any bot answers to it now. Nothing reads it.`));
|
|
155
158
|
}
|
|
@@ -196,9 +199,48 @@ function aboutBot(bots, name, setups, sessions) {
|
|
|
196
199
|
...permissionsTrouble(home, bot).map(said('config')),
|
|
197
200
|
...skillsTrouble(bots, home, bot).map(said('skill')),
|
|
198
201
|
...inOrca(bots, home, bot, setups, sessions),
|
|
202
|
+
...leftRunning(home, bot),
|
|
199
203
|
];
|
|
200
204
|
}
|
|
201
205
|
|
|
206
|
+
/**
|
|
207
|
+
* What still runs in the work dir of a retired session (#537): a retire stops
|
|
208
|
+
* it, so this is one from before that, or one a retire could not stop. The
|
|
209
|
+
* book keeps a retired session's work dir; a temporary session retired before
|
|
210
|
+
* it did worked in `work/<name>`, where `obk temp make` puts it. A work dir a
|
|
211
|
+
* session in bot.yaml still uses is that session's, and not looked at, by its
|
|
212
|
+
* real path, so a link to it is not taken for a retired one (review of PR #543).
|
|
213
|
+
*/
|
|
214
|
+
function leftRunning(home, bot) {
|
|
215
|
+
let book;
|
|
216
|
+
try {
|
|
217
|
+
book = readBook(home);
|
|
218
|
+
} catch {
|
|
219
|
+
// A book that cannot be read is said where the book is read.
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
const real = (dir) => (dir === undefined ? undefined : realpathOf(dir));
|
|
223
|
+
const live = new Set(bot.sessions.map((session) => real(workDirOf(session, home))));
|
|
224
|
+
const dirs = [];
|
|
225
|
+
const seen = new Set();
|
|
226
|
+
for (const entry of Array.isArray(book.retired) ? book.retired : []) {
|
|
227
|
+
const named = typeof entry?.work_dir === 'string' && entry.work_dir !== '' ? entry.work_dir : undefined;
|
|
228
|
+
const dir = workDirOf({ work_dir: named ?? (entry?.temporary === undefined ? undefined : `work/${entry.name}`) }, home);
|
|
229
|
+
// One that is not there holds nothing.
|
|
230
|
+
const at = real(dir);
|
|
231
|
+
if (at === undefined || live.has(at) || seen.has(at)) continue;
|
|
232
|
+
seen.add(at);
|
|
233
|
+
dirs.push({ session: entry.name, dir });
|
|
234
|
+
}
|
|
235
|
+
if (dirs.length === 0) return [];
|
|
236
|
+
|
|
237
|
+
const read = processesIn(dirs);
|
|
238
|
+
if (read.unreadable !== undefined) {
|
|
239
|
+
return [finding('process', bookFile(home), `The kit cannot tell whether anything still runs in the work dirs of ${bot.name}'s retired sessions: ${read.unreadable}.`, bot.name)];
|
|
240
|
+
}
|
|
241
|
+
return read.running.map((one) => finding('process', one.dir, `pid ${one.pid} still runs in the work dir of ${bot.name}/${one.session}, which was retired: ${one.command}. Nothing of the kit's stops it now. If it is not wanted, stop it yourself: kill ${one.pid}`, bot.name));
|
|
242
|
+
}
|
|
243
|
+
|
|
202
244
|
/** A session the kit would refuse to start, in the words `up` refuses it with. */
|
|
203
245
|
function sessionSettings(home, bot) {
|
|
204
246
|
const file = path.join(home, 'bot.yaml');
|
|
@@ -461,6 +503,21 @@ function recordIn(home, bot) {
|
|
|
461
503
|
}
|
|
462
504
|
|
|
463
505
|
/** What a directory holds, and nothing at all when there is no directory. */
|
|
506
|
+
/**
|
|
507
|
+
* The files in the kit's folder of start prompts: in each bot's own folder
|
|
508
|
+
* there (#534), and flat in the folder itself, where they were before.
|
|
509
|
+
*/
|
|
510
|
+
function promptFilesIn(dir) {
|
|
511
|
+
return namesIn(dir).flatMap((name) => {
|
|
512
|
+
const at = path.join(dir, name);
|
|
513
|
+
try {
|
|
514
|
+
return statSync(at).isDirectory() ? namesIn(at).map((file) => path.join(at, file)) : [at];
|
|
515
|
+
} catch {
|
|
516
|
+
return [];
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
464
521
|
function namesIn(dir) {
|
|
465
522
|
try {
|
|
466
523
|
return readdirSync(dir);
|
package/src/hooks.js
CHANGED
|
@@ -58,36 +58,7 @@ export const sentCommand = (bots, bot, cli = ownCli()) =>
|
|
|
58
58
|
`${shellWord(cli)} session sent --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
|
-
*
|
|
62
|
-
* about fleet mail the kit sent it that is still unread (#509, ADR 0035).
|
|
63
|
-
* Claude Code only: on Codex a new hook entry puts every session on "Hooks
|
|
64
|
-
* need review", and that waits for #511. It asks Orca only when the kit's hint
|
|
65
|
-
* holds mail for the session, and gives Orca at most ten seconds of its thirty.
|
|
66
|
-
*/
|
|
67
|
-
const MAIL_EVENT = 'Stop';
|
|
68
|
-
const MAIL_TIMEOUT = 30;
|
|
69
|
-
|
|
70
|
-
/** What the mail hook runs, by the kit's own path, as `hookCommand` does. */
|
|
71
|
-
export const mailCommand = (bots, bot, cli = ownCli()) =>
|
|
72
|
-
`${shellWord(cli)} session mail --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Codex's second kit hook: after each command its shell tool ran, outside
|
|
76
|
-
* Codex's sandbox, it decides the mail nudges a send in that command could not
|
|
77
|
-
* (#350, ADR 0034). Codex only, since only Codex runs its commands where `ps`
|
|
78
|
-
* does not start. It types into another session's tab, which takes Orca a few
|
|
79
|
-
* seconds a nudge, so it is given longer than the others.
|
|
80
|
-
*/
|
|
81
|
-
const NUDGE_EVENT = 'PostToolUse';
|
|
82
|
-
const NUDGE_MATCHER = 'Bash';
|
|
83
|
-
const NUDGE_TIMEOUT = 30;
|
|
84
|
-
|
|
85
|
-
/** What the nudge hook runs, by the kit's own path, as `hookCommand` does. */
|
|
86
|
-
export const nudgeCommand = (bots, bot, cli = ownCli()) =>
|
|
87
|
-
`${shellWord(cli)} session nudge --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Codex's third kit hook: at each turn end, the session's thread is named after
|
|
61
|
+
* Codex's second kit hook: at each turn end, the session's thread is named after
|
|
91
62
|
* its bot and session, so its tab says which it is (#480). Async, so the turn
|
|
92
63
|
* ends without waiting on it: it waits for the session to be idle, up to 30 s,
|
|
93
64
|
* then types and confirms. A naming took 73 s live, each character waiting on
|
|
@@ -123,7 +94,9 @@ export function installHook(home, harness, { bots, bot }) {
|
|
|
123
94
|
// longer asks about is taken out, and a hook of the user's beside the kit's
|
|
124
95
|
// stays where they put it.
|
|
125
96
|
const started = withKitHook(settings.hooks, file, mine);
|
|
126
|
-
|
|
97
|
+
// The Claude Code mail hook and the Codex nudge hook went with the kit's
|
|
98
|
+
// typed line (#555), so an entry of either is taken out.
|
|
99
|
+
const wanted = { ...settings, hooks: toolHooksOf(harness, bots, bot).reduce(withToolHook, withoutHooks(started, [KIT_MAIL, KIT_NUDGE])) };
|
|
127
100
|
|
|
128
101
|
// Compared as documents, not as text: how the user laid their file out is
|
|
129
102
|
// theirs, and a run that changes nothing writes nothing.
|
|
@@ -171,9 +144,8 @@ export function hookTrouble(home, harness, { bots, bot }) {
|
|
|
171
144
|
const program = running ?? ownCli();
|
|
172
145
|
wanted = { ...settings, hooks: withKitHook(settings.hooks, file, { type: 'command', command: hookCommand(bots, bot, program), timeout: TIMEOUT }) };
|
|
173
146
|
// The harness's other hooks are each judged on their own: the session hook
|
|
174
|
-
// may be right while the warning after a native message (ADR 0032)
|
|
175
|
-
//
|
|
176
|
-
// (#480) is missing.
|
|
147
|
+
// may be right while the warning after a native message (ADR 0032) or the
|
|
148
|
+
// naming of a Codex thread (#480) is missing.
|
|
177
149
|
if (isDeepStrictEqual(settings, wanted)) {
|
|
178
150
|
for (const tool of toolHooksOf(harness, bots, bot)) {
|
|
179
151
|
const toolRunning = programIn(settings, tool.pattern);
|
|
@@ -267,24 +239,15 @@ function withKitHook(hooks, file, mine) {
|
|
|
267
239
|
|
|
268
240
|
/** Each harness's kit hooks beside its session hook, as `toolHookOf` gives each. */
|
|
269
241
|
const toolHooksOf = (harness, bots, bot, cli = ownCli()) =>
|
|
270
|
-
(harness === 'claude' ? ['sent'
|
|
242
|
+
(harness === 'claude' ? ['sent'] : ['name']).map((kind) => toolHookOf(kind, bots, bot, cli));
|
|
271
243
|
|
|
272
244
|
/**
|
|
273
245
|
* One of the kit's hooks beside its session hook: Claude Code's after a native
|
|
274
|
-
* `SendMessage` (`sent`, ADR 0032), Codex's
|
|
275
|
-
*
|
|
276
|
-
*
|
|
246
|
+
* `SendMessage` (`sent`, ADR 0032), and Codex's at a turn end (`name`, #480).
|
|
247
|
+
* The entry, where it goes, how the kit knows its own, and what is lost
|
|
248
|
+
* without it.
|
|
277
249
|
*/
|
|
278
250
|
function toolHookOf(kind, bots, bot, cli = ownCli()) {
|
|
279
|
-
if (kind === 'mail') {
|
|
280
|
-
return {
|
|
281
|
-
kind,
|
|
282
|
-
event: MAIL_EVENT,
|
|
283
|
-
pattern: KIT_MAIL,
|
|
284
|
-
mine: { type: 'command', command: mailCommand(bots, bot, cli), timeout: MAIL_TIMEOUT },
|
|
285
|
-
missing: 'a Claude session busy when fleet mail came is never told about it at the end of its turn, and nothing is typed into its tab either (#509)',
|
|
286
|
-
};
|
|
287
|
-
}
|
|
288
251
|
if (kind === 'name') {
|
|
289
252
|
return {
|
|
290
253
|
kind,
|
|
@@ -294,26 +257,34 @@ function toolHookOf(kind, bots, bot, cli = ownCli()) {
|
|
|
294
257
|
missing: "a Codex session's thread is never named after its bot and session, and its tab shows a topic Codex made up instead (#480)",
|
|
295
258
|
};
|
|
296
259
|
}
|
|
297
|
-
if (kind === 'sent') {
|
|
298
|
-
return {
|
|
299
|
-
kind,
|
|
300
|
-
event: SENT_EVENT,
|
|
301
|
-
matcher: SENT_MATCHER,
|
|
302
|
-
pattern: KIT_SENT,
|
|
303
|
-
mine: { type: 'command', command: sentCommand(bots, bot, cli), timeout: TIMEOUT },
|
|
304
|
-
missing: 'a session is never warned when its native message goes outside this bots folder (ADR 0032)',
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
260
|
return {
|
|
308
261
|
kind,
|
|
309
|
-
event:
|
|
310
|
-
matcher:
|
|
311
|
-
pattern:
|
|
312
|
-
mine: { type: 'command', command:
|
|
313
|
-
missing:
|
|
262
|
+
event: SENT_EVENT,
|
|
263
|
+
matcher: SENT_MATCHER,
|
|
264
|
+
pattern: KIT_SENT,
|
|
265
|
+
mine: { type: 'command', command: sentCommand(bots, bot, cli), timeout: TIMEOUT },
|
|
266
|
+
missing: 'a session is never warned when its native message goes outside this bots folder (ADR 0032)',
|
|
314
267
|
};
|
|
315
268
|
}
|
|
316
269
|
|
|
270
|
+
/**
|
|
271
|
+
* The hooks without the kit's entries of these patterns, the hooks it no
|
|
272
|
+
* longer writes, and everything else as the user left it. A group the kit
|
|
273
|
+
* empties goes, and an event left with no groups goes with it.
|
|
274
|
+
*/
|
|
275
|
+
function withoutHooks(hooks, patterns) {
|
|
276
|
+
return Object.fromEntries(Object.entries(hooks).flatMap(([event, groups]) => {
|
|
277
|
+
if (!Array.isArray(groups)) return [[event, groups]];
|
|
278
|
+
const kept = groups.flatMap((group) => {
|
|
279
|
+
if (!Array.isArray(group?.hooks)) return [group];
|
|
280
|
+
const entries = group.hooks.filter((hook) => patterns.every((pattern) => programOf(hook?.command, pattern) === undefined));
|
|
281
|
+
if (entries.length === group.hooks.length) return [group];
|
|
282
|
+
return entries.length === 0 ? [] : [{ ...group, hooks: entries }];
|
|
283
|
+
});
|
|
284
|
+
return kept.length === 0 ? [] : [[event, kept]];
|
|
285
|
+
}));
|
|
286
|
+
}
|
|
287
|
+
|
|
317
288
|
/**
|
|
318
289
|
* The hooks with the kit's tool hook in a group of its event matched to its
|
|
319
290
|
* tool (or with no matcher, for one that has none), and everything else as the
|
|
@@ -380,6 +351,7 @@ const isKitHook = (hook) => kitProgramOf(hook?.command) !== undefined;
|
|
|
380
351
|
const WORD = String.raw`(?:[A-Za-z0-9,._+:@%/=-]+|'(?:[^']|'\\'')*')`;
|
|
381
352
|
const KIT_HOOK = new RegExp(String.raw`^(${WORD}) session record --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
382
353
|
const KIT_SENT = new RegExp(String.raw`^(${WORD}) session sent --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
354
|
+
/** The hooks the kit wrote before #555, and takes out. */
|
|
383
355
|
const KIT_NUDGE = new RegExp(String.raw`^(${WORD}) session nudge --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
384
356
|
const KIT_MAIL = new RegExp(String.raw`^(${WORD}) session mail --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
385
357
|
const KIT_NAME = new RegExp(String.raw`^(${WORD}) session name --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
@@ -390,7 +362,7 @@ const KIT_NAME = new RegExp(String.raw`^(${WORD}) session name --bots ${WORD} --
|
|
|
390
362
|
* (#506). One that runs some other program, even another `obk`, is not.
|
|
391
363
|
*/
|
|
392
364
|
export const runsThisKit = (hook, cli = ownCli()) =>
|
|
393
|
-
hook?.type === 'command' && [KIT_HOOK, KIT_SENT,
|
|
365
|
+
hook?.type === 'command' && [KIT_HOOK, KIT_SENT, KIT_NAME].some((pattern) => programOf(hook.command, pattern) === cli);
|
|
394
366
|
|
|
395
367
|
/** The program a bot made before the kit named itself by path runs. */
|
|
396
368
|
const BARE = 'obk';
|
package/src/launch.js
CHANGED
|
@@ -76,7 +76,7 @@ export const harnessOf = (session, botHarness) => (set(session.harness) ? sessio
|
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
78
|
* A session's name on Claude Code, which is also the address another Claude
|
|
79
|
-
* session writes to (ADR
|
|
79
|
+
* session writes to (ADR 0042). It goes on the launch line as `-n`, and it is
|
|
80
80
|
* re-applied every time the session is started: a resume keeps the name by
|
|
81
81
|
* itself (tech notes, section 2), and the kit does not depend on that.
|
|
82
82
|
*
|
|
@@ -231,7 +231,7 @@ const where = (session) => (set(session.name) ? `session ${session.name}` : 'thi
|
|
|
231
231
|
* send would land on that list and answer it. A prompt given as an argument is
|
|
232
232
|
* held by the harness until it is ready for it (tech notes, section 1).
|
|
233
233
|
*/
|
|
234
|
-
export function launchCommand(session, { harness, home, workDir, prompt, promptFile: fromFile, resume, address }) {
|
|
234
|
+
export function launchCommand(session, { harness, home, workDir, prompt, promptFile: fromFile, resume, address, kit }) {
|
|
235
235
|
const trouble = sessionTrouble(session, harness, home);
|
|
236
236
|
if (trouble !== undefined) throw new Error(trouble);
|
|
237
237
|
|
|
@@ -249,7 +249,7 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
|
|
|
249
249
|
|
|
250
250
|
if (harness === 'claude') {
|
|
251
251
|
// The name is the address other Claude sessions write to, so it goes on
|
|
252
|
-
// every launch line, a resume included (ADR
|
|
252
|
+
// every launch line, a resume included (ADR 0042).
|
|
253
253
|
if (address !== undefined) words.push('-n', address);
|
|
254
254
|
// The context window rides on the model name: `sonnet[1m]`.
|
|
255
255
|
if (set(session.model)) words.push('--model', set(session.context) ? `${session.model}[${session.context}]` : session.model);
|
|
@@ -265,6 +265,10 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
|
|
|
265
265
|
// started in, and nowhere else (ADR 0015), so a work dir outside the bot
|
|
266
266
|
// home has to be named.
|
|
267
267
|
if (workDir !== undefined && !inside(home, workDir)) words.push('--add-dir', workDir);
|
|
268
|
+
// And the kit's own folders for this bot beside the bots folder: its long
|
|
269
|
+
// message bodies, its start prompts and its locks (#534). The bot's, and no
|
|
270
|
+
// other bot's: a session never gets to write another bot's mail.
|
|
271
|
+
if (kit !== undefined) for (const folder of Object.values(kit)) words.push('--add-dir', folder);
|
|
268
272
|
}
|
|
269
273
|
|
|
270
274
|
return [
|
|
@@ -339,6 +343,19 @@ const resumeWords = (harness, resume) => {
|
|
|
339
343
|
return harness === 'claude' ? ['--resume', quoted(resume)] : [quoted(resume)];
|
|
340
344
|
};
|
|
341
345
|
|
|
346
|
+
/**
|
|
347
|
+
* The kit's own folders for one bot, beside the bots folder: `messages` for
|
|
348
|
+
* the bodies of its long fleet mail, `prompts` for its sessions' start
|
|
349
|
+
* prompts, `locks` for its book's and its sessions' turns. Each is a folder of
|
|
350
|
+
* the bot's own, so that a Codex session, whose sandbox writes only where its
|
|
351
|
+
* launch line says, can be given its bot's and no other's (#534).
|
|
352
|
+
*/
|
|
353
|
+
export const kitFolders = (bots, bot) => ({
|
|
354
|
+
messages: path.join(`${bots}.messages`, bot),
|
|
355
|
+
prompts: path.join(`${bots}.prompts`, bot),
|
|
356
|
+
locks: path.join(`${bots}.locks`, bot),
|
|
357
|
+
});
|
|
358
|
+
|
|
342
359
|
/** Whether `target` is the folder at `home` or something inside it. */
|
|
343
360
|
const inside = (home, target) => target === home || target.startsWith(home + path.sep);
|
|
344
361
|
|