@zgeoff/atc 0.1.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/src/daemon.ts ADDED
@@ -0,0 +1,648 @@
1
+ import { existsSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import type { AgentAdapter } from './agent-adapter';
3
+ import { AttachRegistry } from './attach-registry';
4
+ import type { Dims } from './attach-registry';
5
+ import { DaemonConnection } from './daemon-connection';
6
+ import type { DaemonContext, OutputClient } from './daemon-connection';
7
+ import { startHookServer } from './hooks';
8
+ import { PermissionRegistry } from './permission-registry';
9
+ import { MAX_CHUNK, PROTOCOL_V } from './protocol';
10
+ import type { EventMsg } from './protocol';
11
+ import { ScreenModel } from './screen-model';
12
+ import { SessionManager } from './sessions';
13
+ import type { SessionDescriptor, SessionState } from './sessions';
14
+ import { StateStore } from './state-store';
15
+
16
+ export interface DaemonOptions {
17
+ readonly socketPath: string;
18
+ readonly reporterSocketPath: string;
19
+
20
+ // Build string sent in the handshake and in mismatch errors, e.g. "atc/0.1.0".
21
+ readonly build: string;
22
+ readonly adapter: AgentAdapter;
23
+
24
+ // SQLite path for daemon state; a fleet.json at legacyFleetPath seeds the
25
+ // fleet table once so upgrading keeps the restorable fleet.
26
+ readonly dbPath: string;
27
+ readonly legacyFleetPath?: string;
28
+
29
+ // When set, the daemon's pid is written here and removed on stop.
30
+ readonly pidPath?: string;
31
+
32
+ // Where the statusline contract file is written; defaults to the real one.
33
+ readonly statusPath?: string;
34
+
35
+ // Outbound queue capacity per client; small values force desync in tests.
36
+ readonly queueBytes?: number;
37
+
38
+ // Runs one headless turn over a session; the real one drives the Agent
39
+ // SDK, which spawns real claude — a boundary tests never cross. Absent
40
+ // means eject is unsupported.
41
+ readonly headlessRunner?: HeadlessRunner;
42
+
43
+ // How long an eject waits for the dying terminal to report SessionEnd
44
+ // before starting the headless run anyway.
45
+ readonly ejectSettleMs?: number;
46
+ }
47
+
48
+ interface HeadlessRunRequest {
49
+ readonly cwd: string;
50
+ readonly prompt: string;
51
+ readonly resume?: string;
52
+ readonly permissionMode?: string;
53
+ }
54
+
55
+ interface HeadlessRunEvents {
56
+ readonly onOutput: (text: string) => void;
57
+ readonly onDone: (summary: string) => void;
58
+ readonly onNeedsYou: (msg: string) => void;
59
+ }
60
+
61
+ export type HeadlessRunner = (
62
+ opts: HeadlessRunRequest,
63
+ hooks: HeadlessRunEvents,
64
+ ) => { readonly stop: () => void };
65
+
66
+ export interface DaemonHandle {
67
+ readonly stop: () => void;
68
+ }
69
+
70
+ /**
71
+ * The daemon: owns the sessions, the client-protocol listener, and the
72
+ * reporter listener. Protocol requests are NDJSON lines, one response per
73
+ * request, and state changes broadcast to every connected client. The first
74
+ * request on a connection must be `daemon.hello`; an unknown method is an
75
+ * error, never a disconnect; a malformed or oversized line is a disconnect,
76
+ * because transports guarantee byte integrity and such a line means a buggy
77
+ * or hostile peer.
78
+ */
79
+ export function startDaemon(opts: DaemonOptions): DaemonHandle {
80
+ if (opts.pidPath !== undefined) {
81
+ writeFileSync(opts.pidPath, String(process.pid));
82
+ }
83
+
84
+ const store = new StateStore(opts.dbPath, opts.legacyFleetPath);
85
+
86
+ const mgr =
87
+ opts.statusPath === undefined
88
+ ? new SessionManager(opts.adapter, store)
89
+ : new SessionManager(opts.adapter, store, opts.statusPath);
90
+
91
+ const clients = new Set<DaemonConnection>();
92
+
93
+ const emitEvent = (event: EventMsg) => {
94
+ for (const client of clients) {
95
+ client.sendEvent(event);
96
+ }
97
+ };
98
+
99
+ const registry = new PermissionRegistry();
100
+
101
+ registry.onRequested = (req) => {
102
+ emitEvent({
103
+ v: PROTOCOL_V,
104
+ ev: 'permission.requested',
105
+ request: req.id,
106
+ s: req.sessionID,
107
+ message: req.message,
108
+ respondable: req.respondable,
109
+ });
110
+ };
111
+
112
+ registry.onResolved = (id, decision) => {
113
+ emitEvent({ v: PROTOCOL_V, ev: 'permission.resolved', request: id, decision });
114
+ };
115
+
116
+ const headlessRuns = new Map<string, { readonly stop: () => void }>();
117
+
118
+ // Resuming an agent session while its old process is still shutting down
119
+ // corrupts the handoff, so an eject waits for the terminal's final report
120
+ // (or a settle timeout) before the headless run starts.
121
+ const pendingEjects = new Map<string, () => void>();
122
+
123
+ const startHeadlessTurn = (sessionID: string, prompt: string): boolean => {
124
+ const runner = opts.headlessRunner;
125
+ const s = mgr.sessions.find((x) => x.id === sessionID);
126
+
127
+ if (runner === undefined || s === undefined || headlessRuns.has(sessionID)) {
128
+ return false;
129
+ }
130
+
131
+ mgr.updateSurfaceState(sessionID, 'running', 'headless turn running');
132
+
133
+ const handle = runner(
134
+ {
135
+ cwd: s.cwd,
136
+ prompt,
137
+ ...(s.claudeId === undefined ? {} : { resume: s.claudeId }),
138
+ permissionMode: 'auto',
139
+ },
140
+ {
141
+ onOutput: (text) => {
142
+ mgr.onOutput(s, text);
143
+ },
144
+ onDone: (summary) => {
145
+ headlessRuns.delete(sessionID);
146
+
147
+ const doneMsg = summary === '' ? 'headless turn done' : summary;
148
+
149
+ mgr.updateSurfaceState(sessionID, 'done', doneMsg);
150
+ },
151
+ onNeedsYou: (msg) => {
152
+ headlessRuns.delete(sessionID);
153
+ mgr.updateSurfaceState(sessionID, 'needs_you', msg);
154
+ },
155
+ },
156
+ );
157
+
158
+ headlessRuns.set(sessionID, handle);
159
+
160
+ return true;
161
+ };
162
+
163
+ const attachments = new AttachRegistry<OutputClient>();
164
+ const seqs = new Map<string, number>();
165
+ const ptyDims = new Map<string, Dims>();
166
+ const screens = new Map<string, ScreenModel>();
167
+ const resizeTimers = new Map<string, ReturnType<typeof setTimeout>>();
168
+ const detectTimers = new Map<string, ReturnType<typeof setTimeout>>();
169
+
170
+ // The screen tier of the detector stack: once a session's output has
171
+ // quiesced, judge the serialized screen and flip running/needs_you.
172
+ const scheduleDetect = (sessionID: string) => {
173
+ const detector = opts.adapter.screenDetector;
174
+
175
+ if (detector === null) {
176
+ return;
177
+ }
178
+
179
+ const pending = detectTimers.get(sessionID);
180
+
181
+ if (pending !== undefined) {
182
+ clearTimeout(pending);
183
+ }
184
+
185
+ detectTimers.set(
186
+ sessionID,
187
+ setTimeout(() => {
188
+ detectTimers.delete(sessionID);
189
+ void applyScreenJudgment(sessionID);
190
+ }, 300),
191
+ );
192
+ };
193
+
194
+ // Replay is the serialized screen sent as ordinary output events: the
195
+ // client cannot tell replay from live and does not need to. A clear leads
196
+ // so a stale or desynced client screen resets first.
197
+ const sendReplay = async (sessionID: string, client: OutputClient) => {
198
+ const model = screens.get(sessionID);
199
+
200
+ if (model === undefined) {
201
+ return;
202
+ }
203
+
204
+ const replay = `\u001B[2J\u001B[H${await model.renderReplay()}`;
205
+ const seq = seqs.get(sessionID) ?? 0;
206
+
207
+ for (let i = 0; i < replay.length; i += MAX_CHUNK) {
208
+ const chunk = replay.slice(i, i + MAX_CHUNK);
209
+
210
+ client.sendOutput(
211
+ sessionID,
212
+ { v: PROTOCOL_V, ev: 'session.output', s: sessionID, seq, d: chunk },
213
+ chunk.length,
214
+ );
215
+ }
216
+ };
217
+
218
+ const applyEffectiveDims = (sessionID: string) => {
219
+ const dims = attachments.findEffectiveDims(sessionID);
220
+
221
+ if (dims === null) {
222
+ return;
223
+ }
224
+
225
+ const prev = ptyDims.get(sessionID);
226
+
227
+ if (prev !== undefined && prev.cols === dims.cols && prev.rows === dims.rows) {
228
+ return;
229
+ }
230
+
231
+ const s = mgr.sessions.find((x) => x.id === sessionID);
232
+
233
+ s?.pty?.resize(dims.cols, dims.rows);
234
+ screens.get(sessionID)?.updateDims(dims.cols, dims.rows);
235
+ ptyDims.set(sessionID, dims);
236
+
237
+ emitEvent({
238
+ v: PROTOCOL_V,
239
+ ev: 'session.resized',
240
+ s: sessionID,
241
+ cols: dims.cols,
242
+ rows: dims.rows,
243
+ });
244
+ };
245
+
246
+ // Debounced so two clients resizing in opposite directions cannot produce
247
+ // a SIGWINCH storm; a no-op effective size never reaches the PTY.
248
+ const scheduleResize = (sessionID: string) => {
249
+ if (resizeTimers.has(sessionID)) {
250
+ return;
251
+ }
252
+
253
+ resizeTimers.set(
254
+ sessionID,
255
+ setTimeout(() => {
256
+ resizeTimers.delete(sessionID);
257
+
258
+ applyEffectiveDims(sessionID);
259
+ }, 50),
260
+ );
261
+ };
262
+
263
+ const applyScreenJudgment = async (sessionID: string) => {
264
+ const detector = opts.adapter.screenDetector;
265
+ const model = screens.get(sessionID);
266
+
267
+ if (detector === null || model === undefined) {
268
+ return;
269
+ }
270
+
271
+ const screen = await model.renderReplay();
272
+
273
+ const judgment = detector.detectAttention(screen);
274
+
275
+ if (judgment === 'needs-input') {
276
+ mgr.updateAttention(sessionID, 'needs_you', 'waiting at a prompt');
277
+ }
278
+
279
+ if (judgment === 'working') {
280
+ mgr.updateAttention(sessionID, 'running', 'working');
281
+ }
282
+ };
283
+
284
+ mgr.onOutput = (s, data) => {
285
+ // The screen model consumes every byte continuously — background output
286
+ // is consumed, not discarded.
287
+ screens.get(s.id)?.record(data);
288
+ scheduleDetect(s.id);
289
+
290
+ const conns = attachments.collectClients(s.id);
291
+
292
+ if (conns.length === 0) {
293
+ return;
294
+ }
295
+
296
+ let seq = seqs.get(s.id) ?? 0;
297
+
298
+ for (let i = 0; i < data.length; i += MAX_CHUNK) {
299
+ const chunk = data.slice(i, i + MAX_CHUNK);
300
+
301
+ seq++;
302
+
303
+ const event: EventMsg = { v: PROTOCOL_V, ev: 'session.output', s: s.id, seq, d: chunk };
304
+
305
+ for (const conn of conns) {
306
+ conn.sendOutput(s.id, event, chunk.length);
307
+ }
308
+ }
309
+
310
+ seqs.set(s.id, seq);
311
+ };
312
+
313
+ // Permission requests are synthesized from attention transitions: entering
314
+ // needs_you opens one, and leaving it (answered directly in the terminal,
315
+ // or the session dying) dismisses whatever is pending.
316
+ const lastStates = new Map<string, SessionState>();
317
+
318
+ const recordAttention: SessionManager['onEvent'] = (kind, s) => {
319
+ if (kind === 'removed') {
320
+ registry.answerAll(s.id, 'dismissed');
321
+ lastStates.delete(s.id);
322
+
323
+ return;
324
+ }
325
+
326
+ const prev = lastStates.get(s.id);
327
+
328
+ lastStates.set(s.id, s.state);
329
+
330
+ if (s.state === 'needs_you' && prev !== 'needs_you') {
331
+ registry.open(s.id, s.lastMsg, false);
332
+ }
333
+
334
+ if (prev === 'needs_you' && s.state !== 'needs_you') {
335
+ registry.answerAll(s.id, 'dismissed');
336
+ }
337
+ };
338
+
339
+ mgr.onEvent = (kind, s) => {
340
+ recordAttention(kind, s);
341
+
342
+ if (kind === 'removed') {
343
+ attachments.removeSession(s.id);
344
+ seqs.delete(s.id);
345
+ ptyDims.delete(s.id);
346
+ screens.get(s.id)?.stop();
347
+ screens.delete(s.id);
348
+
349
+ const pendingDetect = detectTimers.get(s.id);
350
+
351
+ if (pendingDetect !== undefined) {
352
+ clearTimeout(pendingDetect);
353
+
354
+ detectTimers.delete(s.id);
355
+ }
356
+ }
357
+
358
+ const builders: Record<typeof kind, () => EventMsg> = {
359
+ added: () => ({ v: PROTOCOL_V, ev: 'session.added', session: getDescriptor(mgr, s.id) }),
360
+ state: () => ({
361
+ v: PROTOCOL_V,
362
+ ev: 'session.state',
363
+ s: s.id,
364
+ state: s.state,
365
+ unread: s.unread,
366
+ lastMsg: s.lastMsg,
367
+ kind: s.kind,
368
+ alive: s.pty !== null || (s.kind === 'jsonl' && s.state !== 'exited'),
369
+ ...(s.claudeId === undefined ? {} : { claudeId: s.claudeId }),
370
+ }),
371
+ renamed: () => ({
372
+ v: PROTOCOL_V,
373
+ ev: 'session.renamed',
374
+ s: s.id,
375
+ name: s.name,
376
+ namedBy: s.namedBy,
377
+ }),
378
+ removed: () => ({ v: PROTOCOL_V, ev: 'session.removed', s: s.id }),
379
+ };
380
+
381
+ emitEvent(builders[kind]());
382
+ };
383
+
384
+ const reporter = startHookServer((e) => {
385
+ if (e.event !== 'Statusline') {
386
+ store.recordEvent(e);
387
+ }
388
+
389
+ if (e.event === 'SessionEnd') {
390
+ pendingEjects.get(e.atcId)?.();
391
+ }
392
+
393
+ mgr.applyHook(e);
394
+ }, opts.reporterSocketPath);
395
+
396
+ const ctx: DaemonContext = {
397
+ build: opts.build,
398
+ collectSessions: () => mgr.collectDescriptors(),
399
+ collectSpawnDirs: () => store.collectSpawnDirs(),
400
+ collectFleet: () => store.loadFleet(),
401
+ spawnSession: (p) => {
402
+ const s = mgr.spawn(p.cwd, p.name, p.prompt, p.cols, p.rows, p.resume, p.namedBy);
403
+
404
+ ptyDims.set(s.id, { cols: p.cols, rows: p.rows });
405
+ screens.set(s.id, new ScreenModel(p.cols, p.rows));
406
+ store.recordSpawnDir(p.cwd);
407
+
408
+ return getDescriptor(mgr, s.id);
409
+ },
410
+ killSession: (id) => {
411
+ if (!mgr.sessions.some((s) => s.id === id)) {
412
+ return false;
413
+ }
414
+
415
+ headlessRuns.get(id)?.stop();
416
+ headlessRuns.delete(id);
417
+ mgr.kill(id);
418
+
419
+ return true;
420
+ },
421
+ ejectSession: (id, prompt) => {
422
+ if (opts.headlessRunner === undefined) {
423
+ return 'unsupported';
424
+ }
425
+
426
+ if (!hasResumableTranscript(mgr, id)) {
427
+ return 'no_transcript';
428
+ }
429
+
430
+ const yanked = mgr.yankHeadless(id);
431
+
432
+ if (yanked === null) {
433
+ return 'missing';
434
+ }
435
+
436
+ const settled = Promise.withResolvers<void>();
437
+ const timer = setTimeout(settled.resolve, opts.ejectSettleMs ?? 4000);
438
+
439
+ pendingEjects.set(id, settled.resolve);
440
+
441
+ void (async () => {
442
+ await settled.promise;
443
+
444
+ clearTimeout(timer);
445
+
446
+ pendingEjects.delete(id);
447
+
448
+ startHeadlessTurn(id, prompt);
449
+ })();
450
+
451
+ return 'ok';
452
+ },
453
+ adoptSession: (id, cols, rows) => {
454
+ if (!hasResumableTranscript(mgr, id)) {
455
+ return 'no_transcript';
456
+ }
457
+
458
+ headlessRuns.get(id)?.stop();
459
+ headlessRuns.delete(id);
460
+
461
+ const adopted = mgr.adoptTerminal(id, cols, rows);
462
+
463
+ if (adopted === null) {
464
+ return 'missing';
465
+ }
466
+
467
+ ptyDims.set(id, { cols, rows });
468
+
469
+ scheduleResize(id);
470
+
471
+ return 'ok';
472
+ },
473
+ ackSession: (id) => {
474
+ if (!mgr.sessions.some((s) => s.id === id)) {
475
+ return false;
476
+ }
477
+
478
+ mgr.ack(id);
479
+
480
+ return true;
481
+ },
482
+ buildResumeCommand: (id) => mgr.buildResumeCommand(id),
483
+ answerPermission: (request, decision) => registry.answer(request, decision),
484
+ attachSession: (client, sessionID, dims) => {
485
+ const s = mgr.sessions.find((x) => x.id === sessionID);
486
+
487
+ if (s === undefined) {
488
+ return 'missing';
489
+ }
490
+
491
+ if (s.pty === null) {
492
+ return 'dead';
493
+ }
494
+
495
+ attachments.attach(sessionID, client, dims);
496
+ mgr.attach(sessionID);
497
+
498
+ scheduleResize(sessionID);
499
+ void sendReplay(sessionID, client);
500
+
501
+ return 'ok';
502
+ },
503
+ detachSession: (client, sessionID) => {
504
+ attachments.detach(sessionID, client);
505
+
506
+ scheduleResize(sessionID);
507
+ },
508
+ detachClient: (client) => {
509
+ for (const sessionID of attachments.detachAll(client)) {
510
+ scheduleResize(sessionID);
511
+ }
512
+ },
513
+ writeSessionInput: (sessionID, data) => {
514
+ const s = mgr.sessions.find((x) => x.id === sessionID);
515
+
516
+ if (s === undefined) {
517
+ return 'missing';
518
+ }
519
+
520
+ if (s.kind === 'jsonl') {
521
+ if (headlessRuns.has(sessionID)) {
522
+ return 'busy';
523
+ }
524
+
525
+ return startHeadlessTurn(sessionID, data.trimEnd()) ? 'ok' : 'dead';
526
+ }
527
+
528
+ if (s.pty === null) {
529
+ return 'dead';
530
+ }
531
+
532
+ s.pty.write(data);
533
+
534
+ return 'ok';
535
+ },
536
+ resizeSession: (client, sessionID, dims) => {
537
+ if (!attachments.updateDims(sessionID, client, dims)) {
538
+ return false;
539
+ }
540
+
541
+ scheduleResize(sessionID);
542
+
543
+ return true;
544
+ },
545
+ resyncClient: sendReplay,
546
+ ...(opts.queueBytes === undefined ? {} : { queueBytes: opts.queueBytes }),
547
+ getEffectiveDims: (sessionID) =>
548
+ attachments.findEffectiveDims(sessionID) ?? ptyDims.get(sessionID) ?? { cols: 80, rows: 24 },
549
+ restoreFleet: (cols, rows) => {
550
+ let restored = 0;
551
+
552
+ for (const entry of store.loadFleet()) {
553
+ const live = mgr.sessions.some((s) => s.pty !== null && s.claudeId === entry.claudeId);
554
+
555
+ if (live) {
556
+ continue;
557
+ }
558
+
559
+ const s = mgr.spawn(entry.cwd, entry.name, '', cols, rows, entry.claudeId);
560
+
561
+ ptyDims.set(s.id, { cols, rows });
562
+ screens.set(s.id, new ScreenModel(cols, rows));
563
+
564
+ restored++;
565
+ }
566
+
567
+ return restored;
568
+ },
569
+ };
570
+
571
+ try {
572
+ unlinkSync(opts.socketPath);
573
+ } catch {}
574
+
575
+ const server = Bun.listen<DaemonConnection>({
576
+ unix: opts.socketPath,
577
+ socket: {
578
+ open(socket) {
579
+ socket.data = new DaemonConnection(socket, ctx);
580
+
581
+ clients.add(socket.data);
582
+ },
583
+ data(socket, buf) {
584
+ socket.data.applyChunk(buf.toString());
585
+ },
586
+ drain(socket) {
587
+ socket.data.drain();
588
+ },
589
+ close(socket) {
590
+ clients.delete(socket.data);
591
+ ctx.detachClient(socket.data);
592
+ },
593
+ error() {},
594
+ },
595
+ });
596
+
597
+ return {
598
+ stop() {
599
+ for (const timer of resizeTimers.values()) {
600
+ clearTimeout(timer);
601
+ }
602
+
603
+ for (const timer of detectTimers.values()) {
604
+ clearTimeout(timer);
605
+ }
606
+
607
+ server.stop(true);
608
+ reporter.stop(true);
609
+ mgr.killAll();
610
+
611
+ for (const model of screens.values()) {
612
+ model.stop();
613
+ }
614
+
615
+ store.stop();
616
+
617
+ if (opts.pidPath !== undefined) {
618
+ try {
619
+ unlinkSync(opts.pidPath);
620
+ } catch {}
621
+ }
622
+ },
623
+ };
624
+ }
625
+
626
+ // A session reporting a transcript path that does not exist on disk yet has
627
+ // nothing to resume — claude exits immediately on such a resume, which
628
+ // reads as the revive silently failing. A session that never reported a
629
+ // path (foreign adapters) passes; the spawn itself will tell the truth.
630
+ function hasResumableTranscript(mgr: SessionManager, id: string): boolean {
631
+ const s = mgr.sessions.find((x) => x.id === id);
632
+
633
+ if (s === undefined || s.transcriptSource === undefined) {
634
+ return true;
635
+ }
636
+
637
+ return existsSync(s.transcriptSource);
638
+ }
639
+
640
+ function getDescriptor(mgr: SessionManager, id: string): SessionDescriptor {
641
+ const d = mgr.collectDescriptors().find((x) => x.id === id);
642
+
643
+ if (d === undefined) {
644
+ throw new Error(`descriptor for unknown session ${id}`);
645
+ }
646
+
647
+ return d;
648
+ }
package/src/dirs.ts ADDED
@@ -0,0 +1,67 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { basename } from 'node:path';
4
+
5
+ // Spawn-history first (most recently used, reported by the daemon), then
6
+ // zoxide's frecency list.
7
+ export async function collectDirs(recent: readonly string[]): Promise<string[]> {
8
+ let zoxide: string[] = [];
9
+
10
+ try {
11
+ const proc = Bun.spawn(['zoxide', 'query', '-l'], { stdout: 'pipe', stderr: 'ignore' });
12
+
13
+ const text = await new Response(proc.stdout).text();
14
+
15
+ zoxide = text.split('\n').filter((line) => line !== '');
16
+ } catch {}
17
+
18
+ const seen = new Set<string>();
19
+
20
+ const found: string[] = [];
21
+
22
+ for (const d of [...recent, ...zoxide]) {
23
+ if (!seen.has(d) && existsSync(d)) {
24
+ seen.add(d);
25
+ found.push(d);
26
+ }
27
+ }
28
+
29
+ if (found.length === 0) {
30
+ found.push(homedir());
31
+ }
32
+
33
+ return found;
34
+ }
35
+
36
+ export function pickMatches(items: readonly string[], filter: string): string[] {
37
+ if (filter === '') {
38
+ return [...items];
39
+ }
40
+
41
+ const f = filter.toLowerCase();
42
+
43
+ const scored = items
44
+ .map((item) => {
45
+ const base = basename(item).toLowerCase();
46
+ let score = -1;
47
+
48
+ if (base.startsWith(f)) {
49
+ score = 0;
50
+ } else if (base.includes(f)) {
51
+ score = 1;
52
+ } else if (item.toLowerCase().includes(f)) {
53
+ score = 2;
54
+ }
55
+
56
+ return { item, score };
57
+ })
58
+ .filter((x) => x.score >= 0);
59
+
60
+ return scored.toSorted((a, b) => a.score - b.score).map((x) => x.item);
61
+ }
62
+
63
+ export function formatDir(d: string): string {
64
+ const home = homedir();
65
+
66
+ return d.startsWith(home) ? `~${d.slice(home.length)}` : d;
67
+ }