pim-agent 0.9.0 → 0.11.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.
Files changed (71) hide show
  1. package/bin/pim.ts +1 -1
  2. package/package.json +2 -3
  3. package/packages/core/src/extensions/CoreExtensions.ts +19 -16
  4. package/packages/core/src/extensions/apply-patch/render.ts +16 -55
  5. package/packages/core/src/extensions/bash/index.ts +2 -1
  6. package/packages/core/src/extensions/bash/run.ts +8 -2
  7. package/packages/core/src/extensions/system-prompt/index.ts +45 -22
  8. package/packages/core/src/extensions/system-prompt/prompt.ts +6 -3
  9. package/packages/core/src/picker/PickerService.ts +23 -1
  10. package/packages/core/src/picker/token.ts +12 -5
  11. package/packages/core/src/session/EventLog.ts +22 -79
  12. package/packages/core/src/session/SearchIndex.ts +752 -0
  13. package/packages/core/src/session/SearchTokens.ts +97 -0
  14. package/packages/core/src/session/SessionDigest.ts +161 -0
  15. package/packages/core/src/session/SessionHost.ts +52 -10
  16. package/packages/core/src/session/SessionMeta.ts +267 -0
  17. package/packages/core/src/session/SessionName.ts +18 -0
  18. package/packages/core/src/session/SessionRegistry.ts +111 -15
  19. package/packages/core/src/session/SessionUi.ts +127 -0
  20. package/packages/core/src/session/WriteMark.ts +15 -1
  21. package/packages/core/src/shared/DiffLines.ts +1 -1
  22. package/packages/core/src/shared/DiffPatch.ts +70 -0
  23. package/packages/core/src/shared/DiffRenderer.ts +1 -1
  24. package/packages/core/src/shared/Directories.ts +39 -5
  25. package/packages/core/src/shared/ExtensionToggles.ts +8 -0
  26. package/packages/core/src/shared/FileWatch.ts +15 -1
  27. package/packages/core/src/shared/Git.ts +483 -42
  28. package/packages/core/src/shared/GitMonitor.ts +221 -0
  29. package/packages/core/src/shared/Levenshtein.ts +54 -1
  30. package/packages/core/src/shared/PiExtensions.ts +228 -0
  31. package/packages/core/src/shared/Proc.ts +21 -1
  32. package/packages/core/src/shared/RepoDiff.ts +741 -0
  33. package/packages/core/src/shared/Surface.ts +2 -0
  34. package/packages/core/src/shared/Updater.ts +21 -5
  35. package/packages/core/src/view/DiffExpand.ts +214 -0
  36. package/packages/core/src/view/DiffLayout.ts +7 -7
  37. package/packages/core/src/view/DiffPairs.ts +47 -0
  38. package/packages/core/src/view/MovePath.ts +70 -0
  39. package/packages/daemon/src/WebSurface.ts +1 -2
  40. package/packages/protocol/src/Command.ts +151 -3
  41. package/packages/protocol/src/Diff.ts +15 -0
  42. package/packages/protocol/src/ServerEvent.ts +191 -2
  43. package/packages/server/src/ClientConnection.ts +15 -3
  44. package/packages/server/src/ProbeClient.ts +111 -28
  45. package/packages/server/src/SessionCatalogue.ts +292 -38
  46. package/packages/server/src/SessionStream.ts +297 -45
  47. package/packages/server/src/StaticClient.ts +95 -12
  48. package/packages/server/src/WsGateway.ts +417 -45
  49. package/packages/server/src/probe.ts +77 -7
  50. package/packages/telegram/src/Session.ts +1 -0
  51. package/packages/tui/src/extensions/command-picker/index.ts +64 -32
  52. package/packages/tui/src/extensions/footer/index.ts +19 -43
  53. package/packages/tui/src/extensions/session-lease/index.ts +8 -3
  54. package/packages/web/dist/client/assets/CommitMonoV143-VF-ytizKI8U.woff2 +0 -0
  55. package/packages/web/dist/client/assets/{core-CGdQx9la.js → core-D3tyj0PP.js} +1 -1
  56. package/packages/web/dist/client/assets/index-CTrlTM53.js +45 -0
  57. package/packages/web/dist/client/assets/index-JnduS4Cd.css +1 -0
  58. package/packages/web/dist/client/index.html +2 -2
  59. package/packages/protocol/src/Protocol.ts +0 -6
  60. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-B48RJMeK.woff +0 -0
  61. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-DBq5bJCE.woff2 +0 -0
  62. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-BXinMwCi.woff +0 -0
  63. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-DjSHLl2N.woff2 +0 -0
  64. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-s0S3qwFW.woff +0 -0
  65. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-wzhe4RuD.woff2 +0 -0
  66. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-BuQDXT0M.woff +0 -0
  67. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-SwgSWHaV.woff2 +0 -0
  68. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-1-GU0IUE.woff +0 -0
  69. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-DU8mrtj2.woff2 +0 -0
  70. package/packages/web/dist/client/assets/index-BMNwleWO.js +0 -40
  71. package/packages/web/dist/client/assets/index-CqjB2RPr.css +0 -1
@@ -1,8 +1,13 @@
1
- import { type ToolDefinition } from "@earendil-works/pi-coding-agent";
1
+ import {
2
+ SessionManager,
3
+ type ToolDefinition,
4
+ } from "@earendil-works/pi-coding-agent";
2
5
  import { stat } from "node:fs/promises";
3
6
  import { join } from "node:path";
4
7
 
5
8
  import { Directories } from "../shared/Directories";
9
+ import { Pool } from "../shared/Pool";
10
+ import type { Surface } from "../shared/Surface";
6
11
  import { AgentRuntime, type ModelChoice } from "./AgentRuntime";
7
12
  import { EventLog } from "./EventLog";
8
13
  import { SessionCache } from "./SessionCache";
@@ -11,6 +16,9 @@ import {
11
16
  type CustomToolContext,
12
17
  type HostSettings,
13
18
  } from "./SessionHost";
19
+ import { SessionLease } from "./SessionLease";
20
+ import { SessionName } from "./SessionName";
21
+ import type { SessionUi } from "./SessionUi";
14
22
 
15
23
  /** A session as pi stores it: one JSONL file under a cwd-encoded directory. */
16
24
  export type SessionSummary = {
@@ -33,6 +41,8 @@ export type SessionRegistryDeps = {
33
41
  ) => readonly ToolDefinition[];
34
42
  /** Appended to every session's system prompt. */
35
43
  readonly systemInstruction?: () => Promise<string | undefined>;
44
+ /** Where this frontend's human is reading. */
45
+ readonly surface?: Surface;
36
46
  };
37
47
 
38
48
  export type SessionCreateOptions = {
@@ -41,11 +51,15 @@ export type SessionCreateOptions = {
41
51
  readonly like?: SessionHost;
42
52
  };
43
53
 
54
+ /** A session tree is thousands of files and each header is one open; fanning out over all of them at once is how a listing runs the process out of descriptors. */
55
+ const HEADER_READS = 32;
56
+
44
57
  /** Live sessions keyed on pi's session UUID; the catalogue is pi's sessions directory, read on demand. */
45
58
  export class SessionRegistry {
46
59
  private readonly deps: SessionRegistryDeps;
47
60
  private readonly runtime: AgentRuntime;
48
61
  private readonly hosts: SessionCache<SessionHost>;
62
+ private ui: ((host: SessionHost) => SessionUi | undefined) | undefined;
49
63
 
50
64
  public constructor(deps: SessionRegistryDeps) {
51
65
  this.deps = deps;
@@ -53,28 +67,37 @@ export class SessionRegistry {
53
67
  this.hosts = new SessionCache(deps.capacity);
54
68
  }
55
69
 
70
+ /**
71
+ * Where the extensions of every host built from here speak. It belongs to the
72
+ * registry rather than each caller because `create` binds an agent eagerly,
73
+ * and pi freezes a session's UI mode at that bind. Resolved per call: a
74
+ * host outlives the stream that speaks for it.
75
+ */
76
+ public setUi(ui: (host: SessionHost) => SessionUi | undefined): void {
77
+ this.ui = ui;
78
+ }
79
+
56
80
  public get sessionsRoot(): string {
57
81
  return join(this.runtime.agentDir, "sessions");
58
82
  }
59
83
 
84
+ public get agentDir(): string {
85
+ return this.runtime.agentDir;
86
+ }
87
+
60
88
  public async init(): Promise<void> {
61
89
  await this.runtime.init();
62
90
  }
63
91
 
64
92
  /** Sessions on disk, newest first; reads only each file's header line. */
65
93
  public async list(cwd?: string): Promise<readonly SessionSummary[]> {
66
- const root = this.sessionsRoot;
67
- if (!(await stat(root).catch(() => undefined))?.isDirectory()) {
68
- return [];
69
- }
70
- const paths: string[] = [];
71
- for await (const relative of new Bun.Glob("*/*.jsonl").scan({
72
- cwd: root,
73
- onlyFiles: true,
74
- })) {
75
- paths.push(join(root, relative));
76
- }
77
- const summaries = (await Promise.all(paths.map(readSummary))).filter(
94
+ const summaries = (
95
+ await Pool.mapPooled(
96
+ await this.paths("*/*.jsonl"),
97
+ HEADER_READS,
98
+ readSummary
99
+ )
100
+ ).filter(
78
101
  (summary): summary is SessionSummary =>
79
102
  summary !== undefined && (cwd === undefined || summary.cwd === cwd)
80
103
  );
@@ -86,6 +109,38 @@ export class SessionRegistry {
86
109
  return this.hosts.peek(sessionId);
87
110
  }
88
111
 
112
+ /**
113
+ * Rename a session through pi's own session name, so its `/resume` picker shows
114
+ * it too; `null` clears it. A live session is renamed by its agent, a closed one
115
+ * by appending to its file under the turn lease. Answers with the name pi kept.
116
+ */
117
+ public async setName(
118
+ sessionId: string,
119
+ name: string | null
120
+ ): Promise<string | undefined> {
121
+ const host = this.peek(sessionId);
122
+ if (host?.agentSession) {
123
+ return await host.setName(name);
124
+ }
125
+ const summary = await this.find(sessionId);
126
+ if (summary === undefined) {
127
+ throw new Error(`unknown session: ${sessionId}`);
128
+ }
129
+ const next = SessionName.normalise(name);
130
+ // Reopening is a plain read today, but a pi that migrates the file rewrites it.
131
+ // The lease is only ever held for a whole turn, so a rename refuses rather than queues.
132
+ return await SessionLease.hold(
133
+ summary.path,
134
+ "daemon",
135
+ async () => {
136
+ const manager = SessionManager.open(summary.path);
137
+ manager.appendSessionInfo(next);
138
+ return manager.getSessionName();
139
+ },
140
+ { timeoutMs: 0 }
141
+ );
142
+ }
143
+
89
144
  /** Every model this machine has credentials for, qualified as `SessionHost.setModel` takes them. */
90
145
  public models(): readonly ModelChoice[] {
91
146
  return this.runtime.models();
@@ -96,7 +151,7 @@ export class SessionRegistry {
96
151
  if (cached) {
97
152
  return cached;
98
153
  }
99
- const summary = (await this.list()).find((s) => s.sessionId === sessionId);
154
+ const summary = await this.find(sessionId);
100
155
  if (!summary) {
101
156
  throw new Error(`unknown session: ${sessionId}`);
102
157
  }
@@ -134,8 +189,44 @@ export class SessionRegistry {
134
189
  await this.hosts.disposeAll();
135
190
  }
136
191
 
192
+ /**
193
+ * One session by id. Pi names a file for the id it carries, so the usual
194
+ * answer is one glob and one header; resolving it by listing would read the
195
+ * header of every session on disk to throw all but one away.
196
+ */
197
+ private async find(sessionId: string): Promise<SessionSummary | undefined> {
198
+ for (const path of await this.paths(`*/*${sessionId}.jsonl`)) {
199
+ const summary = await readSummary(path);
200
+ if (summary?.sessionId === sessionId) {
201
+ return summary;
202
+ }
203
+ }
204
+ // A file named something else entirely: the id is the header's, not the name's.
205
+ return (await this.list()).find(
206
+ (summary) => summary.sessionId === sessionId
207
+ );
208
+ }
209
+
210
+ private async paths(pattern: string): Promise<readonly string[]> {
211
+ const root = this.sessionsRoot;
212
+ if (!(await stat(root).catch(() => undefined))?.isDirectory()) {
213
+ return [];
214
+ }
215
+ const found: string[] = [];
216
+ for await (const relative of new Bun.Glob(pattern).scan({
217
+ cwd: root,
218
+ onlyFiles: true,
219
+ })) {
220
+ found.push(join(root, relative));
221
+ }
222
+ return found;
223
+ }
224
+
137
225
  private buildHost(label: string, settings: HostSettings): SessionHost {
138
- return new SessionHost({
226
+ const ui = this.ui;
227
+ // Named so the sink can reach back for the host it belongs to; the closure
228
+ // only ever runs once construction has returned.
229
+ const host: SessionHost = new SessionHost({
139
230
  label: `session ${label}`,
140
231
  settings,
141
232
  defaults: this.deps.defaults,
@@ -150,7 +241,12 @@ export class SessionRegistry {
150
241
  ...(this.deps.systemInstruction === undefined
151
242
  ? {}
152
243
  : { systemInstruction: this.deps.systemInstruction }),
244
+ ...(this.deps.surface === undefined
245
+ ? {}
246
+ : { surface: this.deps.surface }),
247
+ ...(ui === undefined ? {} : { ui: () => ui(host) }),
153
248
  });
249
+ return host;
154
250
  }
155
251
  }
156
252
 
@@ -0,0 +1,127 @@
1
+ import type {
2
+ ExtensionUIContext,
3
+ Theme,
4
+ } from "@earendil-works/pi-coding-agent";
5
+
6
+ import type { NoticeSeverity } from "../view/ViewBlock";
7
+
8
+ /** The slice of pi's dialog options a surface without a terminal can honour; the rest is countdown chrome. */
9
+ export type UiAsk = {
10
+ readonly timeout?: number;
11
+ readonly signal?: AbortSignal;
12
+ };
13
+
14
+ /** Where an extension's own words go. Four of pi's thirty methods: the ones that address a human rather than a terminal. */
15
+ export type SessionUi = {
16
+ readonly notify: (text: string, severity: NoticeSeverity) => void;
17
+ readonly select: (
18
+ title: string,
19
+ options: readonly string[],
20
+ opts?: UiAsk
21
+ ) => Promise<string | undefined>;
22
+ readonly confirm: (
23
+ title: string,
24
+ message: string,
25
+ opts?: UiAsk
26
+ ) => Promise<boolean>;
27
+ readonly input: (
28
+ title: string,
29
+ placeholder?: string,
30
+ opts?: UiAsk
31
+ ) => Promise<string | undefined>;
32
+ };
33
+
34
+ /** Everything off the terminal paints with; also what pi's border getters hand back. */
35
+ const IDENTITY = (text: string): string => text;
36
+
37
+ /**
38
+ * pi keeps its own `theme` instance to itself, and nothing off the terminal has
39
+ * colours to paint with. `satisfies` covers the whole public surface — `keyof`
40
+ * skips the private fields the cast is for — so a method pi adds is a type
41
+ * error here rather than a throw inside somebody's event handler.
42
+ */
43
+ const PLAIN_THEME = {
44
+ fg: (_color: string, text: string) => text,
45
+ bg: (_color: string, text: string) => text,
46
+ bold: IDENTITY,
47
+ italic: IDENTITY,
48
+ underline: IDENTITY,
49
+ inverse: IDENTITY,
50
+ strikethrough: IDENTITY,
51
+ getFgAnsi: () => "",
52
+ getBgAnsi: () => "",
53
+ getColorMode: () => "truecolor",
54
+ getThinkingBorderColor: () => IDENTITY,
55
+ getBashModeBorderColor: () => IDENTITY,
56
+ } satisfies { [K in keyof Theme]: Theme[K] } as unknown as Theme;
57
+
58
+ function severityOf(
59
+ kind: Parameters<ExtensionUIContext["notify"]>[1]
60
+ ): NoticeSeverity {
61
+ return kind === "warning" ? "warn" : (kind ?? "info");
62
+ }
63
+
64
+ /** A dialog an extension awaits goes unanswered rather than ever throwing at it. */
65
+ async function settle<T>(
66
+ ask: () => Promise<T> | undefined
67
+ ): Promise<T | undefined> {
68
+ try {
69
+ return await ask();
70
+ } catch (err) {
71
+ console.warn("[SessionUi] dialog sink failed:", err);
72
+ return undefined;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Widens the sink onto pi's `ExtensionUIContext`, no-opping everything a
78
+ * terminal owns exactly as pi's own RPC mode does. `sink` is read per call, so
79
+ * an agent rebuilt under a host keeps whatever its host was last handed.
80
+ *
81
+ * Every method has to be an *own* property: pi re-wraps what it is given with
82
+ * `{ ...ui }` (`runner.js:274`), which copies own enumerable keys only. A class
83
+ * instance would arrive with `notify` and the rest of the prototype missing.
84
+ */
85
+ export function adaptSessionUi(
86
+ sink: () => SessionUi | undefined
87
+ ): ExtensionUIContext {
88
+ return {
89
+ select: (title, options, opts) =>
90
+ settle(() => sink()?.select(title, options, opts)),
91
+ confirm: async (title, message, opts) =>
92
+ (await settle(() => sink()?.confirm(title, message, opts))) === true,
93
+ input: (title, placeholder, opts) =>
94
+ settle(() => sink()?.input(title, placeholder, opts)),
95
+ notify: (message, kind) => {
96
+ try {
97
+ sink()?.notify(message, severityOf(kind));
98
+ } catch (err) {
99
+ console.warn("[SessionUi] notify sink failed:", err);
100
+ }
101
+ },
102
+ onTerminalInput: () => () => {},
103
+ setStatus: () => {},
104
+ setWorkingMessage: () => {},
105
+ setWorkingVisible: () => {},
106
+ setWorkingIndicator: () => {},
107
+ setHiddenThinkingLabel: () => {},
108
+ setWidget: () => {},
109
+ setFooter: () => {},
110
+ setHeader: () => {},
111
+ setTitle: () => {},
112
+ custom: async <T>() => undefined as T,
113
+ pasteToEditor: () => {},
114
+ setEditorText: () => {},
115
+ getEditorText: () => "",
116
+ editor: async () => undefined,
117
+ addAutocompleteProvider: () => {},
118
+ setEditorComponent: () => {},
119
+ getEditorComponent: () => undefined,
120
+ theme: PLAIN_THEME,
121
+ getAllThemes: () => [],
122
+ getTheme: () => undefined,
123
+ setTheme: () => ({ success: false, error: "UI not available" }),
124
+ getToolsExpanded: () => false,
125
+ setToolsExpanded: () => {},
126
+ };
127
+ }
@@ -33,5 +33,19 @@ function foreignSince(mark: WriteMark, next: WriteMark): boolean {
33
33
  return next.head - base.head - (next.entries - base.entries) > 0;
34
34
  }
35
35
 
36
+ /**
37
+ * Whether every line the file gained since `mark` is one no reader has to replay.
38
+ * A rename appends `session_info` and moves no history, so a surface that absorbs
39
+ * it stays on the session it is already holding. Only ask between turns: a window
40
+ * that also holds our own appends is not benign, and a running turn's is.
41
+ */
42
+ async function benignSince(log: EventLog, mark: WriteMark): Promise<boolean> {
43
+ const added = await log.read(mark.head);
44
+ return (
45
+ added.length > 0 &&
46
+ added.every(({ entry }) => entry.type === "session_info")
47
+ );
48
+ }
49
+
36
50
  /** Write accounting over one session file: whose lines are these? */
37
- export const WriteMark = { UNREAD, of, foreignSince };
51
+ export const WriteMark = { UNREAD, of, foreignSince, benignSince };
@@ -301,4 +301,4 @@ function partLines(value: string): readonly string[] {
301
301
 
302
302
  const emptySide: ToolDiffSide = { lines: [], hasTrailingNewline: false };
303
303
 
304
- export const DiffLines = { buildToolDiff, fromText, emptySide };
304
+ export const DiffLines = { buildToolDiff, fromText, emptySide, attachEmphasis };
@@ -0,0 +1,70 @@
1
+ import * as Diff from "diff";
2
+ import {
3
+ DiffLines,
4
+ type ToolDiff,
5
+ type ToolDiffHunk,
6
+ type ToolDiffLine,
7
+ type ToolDiffLineKind,
8
+ } from "./DiffLines";
9
+
10
+ function fromUnified(path: string, patch: string): ToolDiff | undefined {
11
+ const hunks = Diff.parsePatch(patch)
12
+ .flatMap((file) => file.hunks)
13
+ .map(toHunk)
14
+ .filter((hunk) => hunk.lines.length > 0);
15
+
16
+ return hunks.length === 0 ? undefined : { path, hunks };
17
+ }
18
+
19
+ function toHunk(hunk: Diff.StructuredPatchHunk): ToolDiffHunk {
20
+ const lines: ToolDiffLine[] = [];
21
+ let oldLine = hunk.oldStart;
22
+ let newLine = hunk.newStart;
23
+
24
+ for (const raw of hunk.lines) {
25
+ if (raw.startsWith("\\")) {
26
+ continue;
27
+ }
28
+
29
+ const kind = kindOf(raw);
30
+ const text = raw.slice(1);
31
+
32
+ if (kind === "added") {
33
+ lines.push({ kind, newLine, text });
34
+ newLine += 1;
35
+ continue;
36
+ }
37
+
38
+ if (kind === "removed") {
39
+ lines.push({ kind, oldLine, text });
40
+ oldLine += 1;
41
+ continue;
42
+ }
43
+
44
+ lines.push({ kind, oldLine, newLine, text });
45
+ oldLine += 1;
46
+ newLine += 1;
47
+ }
48
+
49
+ return {
50
+ oldStart: hunk.oldStart,
51
+ oldLines: hunk.oldLines,
52
+ newStart: hunk.newStart,
53
+ newLines: hunk.newLines,
54
+ lines: DiffLines.attachEmphasis(lines),
55
+ };
56
+ }
57
+
58
+ function kindOf(line: string): ToolDiffLineKind {
59
+ if (line.startsWith("+")) {
60
+ return "added";
61
+ }
62
+
63
+ if (line.startsWith("-")) {
64
+ return "removed";
65
+ }
66
+
67
+ return "context";
68
+ }
69
+
70
+ export const DiffPatch = { fromUnified };
@@ -95,7 +95,7 @@ function highlightHunkLines(
95
95
  hunk: ToolDiffHunk,
96
96
  highlighter: DiffHighlighter
97
97
  ): readonly string[] {
98
- const highlighted = DiffLayout.mapSides(hunk, highlighter);
98
+ const highlighted = DiffLayout.mapSides(hunk.lines, highlighter);
99
99
  return hunk.lines.map((line, index) => highlighted[index] ?? line.text);
100
100
  }
101
101
 
@@ -1,5 +1,5 @@
1
- import { readdir, stat } from "node:fs/promises";
2
- import { dirname, isAbsolute, join, resolve } from "node:path";
1
+ import { mkdir, readdir, stat } from "node:fs/promises";
2
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3
3
 
4
4
  import { FsErrors } from "./FsErrors";
5
5
  import { Paths } from "./Paths";
@@ -29,12 +29,16 @@ async function check(path: string): Promise<string | undefined> {
29
29
  }
30
30
  }
31
31
 
32
- async function list(path: string): Promise<DirectoryListing> {
32
+ function rooted(path: string): string {
33
33
  const expanded = Paths.expandHome(path);
34
34
  if (!isAbsolute(expanded)) {
35
35
  throw new Error(`not an absolute path: ${path}`);
36
36
  }
37
- const resolved = resolve(expanded);
37
+ return resolve(expanded);
38
+ }
39
+
40
+ async function list(path: string): Promise<DirectoryListing> {
41
+ const resolved = rooted(path);
38
42
  const reason = await check(resolved);
39
43
  if (reason !== undefined) {
40
44
  throw new Error(reason);
@@ -58,4 +62,34 @@ async function list(path: string): Promise<DirectoryListing> {
58
62
  };
59
63
  }
60
64
 
61
- export const Directories = { check, list };
65
+ function refusal(error: unknown, path: string): string {
66
+ switch (FsErrors.code(error)) {
67
+ case "ENOENT":
68
+ return `path does not exist: ${dirname(path)}`;
69
+ case "EEXIST":
70
+ return `already exists: ${path}`;
71
+ case "ENOTDIR":
72
+ return `not a directory: ${dirname(path)}`;
73
+ case "EACCES":
74
+ case "EPERM":
75
+ return `permission denied: ${path}`;
76
+ default:
77
+ return `mkdir failed: ${(error as Error).message}`;
78
+ }
79
+ }
80
+
81
+ /** Makes one directory inside an existing one. Never recursive: a parent that is missing is said so, not invented. */
82
+ async function create(path: string): Promise<void> {
83
+ const name = basename(Paths.expandHome(path));
84
+ if (name === "" || name === "." || name === "..") {
85
+ throw new Error(`not a directory to create: ${path}`);
86
+ }
87
+ const resolved = rooted(path);
88
+ try {
89
+ await mkdir(resolved);
90
+ } catch (error) {
91
+ throw new Error(refusal(error, resolved));
92
+ }
93
+ }
94
+
95
+ export const Directories = { check, list, create };
@@ -34,6 +34,13 @@ const DEFAULT_DISABLED: readonly PimExtensionName[] = ["todo", "tps"];
34
34
 
35
35
  const REQUIRED: readonly PimExtensionName[] = ["_init", "pim", "session-lease"];
36
36
 
37
+ // Hardcoded, never derived: the browser switches these and nothing else, under
38
+ // the label it gives them — the terminal menu names them by key, this does not.
39
+ const WEB = [{ name: "todo", label: "Todo Tool" }] as const satisfies readonly {
40
+ name: PimExtensionName;
41
+ label: string;
42
+ }[];
43
+
37
44
  function isRequired(name: string): boolean {
38
45
  return (REQUIRED as readonly string[]).includes(name);
39
46
  }
@@ -119,6 +126,7 @@ function enabled(
119
126
  export const ExtensionToggles = {
120
127
  REQUIRED,
121
128
  NAMES,
129
+ WEB,
122
130
  isRequired,
123
131
  isKnown,
124
132
  describe,
@@ -8,6 +8,12 @@ import { basename, dirname, join } from "node:path";
8
8
 
9
9
  const POLL_MS = 1_000;
10
10
 
11
+ /**
12
+ * A burst coalesces into one event, and the read it triggers can land inside
13
+ * the burst — so look once more after it, or the last write waits on the poll.
14
+ */
15
+ const SETTLE_MS = 25;
16
+
11
17
  function signatureOf(path: string): string {
12
18
  const stats = statSync(path, { throwIfNoEntry: false });
13
19
  return stats === undefined
@@ -41,6 +47,13 @@ function follow(
41
47
  pollMs: number
42
48
  ): () => void {
43
49
  let watcher: FSWatcher | undefined;
50
+ let settle: ReturnType<typeof setTimeout> | undefined;
51
+ const fire = (): void => {
52
+ onEvent();
53
+ clearTimeout(settle);
54
+ settle = setTimeout(onEvent, SETTLE_MS);
55
+ settle.unref?.();
56
+ };
44
57
  const arm = (): void => {
45
58
  if (watcher !== undefined) {
46
59
  return;
@@ -48,7 +61,7 @@ function follow(
48
61
  try {
49
62
  watcher = watchFs(watched, { persistent: false }, (_event, changed) => {
50
63
  if (entry === undefined || changed === null || changed === entry) {
51
- onEvent();
64
+ fire();
52
65
  }
53
66
  });
54
67
  watcher.on("error", () => {
@@ -66,6 +79,7 @@ function follow(
66
79
  timer.unref?.();
67
80
  return (): void => {
68
81
  clearInterval(timer);
82
+ clearTimeout(settle);
69
83
  try {
70
84
  watcher?.close();
71
85
  } catch {}