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
@@ -0,0 +1,221 @@
1
+ import { join } from "node:path";
2
+
3
+ import { FileWatch } from "./FileWatch";
4
+ import { Git, type GitOutcome, type GitState } from "./Git";
5
+
6
+ export type GitListener = (state: GitState) => void;
7
+
8
+ /**
9
+ * What one operation under the lock says, and what it made where it made
10
+ * anything: a commit has a sha to hand back, a checkout has only its outcome.
11
+ */
12
+ export type GitRun<T = never> = GitOutcome & { readonly value?: T };
13
+
14
+ export type GitMonitorDeps = {
15
+ /** How long a fetch stands before another is worth its round trip. */
16
+ readonly fetchTtlMs?: number;
17
+ /** How often a watched repository is read for the edits `.git` never hears about. */
18
+ readonly pollMs?: number;
19
+ readonly status?: (cwd: string) => Promise<GitState>;
20
+ readonly fetch?: (cwd: string) => Promise<GitOutcome>;
21
+ };
22
+
23
+ type Entry = {
24
+ readonly cwd: string;
25
+ readonly listeners: Set<GitListener>;
26
+ state: GitState;
27
+ stop: (() => void) | undefined;
28
+ inFlight: Promise<GitState> | undefined;
29
+ pending: boolean;
30
+ fetchedAt: number;
31
+ locked: boolean;
32
+ /** How long the last read took, and when it ended: what the poll paces itself by. */
33
+ readMs: number;
34
+ readAt: number;
35
+ };
36
+
37
+ /** A checkout writes half the repository; one read has to stand for the burst. */
38
+ const DEBOUNCE_MS = 200;
39
+
40
+ const FETCH_TTL_MS = 30_000;
41
+
42
+ /** A worktree edit touches nothing under `.git`, so the watch alone would never see it. */
43
+ const POLL_MS = 2_000;
44
+
45
+ /** A repository too big to read in a tick is read this many times its own cost apart. */
46
+ const BACKOFF = 4;
47
+
48
+ function same(a: GitState, b: GitState): boolean {
49
+ return (
50
+ a.branch === b.branch &&
51
+ a.dirtyCount === b.dirtyCount &&
52
+ a.ahead === b.ahead &&
53
+ a.behind === b.behind &&
54
+ a.revision === b.revision
55
+ );
56
+ }
57
+
58
+ /**
59
+ * One reader of one repository, however many sessions share it: it watches
60
+ * `.git` so a commit anywhere reaches every surface, coalesces the burst a
61
+ * checkout makes into a single read, and serialises the operations that write,
62
+ * which cannot run two at a time over one index.
63
+ */
64
+ export class GitMonitor {
65
+ private readonly entries = new Map<string, Entry>();
66
+ private readonly deps: GitMonitorDeps;
67
+
68
+ public constructor(deps: GitMonitorDeps = {}) {
69
+ this.deps = deps;
70
+ }
71
+
72
+ /** Follows `cwd` until the returned stop is called; the listener hears only changes, after a read of its own. */
73
+ public watch(cwd: string, listener: GitListener): () => void {
74
+ const entry = this.entryOf(cwd);
75
+ entry.listeners.add(listener);
76
+ entry.stop ??= this.follow(entry);
77
+ void this.read(entry, false);
78
+ return () => {
79
+ entry.listeners.delete(listener);
80
+ if (entry.listeners.size === 0) {
81
+ entry.stop?.();
82
+ entry.stop = undefined;
83
+ }
84
+ };
85
+ }
86
+
87
+ /** The last state read, without waiting: a caller painting a frame takes what is known now. */
88
+ public stateOf(cwd: string): GitState {
89
+ return this.entries.get(cwd)?.state ?? Git.EMPTY;
90
+ }
91
+
92
+ /** Re-reads now; `fetch` also asks the remote, which is what ahead and behind are counted against. */
93
+ public async refresh(
94
+ cwd: string,
95
+ options: { readonly fetch?: boolean } = {}
96
+ ): Promise<GitState> {
97
+ const entry = this.entryOf(cwd);
98
+ const ttl = this.deps.fetchTtlMs ?? FETCH_TTL_MS;
99
+ if (
100
+ options.fetch === true &&
101
+ !entry.locked &&
102
+ Date.now() - entry.fetchedAt > ttl
103
+ ) {
104
+ entry.fetchedAt = Date.now();
105
+ await (this.deps.fetch ?? Git.fetch)(cwd).catch(() => undefined);
106
+ }
107
+ return await this.read(entry);
108
+ }
109
+
110
+ /** Runs one writing operation over `cwd`, refusing a second while it lasts and re-reading once it ends. */
111
+ public async run<T = never>(
112
+ cwd: string,
113
+ operation: () => Promise<GitRun<T>>
114
+ ): Promise<GitRun<T>> {
115
+ const entry = this.entryOf(cwd);
116
+ if (entry.locked) {
117
+ return {
118
+ ok: false,
119
+ error: "another git operation is already running in this directory",
120
+ };
121
+ }
122
+ entry.locked = true;
123
+ try {
124
+ return await operation();
125
+ } finally {
126
+ entry.locked = false;
127
+ await this.read(entry);
128
+ }
129
+ }
130
+
131
+ public dispose(): void {
132
+ for (const entry of this.entries.values()) {
133
+ entry.stop?.();
134
+ }
135
+ this.entries.clear();
136
+ }
137
+
138
+ private entryOf(cwd: string): Entry {
139
+ const found = this.entries.get(cwd);
140
+ if (found) {
141
+ return found;
142
+ }
143
+ const entry: Entry = {
144
+ cwd,
145
+ listeners: new Set(),
146
+ state: Git.EMPTY,
147
+ stop: undefined,
148
+ inFlight: undefined,
149
+ pending: false,
150
+ fetchedAt: 0,
151
+ locked: false,
152
+ readMs: 0,
153
+ readAt: 0,
154
+ };
155
+ this.entries.set(cwd, entry);
156
+ return entry;
157
+ }
158
+
159
+ private follow(entry: Entry): () => void {
160
+ let timer: ReturnType<typeof setTimeout> | undefined;
161
+ const stop = FileWatch.directory(join(entry.cwd, ".git"), () => {
162
+ clearTimeout(timer);
163
+ timer = setTimeout(() => {
164
+ void this.read(entry);
165
+ }, DEBOUNCE_MS);
166
+ timer.unref?.();
167
+ });
168
+ // `false`: a read already running is fresh enough for a tick, and a slow
169
+ // status must never queue another behind itself.
170
+ const poll = setInterval(() => {
171
+ if (Date.now() - entry.readAt < entry.readMs * BACKOFF) {
172
+ return;
173
+ }
174
+ void this.read(entry, false);
175
+ }, this.deps.pollMs ?? POLL_MS);
176
+ poll.unref?.();
177
+ return () => {
178
+ clearTimeout(timer);
179
+ clearInterval(poll);
180
+ stop();
181
+ };
182
+ }
183
+
184
+ /** `restart` re-reads behind a read already running: a change cannot be answered by a read that predates it, a new subscriber can. */
185
+ private read(entry: Entry, restart = true): Promise<GitState> {
186
+ if (entry.inFlight) {
187
+ entry.pending ||= restart;
188
+ return entry.inFlight;
189
+ }
190
+ const run = (async (): Promise<GitState> => {
191
+ const started = Date.now();
192
+ try {
193
+ let next = entry.state;
194
+ do {
195
+ entry.pending = false;
196
+ next = await (this.deps.status ?? Git.fetchStatus)(entry.cwd);
197
+ } while (entry.pending);
198
+ this.settle(entry, next);
199
+ return next;
200
+ } catch {
201
+ return entry.state;
202
+ } finally {
203
+ entry.inFlight = undefined;
204
+ entry.readAt = Date.now();
205
+ entry.readMs = entry.readAt - started;
206
+ }
207
+ })();
208
+ entry.inFlight = run;
209
+ return run;
210
+ }
211
+
212
+ private settle(entry: Entry, next: GitState): void {
213
+ if (same(entry.state, next)) {
214
+ return;
215
+ }
216
+ entry.state = next;
217
+ for (const listener of entry.listeners) {
218
+ listener(next);
219
+ }
220
+ }
221
+ }
@@ -27,4 +27,57 @@ function distance(left: string, right: string): number {
27
27
  return previous[right.length] ?? 0;
28
28
  }
29
29
 
30
- export const Levenshtein = { distance };
30
+ /**
31
+ * Damerau–Levenshtein, where a transposition is one edit rather than two, and
32
+ * a pair that cannot reach `max` answers `max + 1` without finishing the
33
+ * matrix — the shape a vocabulary scan wants, since almost every term it asks
34
+ * about is nowhere near.
35
+ */
36
+ function damerau(left: string, right: string, max: number): number {
37
+ const over = max + 1;
38
+ if (left === right) {
39
+ return 0;
40
+ }
41
+ if (Math.abs(left.length - right.length) > max) {
42
+ return over;
43
+ }
44
+
45
+ const width = right.length + 1;
46
+ let before = Array.from({ length: width }, () => 0);
47
+ let previous = Array.from({ length: width }, (_, index) => index);
48
+ let current = Array.from({ length: width }, () => 0);
49
+
50
+ for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
51
+ current[0] = leftIndex;
52
+ let best = leftIndex;
53
+
54
+ for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
55
+ const cost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
56
+ let value = Math.min(
57
+ previous[rightIndex]! + 1,
58
+ current[rightIndex - 1]! + 1,
59
+ previous[rightIndex - 1]! + cost
60
+ );
61
+ if (
62
+ leftIndex > 1 &&
63
+ rightIndex > 1 &&
64
+ left[leftIndex - 1] === right[rightIndex - 2] &&
65
+ left[leftIndex - 2] === right[rightIndex - 1]
66
+ ) {
67
+ value = Math.min(value, before[rightIndex - 2]! + 1);
68
+ }
69
+ current[rightIndex] = value;
70
+ best = Math.min(best, value);
71
+ }
72
+
73
+ if (best > max) {
74
+ return over;
75
+ }
76
+ [before, previous, current] = [previous, current, before];
77
+ }
78
+
79
+ const found = previous[right.length]!;
80
+ return found > max ? over : found;
81
+ }
82
+
83
+ export const Levenshtein = { distance, damerau };
@@ -0,0 +1,228 @@
1
+ import {
2
+ DefaultPackageManager,
3
+ type PackageSource,
4
+ type ResolvedResource,
5
+ SettingsManager,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { basename, dirname, extname, relative } from "node:path";
8
+
9
+ import { ExtensionToggles } from "./ExtensionToggles";
10
+ import { Fs } from "./Fs";
11
+ import { Paths } from "./Paths";
12
+
13
+ /**
14
+ * Every origin an entry comes from, in the order a reader meets them: the
15
+ * roster is sorted by it, and a pane groups what it is handed without
16
+ * restating it.
17
+ */
18
+ const EXTENSION_GROUPS = ["pim", "package", "user", "project"] as const;
19
+
20
+ export type ExtensionGroup = (typeof EXTENSION_GROUPS)[number];
21
+
22
+ export type ExtensionEntry = {
23
+ readonly id: string;
24
+ readonly label: string;
25
+ readonly group: ExtensionGroup;
26
+ readonly enabled: boolean;
27
+ /** Whether this process will write the toggle; a project-scoped entry is listed but not switched. */
28
+ readonly writable: boolean;
29
+ };
30
+
31
+ export type ExtensionScope = {
32
+ readonly cwd: string;
33
+ readonly agentDir: string;
34
+ };
35
+
36
+ type Resolution = {
37
+ readonly extensions: readonly ResolvedResource[];
38
+ readonly settingsManager: SettingsManager;
39
+ };
40
+
41
+ type PackageFilter = Exclude<PackageSource, string>;
42
+
43
+ const FILTER_KEYS = ["extensions", "skills", "prompts", "themes"] as const;
44
+
45
+ const writes = Fs.serialised();
46
+
47
+ async function list(scope: ExtensionScope): Promise<readonly ExtensionEntry[]> {
48
+ const [off, { extensions }] = await Promise.all([
49
+ ExtensionToggles.disabled(),
50
+ resolve(scope),
51
+ ]);
52
+ const disabled = new Set(off);
53
+ const pim = ExtensionToggles.WEB.map((allowed) =>
54
+ pimEntry(allowed, !disabled.has(allowed.name))
55
+ );
56
+ const pi = extensions
57
+ .filter((resource) => resource.metadata.scope !== "temporary")
58
+ .map(piEntry)
59
+ .sort(byGroupThenLabel);
60
+ return [...pim, ...pi];
61
+ }
62
+
63
+ async function setEnabled(
64
+ id: string,
65
+ enabled: boolean,
66
+ scope: ExtensionScope
67
+ ): Promise<void> {
68
+ const pim = ExtensionToggles.WEB.find((entry) => `pim:${entry.name}` === id);
69
+ if (pim) {
70
+ await ExtensionToggles.setDisabled(pim.name, !enabled);
71
+ return;
72
+ }
73
+ if (!id.startsWith("pi:")) {
74
+ throw new Error(`Unknown extension "${id}"`);
75
+ }
76
+ await writes.run(() => writePiToggle(id.slice("pi:".length), enabled, scope));
77
+ }
78
+
79
+ // A fresh SettingsManager per write: `setExtensionPaths` persists its in-memory
80
+ // copy, which another process may have superseded since we last read it.
81
+ async function writePiToggle(
82
+ path: string,
83
+ enabled: boolean,
84
+ scope: ExtensionScope
85
+ ): Promise<void> {
86
+ const { extensions, settingsManager } = await resolve(scope);
87
+ const resource = extensions.find((candidate) => candidate.path === path);
88
+ if (!resource) {
89
+ throw new Error(`Unknown extension "pi:${path}"`);
90
+ }
91
+ const { metadata } = resource;
92
+ if (metadata.scope !== "user") {
93
+ throw new Error(
94
+ `"${path}" is ${metadata.scope}-scoped and cannot be switched from here`
95
+ );
96
+ }
97
+ if (metadata.origin === "package") {
98
+ togglePackage(
99
+ settingsManager,
100
+ metadata.source,
101
+ relative(metadata.baseDir ?? dirname(path), path),
102
+ enabled
103
+ );
104
+ } else {
105
+ toggleTopLevel(
106
+ settingsManager,
107
+ relative(metadata.baseDir ?? scope.agentDir, path),
108
+ enabled
109
+ );
110
+ }
111
+ await settingsManager.flush();
112
+ const failure = settingsManager.drainErrors()[0];
113
+ if (failure) {
114
+ throw new Error(`Failed to write pi settings: ${failure.error.message}`);
115
+ }
116
+ }
117
+
118
+ function toggleTopLevel(
119
+ settingsManager: SettingsManager,
120
+ pattern: string,
121
+ enabled: boolean
122
+ ): void {
123
+ const current = settingsManager.getGlobalSettings().extensions ?? [];
124
+ settingsManager.setExtensionPaths(withPattern(current, pattern, enabled));
125
+ }
126
+
127
+ function togglePackage(
128
+ settingsManager: SettingsManager,
129
+ source: string,
130
+ pattern: string,
131
+ enabled: boolean
132
+ ): void {
133
+ const packages = [...(settingsManager.getGlobalSettings().packages ?? [])];
134
+ const index = packages.findIndex((entry) => sourceOf(entry) === source);
135
+ const entry = packages[index];
136
+ if (entry === undefined) {
137
+ throw new Error(`No configured package provides "${source}"`);
138
+ }
139
+ const pkg: PackageFilter =
140
+ typeof entry === "string" ? { source: entry } : { ...entry };
141
+ const updated = withPattern(pkg.extensions ?? [], pattern, enabled);
142
+ pkg.extensions = updated.length > 0 ? updated : undefined;
143
+ packages[index] = FILTER_KEYS.some((key) => pkg[key] !== undefined)
144
+ ? pkg
145
+ : pkg.source;
146
+ settingsManager.setPackages(packages);
147
+ }
148
+
149
+ function withPattern(
150
+ current: readonly string[],
151
+ pattern: string,
152
+ enabled: boolean
153
+ ): string[] {
154
+ const kept = current.filter((entry) => stripMarker(entry) !== pattern);
155
+ return [...kept, `${enabled ? "+" : "-"}${pattern}`];
156
+ }
157
+
158
+ function stripMarker(entry: string): string {
159
+ return /^[!+-]/.test(entry) ? entry.slice(1) : entry;
160
+ }
161
+
162
+ function sourceOf(entry: PackageSource): string {
163
+ return typeof entry === "string" ? entry : entry.source;
164
+ }
165
+
166
+ async function resolve(scope: ExtensionScope): Promise<Resolution> {
167
+ const { cwd, agentDir } = scope;
168
+ const settingsManager = SettingsManager.create(cwd, agentDir);
169
+ const manager = new DefaultPackageManager({
170
+ cwd,
171
+ agentDir,
172
+ settingsManager,
173
+ });
174
+ // Without an answer, pi npm-installs every missing or drifted package, so a
175
+ // bare listing request would fire off an install.
176
+ const resolved = await manager.resolve(async () => "skip");
177
+ return { extensions: resolved.extensions, settingsManager };
178
+ }
179
+
180
+ function pimEntry(
181
+ allowed: (typeof ExtensionToggles.WEB)[number],
182
+ enabled: boolean
183
+ ): ExtensionEntry {
184
+ return {
185
+ id: `pim:${allowed.name}`,
186
+ label: allowed.label,
187
+ group: "pim",
188
+ enabled,
189
+ writable: true,
190
+ };
191
+ }
192
+
193
+ function piEntry(resource: ResolvedResource): ExtensionEntry {
194
+ const { path, metadata } = resource;
195
+ const fromPackage = metadata.origin === "package";
196
+ const group = fromPackage
197
+ ? "package"
198
+ : metadata.scope === "project"
199
+ ? "project"
200
+ : "user";
201
+ return {
202
+ id: `pi:${path}`,
203
+ label: fromPackage
204
+ ? metadata.source.replace(/^(?:npm|git):/, "")
205
+ : fileLabel(path),
206
+ group,
207
+ enabled: resource.enabled,
208
+ // Scope, not group: a package a project configures is written to the repo's
209
+ // settings, which a browser toggle never touches.
210
+ writable: metadata.scope === "user",
211
+ };
212
+ }
213
+
214
+ function fileLabel(path: string): string {
215
+ const base = basename(path, extname(path));
216
+ return base === "index" ? basename(dirname(path)) : base;
217
+ }
218
+
219
+ function byGroupThenLabel(left: ExtensionEntry, right: ExtensionEntry): number {
220
+ const byGroup =
221
+ EXTENSION_GROUPS.indexOf(left.group) -
222
+ EXTENSION_GROUPS.indexOf(right.group);
223
+ return byGroup === 0
224
+ ? Paths.compare(left.label.toLowerCase(), right.label.toLowerCase())
225
+ : byGroup;
226
+ }
227
+
228
+ export const PiExtensions = { list, setEnabled };
@@ -1,12 +1,17 @@
1
1
  export type ProcOptions = {
2
2
  readonly cwd?: string | undefined;
3
3
  readonly stdout?: "pipe" | "inherit";
4
+ /** Merged over the process environment; a key set to `undefined` is removed from it. */
5
+ readonly env?: Readonly<Record<string, string | undefined>>;
6
+ /** Kills the child once it elapses, answering `timedOut`. */
7
+ readonly timeoutMs?: number;
4
8
  };
5
9
 
6
10
  export type ProcResult = {
7
11
  readonly code: number;
8
12
  readonly stdout: string;
9
13
  readonly stderr: string;
14
+ readonly timedOut: boolean;
10
15
  };
11
16
 
12
17
  async function run(
@@ -17,14 +22,29 @@ async function run(
17
22
  cwd: options.cwd,
18
23
  stdout: options.stdout ?? "pipe",
19
24
  stderr: "pipe",
25
+ // `undefined` is Bun's own "inherit this process's environment".
26
+ env:
27
+ options.env === undefined
28
+ ? undefined
29
+ : { ...process.env, ...options.env },
20
30
  });
31
+ let timedOut = false;
32
+ const timer =
33
+ options.timeoutMs === undefined
34
+ ? undefined
35
+ : setTimeout(() => {
36
+ timedOut = true;
37
+ proc.kill();
38
+ }, options.timeoutMs);
21
39
  const [stdout, stderr] = await Promise.all([
22
40
  proc.stdout instanceof ReadableStream
23
41
  ? new Response(proc.stdout).text()
24
42
  : "",
25
43
  new Response(proc.stderr).text(),
26
44
  ]);
27
- return { code: await proc.exited, stdout, stderr };
45
+ const code = await proc.exited;
46
+ clearTimeout(timer);
47
+ return { code, stdout, stderr, timedOut };
28
48
  }
29
49
 
30
50
  export const Proc = { run };