pi-tian-background-terminals 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/index.ts ADDED
@@ -0,0 +1,584 @@
1
+ /**
2
+ * Background terminals — execute no-stdin shell commands that automatically
3
+ * yield into the background when they outlive a bounded initial wait.
4
+ *
5
+ * One tool for the LLM:
6
+ * - bash: overrides Pi's built-in bash, returns final output when the command
7
+ * finishes promptly, otherwise returns a terminal id and notifies exactly
8
+ * once when it exits. Inspection and termination remain user-owned via /ps.
9
+ *
10
+ * While ≥1 process runs, a one-line widget above the editor shows
11
+ * "N background terminal(s) running • /ps to view". `/ps` opens a two-stage
12
+ * full-screen overlay (list → read-only detail with stdout/stderr toggle).
13
+ *
14
+ * Architecture: Effect v4 core (manager service behind one ManagedRuntime);
15
+ * this file is the async boundary where tool handlers run effects via
16
+ * runTool. Node stream plumbing inside the manager is plain callbacks.
17
+ */
18
+
19
+ import * as fs from "node:fs";
20
+ import * as path from "node:path";
21
+ import type {
22
+ ExtensionAPI,
23
+ ExtensionContext,
24
+ ExtensionUIContext,
25
+ } from "@earendil-works/pi-coding-agent";
26
+ import {
27
+ createBashToolDefinition,
28
+ getMarkdownTheme,
29
+ SettingsManager,
30
+ } from "@earendil-works/pi-coding-agent";
31
+ import { Markdown, Text } from "@earendil-works/pi-tui";
32
+ import { Type } from "typebox";
33
+ import { SpawnError, type TerminalSnapshot } from "./src/domain.ts";
34
+ import {
35
+ DEFAULT_YIELD_TIME_MS,
36
+ MAX_RUNTIME_TIMEOUT_SECONDS,
37
+ MAX_YIELD_TIME_MS,
38
+ MIN_YIELD_TIME_MS,
39
+ TerminalManager,
40
+ type TerminalManagerShape,
41
+ } from "./src/manager.ts";
42
+ import {
43
+ BASH_PARAMETER_DESCRIPTIONS,
44
+ BASH_PROMPT_GUIDELINES,
45
+ BASH_PROMPT_SNIPPET,
46
+ BASH_TOOL_DESCRIPTION,
47
+ buildBashProgress,
48
+ buildBashResult,
49
+ buildTerminalResultMessage,
50
+ describeTerminal,
51
+ } from "./src/prompt.ts";
52
+ import { createDeferredResultDelivery } from "./src/result-delivery.ts";
53
+ import {
54
+ createTerminalRuntime,
55
+ runTool,
56
+ type TerminalRuntime,
57
+ } from "./src/runtime.ts";
58
+ import { sanitizeText } from "./src/ui/output-view.ts";
59
+ import { openTerminalPicker } from "./src/ui/ps.ts";
60
+
61
+ const WIDGET_KEY = "background-terminals";
62
+ const UPDATE_THROTTLE_MS = 100;
63
+ const SESSION_ENV_KEYS = [
64
+ "PI_SESSION_ID",
65
+ "PI_SESSION_FILE",
66
+ "PI_PROVIDER",
67
+ "PI_MODEL",
68
+ "PI_REASONING_LEVEL",
69
+ ] as const;
70
+
71
+ export interface BackgroundTerminalsDependencies {
72
+ readonly createRuntime?: typeof createTerminalRuntime;
73
+ readonly createForegroundBash?: typeof createBashToolDefinition;
74
+ readonly resolveShellSettings?: (ctx: ExtensionContext) => {
75
+ readonly shellPath?: string;
76
+ readonly commandPrefix?: string;
77
+ };
78
+ }
79
+
80
+ /** Dependency injection is public only so the pre-spawn fallback is testable. */
81
+ export function createBackgroundTerminalsExtension(
82
+ dependencies: BackgroundTerminalsDependencies = {},
83
+ ) {
84
+ const makeRuntime = dependencies.createRuntime ?? createTerminalRuntime;
85
+ const makeForegroundBash =
86
+ dependencies.createForegroundBash ?? createBashToolDefinition;
87
+ const resolveShellSettings =
88
+ dependencies.resolveShellSettings ??
89
+ ((ctx: ExtensionContext) => {
90
+ const settings = SettingsManager.create(ctx.cwd, undefined, {
91
+ projectTrusted: ctx.isProjectTrusted(),
92
+ });
93
+ return {
94
+ shellPath: settings.getShellPath(),
95
+ commandPrefix: settings.getShellCommandPrefix(),
96
+ };
97
+ });
98
+
99
+ return function backgroundTerminals(pi: ExtensionAPI) {
100
+ let runtime: TerminalRuntime | undefined;
101
+ let managerPromise: Promise<TerminalManagerShape> | undefined;
102
+ let sessionContext: ExtensionContext | undefined;
103
+ let ui: ExtensionUIContext | undefined;
104
+ let unsubStatus: (() => void) | undefined;
105
+ const resultDelivery = createDeferredResultDelivery<TerminalSnapshot>();
106
+
107
+ const getRuntime = () => (runtime ??= makeRuntime());
108
+
109
+ /** Resolve the manager service once per runtime and wire the extension hooks. */
110
+ const getManager = () => {
111
+ managerPromise ??= getRuntime()
112
+ .runPromise(TerminalManager)
113
+ .then((manager) => {
114
+ manager.view.setOnSettled(onSettled);
115
+ unsubStatus?.();
116
+ unsubStatus = manager.view.subscribe(() => updateWidget(manager));
117
+ updateWidget(manager);
118
+ return manager;
119
+ });
120
+ return managerPromise;
121
+ };
122
+
123
+ /** One-line widget directly above the editor, only while ≥1 is running.
124
+ * Called on every manager notification (including per-output-chunk), so it
125
+ * only touches setWidget when the running count actually changes —
126
+ * replacing the widget factory hundreds of times a second would churn
127
+ * component creation for no visible difference. */
128
+ let widgetRunning = 0;
129
+ const updateWidget = (manager: TerminalManagerShape) => {
130
+ if (!ui) return;
131
+ try {
132
+ const running = manager.view
133
+ .list()
134
+ .filter((snap) => snap.status === "running").length;
135
+ if (running === widgetRunning) return;
136
+ widgetRunning = running;
137
+ if (running === 0) {
138
+ ui.setWidget(WIDGET_KEY, undefined);
139
+ return;
140
+ }
141
+ ui.setWidget(WIDGET_KEY, (_tui, theme) => {
142
+ const line =
143
+ theme.fg("warning", "■ ") +
144
+ theme.fg(
145
+ "text",
146
+ `${running} background terminal${running === 1 ? "" : "s"} running`,
147
+ ) +
148
+ theme.fg("dim", " • ") +
149
+ theme.fg("accent", "/ps") +
150
+ theme.fg("dim", " to view");
151
+ return { render: () => [line], invalidate: () => {} };
152
+ });
153
+ } catch {
154
+ // UI may be unavailable (print/RPC modes or teardown).
155
+ }
156
+ };
157
+
158
+ const deliverResult = (snap: TerminalSnapshot) => {
159
+ try {
160
+ pi.sendMessage(
161
+ {
162
+ customType: "background-terminal-result",
163
+ content: buildTerminalResultMessage(snap),
164
+ display: true,
165
+ details: {
166
+ id: snap.id,
167
+ title: snap.title,
168
+ status: snap.status,
169
+ exitCode: snap.exitCode,
170
+ signal: snap.signal,
171
+ },
172
+ },
173
+ // followUp: queued until the agent has no more tool calls — never
174
+ // interrupts a mid-turn stream. triggerTurn: wakes the model
175
+ // immediately iff idle; if busy, the queued follow-up is delivered
176
+ // when the current run settles. Either way exactly one delivery.
177
+ { deliverAs: "followUp", triggerTurn: true },
178
+ );
179
+ return true;
180
+ } catch (error) {
181
+ // Session may be shutting down, but retain the snapshot so any later
182
+ // agent-settled flush can retry instead of silently dropping it.
183
+ console.error("background-terminals: failed to deliver result", error);
184
+ return false;
185
+ }
186
+ };
187
+
188
+ const flushResults = () => {
189
+ for (const snap of resultDelivery.drain()) {
190
+ if (!deliverResult(snap)) resultDelivery.defer(snap);
191
+ }
192
+ };
193
+
194
+ const onSettled = (snap: TerminalSnapshot, consumed: boolean) => {
195
+ if (consumed) {
196
+ // The initial bash wait is returning this settlement itself.
197
+ resultDelivery.consume([snap.id]);
198
+ return;
199
+ }
200
+ // Defer a deep-enough copy: the live snapshot's output views keep
201
+ // mutating (late flushes) after settle.
202
+ resultDelivery.defer({
203
+ ...snap,
204
+ stdout: { ...snap.stdout },
205
+ stderr: { ...snap.stderr },
206
+ });
207
+ if (sessionContext?.isIdle()) flushResults();
208
+ };
209
+
210
+ pi.on("session_start", (_event, ctx) => {
211
+ sessionContext = ctx;
212
+ if (ctx.hasUI) ui = ctx.ui;
213
+ });
214
+
215
+ // Drain deferred results when the agent settles: together with the
216
+ // isIdle() fast path above and the Map-keyed delivery (drain clears),
217
+ // double delivery is structurally impossible — whoever drains first wins.
218
+ pi.on("agent_settled", flushResults);
219
+
220
+ // /new, /resume, /fork, /reload, and quit all emit session_shutdown for
221
+ // the old extension instance. Processes never survive a session
222
+ // transition: disposing the runtime runs the manager finalizer →
223
+ // disposeAll → every entry scope → SIGTERM→SIGKILL tree kill, each close
224
+ // bounded so a wedged process cannot hang shutdown.
225
+ pi.on("session_shutdown", async () => {
226
+ sessionContext = undefined;
227
+ resultDelivery.clear();
228
+ unsubStatus?.();
229
+ unsubStatus = undefined;
230
+ try {
231
+ ui?.setWidget(WIDGET_KEY, undefined);
232
+ } catch {
233
+ // UI may already be gone.
234
+ }
235
+ widgetRunning = 0;
236
+ ui = undefined;
237
+ const closing = runtime;
238
+ runtime = undefined;
239
+ managerPromise = undefined;
240
+ await closing?.dispose();
241
+ });
242
+
243
+ // --- Tool --------------------------------------------------------------
244
+
245
+ pi.registerTool({
246
+ // Registering the built-in name is Pi's supported override mechanism.
247
+ // The model sees one canonical shell tool, not a second execution lane.
248
+ name: "bash",
249
+ label: "bash",
250
+ description: BASH_TOOL_DESCRIPTION,
251
+ promptSnippet: BASH_PROMPT_SNIPPET,
252
+ promptGuidelines: BASH_PROMPT_GUIDELINES,
253
+ parameters: Type.Object({
254
+ command: Type.String({
255
+ description: BASH_PARAMETER_DESCRIPTIONS.command,
256
+ }),
257
+ timeout: Type.Optional(
258
+ Type.Number({
259
+ exclusiveMinimum: 0,
260
+ maximum: MAX_RUNTIME_TIMEOUT_SECONDS,
261
+ description: BASH_PARAMETER_DESCRIPTIONS.timeout,
262
+ }),
263
+ ),
264
+ title: Type.Optional(
265
+ Type.String({
266
+ description: BASH_PARAMETER_DESCRIPTIONS.title,
267
+ }),
268
+ ),
269
+ working_dir: Type.Optional(
270
+ Type.String({
271
+ description: BASH_PARAMETER_DESCRIPTIONS.workingDir,
272
+ }),
273
+ ),
274
+ yield_time_ms: Type.Optional(
275
+ Type.Integer({
276
+ minimum: MIN_YIELD_TIME_MS,
277
+ maximum: MAX_YIELD_TIME_MS,
278
+ description: BASH_PARAMETER_DESCRIPTIONS.yieldTimeMs,
279
+ }),
280
+ ),
281
+ }),
282
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
283
+ // Preserve the exact command text. Trimming here can break heredocs and
284
+ // multiline scripts; trim only for validation and the display title.
285
+ const command = params.command;
286
+ if (!command.trim()) throw new Error("command must not be empty.");
287
+
288
+ if (
289
+ params.timeout !== undefined &&
290
+ (!Number.isFinite(params.timeout) ||
291
+ params.timeout <= 0 ||
292
+ params.timeout > MAX_RUNTIME_TIMEOUT_SECONDS)
293
+ ) {
294
+ throw new Error(
295
+ `timeout must be a finite number of seconds in (0, ${MAX_RUNTIME_TIMEOUT_SECONDS}].`,
296
+ );
297
+ }
298
+
299
+ const cwd = path.resolve(ctx.cwd, params.working_dir ?? ".");
300
+ try {
301
+ if (!fs.statSync(cwd).isDirectory()) {
302
+ throw new Error("not a directory");
303
+ }
304
+ } catch {
305
+ throw new Error(`working_dir is not a directory: ${cwd}`);
306
+ }
307
+
308
+ // Preserve Pi's built-in shellPath and shellCommandPrefix settings even
309
+ // though this extension replaces the built-in definition.
310
+ const { shellPath, commandPrefix } = resolveShellSettings(ctx);
311
+ const executionCommand = commandPrefix
312
+ ? `${commandPrefix}\n${command}`
313
+ : command;
314
+
315
+ // Collapse whitespace before bounding: titles render in one-line rows.
316
+ const title =
317
+ (params.title ?? command).replace(/\s+/g, " ").trim().slice(0, 80) ||
318
+ "command";
319
+
320
+ const runForegroundFallback = async (
321
+ reason: unknown,
322
+ resetManagedRuntime: boolean,
323
+ ) => {
324
+ if (resetManagedRuntime) {
325
+ const brokenRuntime = runtime;
326
+ runtime = undefined;
327
+ managerPromise = undefined;
328
+ await brokenRuntime?.dispose().catch(() => {});
329
+ }
330
+ const reasonText = reason instanceof Error ? reason.message : String(reason);
331
+ const warning =
332
+ `[Managed bash unavailable before spawn; using Pi's foreground bash fallback. ` +
333
+ `Automatic yielding and /ps tracking are unavailable for this call. Reason: ${reasonText.slice(0, 500)}]`;
334
+ if (ctx.hasUI) ctx.ui.notify(warning, "warning");
335
+
336
+ const fallback = makeForegroundBash(cwd, {
337
+ shellPath,
338
+ commandPrefix,
339
+ });
340
+ try {
341
+ const result = await fallback.execute(
342
+ toolCallId,
343
+ { command, timeout: params.timeout },
344
+ signal,
345
+ onUpdate,
346
+ ctx,
347
+ );
348
+ return {
349
+ ...result,
350
+ content: [
351
+ { type: "text" as const, text: warning },
352
+ ...result.content,
353
+ ],
354
+ };
355
+ } catch (error) {
356
+ const message = error instanceof Error ? error.message : String(error);
357
+ throw new Error(`${warning}\n\n${message}`);
358
+ }
359
+ };
360
+
361
+ let manager: TerminalManagerShape;
362
+ try {
363
+ manager = await getManager();
364
+ } catch (managerError) {
365
+ // Manager resolution precedes start(), so no child can exist yet.
366
+ return await runForegroundFallback(managerError, true);
367
+ }
368
+
369
+ const env = { ...process.env };
370
+ for (const key of SESSION_ENV_KEYS) delete env[key];
371
+ env.PI_SESSION_ID = ctx.sessionManager.getSessionId();
372
+ const sessionFile = ctx.sessionManager.getSessionFile();
373
+ if (sessionFile) env.PI_SESSION_FILE = sessionFile;
374
+ if (ctx.model) {
375
+ env.PI_PROVIDER = ctx.model.provider;
376
+ env.PI_MODEL = ctx.model.id;
377
+ }
378
+ const thinkingLevel = pi.getThinkingLevel();
379
+ if (thinkingLevel) env.PI_REASONING_LEVEL = thinkingLevel;
380
+
381
+ let started: TerminalSnapshot;
382
+ try {
383
+ started = await runTool(
384
+ getRuntime(),
385
+ manager.start({
386
+ command,
387
+ executionCommand,
388
+ shellPath,
389
+ title,
390
+ cwd,
391
+ env,
392
+ timeoutMs:
393
+ params.timeout === undefined ? undefined : params.timeout * 1000,
394
+ }),
395
+ );
396
+ } catch (error) {
397
+ if (error instanceof SpawnError && error.fallbackSafe) {
398
+ return await runForegroundFallback(error, false);
399
+ }
400
+ // Concurrency, shutdown, asynchronous spawn failure, non-zero exit,
401
+ // timeout, and abort are never retried.
402
+ throw error;
403
+ }
404
+
405
+ let updateTimer: NodeJS.Timeout | undefined;
406
+ let updateDirty = false;
407
+ let lastUpdateAt = 0;
408
+ const emitUpdate = () => {
409
+ if (!onUpdate || !updateDirty) return;
410
+ updateDirty = false;
411
+ lastUpdateAt = Date.now();
412
+ const snap = manager.view.get(started.id);
413
+ if (!snap || snap.status !== "running") return;
414
+ try {
415
+ onUpdate({
416
+ content: [{ type: "text", text: buildBashProgress(snap) }],
417
+ details: undefined,
418
+ });
419
+ } catch {
420
+ // A display update must never affect command execution.
421
+ }
422
+ };
423
+ const scheduleUpdate = () => {
424
+ if (!onUpdate) return;
425
+ updateDirty = true;
426
+ const delay = UPDATE_THROTTLE_MS - (Date.now() - lastUpdateAt);
427
+ if (delay <= 0) {
428
+ if (updateTimer) clearTimeout(updateTimer);
429
+ updateTimer = undefined;
430
+ emitUpdate();
431
+ return;
432
+ }
433
+ updateTimer ??= setTimeout(() => {
434
+ updateTimer = undefined;
435
+ emitUpdate();
436
+ }, delay);
437
+ };
438
+ const unsubscribe = manager.view.subscribeTo(started.id, scheduleUpdate);
439
+ if (onUpdate) {
440
+ try {
441
+ onUpdate({ content: [], details: undefined });
442
+ } catch {
443
+ // Same display-only boundary.
444
+ }
445
+ }
446
+
447
+ let waited;
448
+ try {
449
+ waited = await runTool(
450
+ getRuntime(),
451
+ manager.waitForSettlement(
452
+ started.id,
453
+ params.yield_time_ms ?? DEFAULT_YIELD_TIME_MS,
454
+ ),
455
+ {
456
+ signal,
457
+ interruptMessage: `Initial wait aborted; ${started.id} continues in the background and will report when it exits.`,
458
+ },
459
+ );
460
+ } finally {
461
+ unsubscribe();
462
+ if (updateTimer) clearTimeout(updateTimer);
463
+ }
464
+ const snap = waited.snapshot;
465
+
466
+ // A quick completion is returned by this tool call. Remove any already
467
+ // deferred result from the tiny start→wait registration race.
468
+ if (waited.settled || snap.status !== "running") {
469
+ resultDelivery.consume([snap.id]);
470
+ }
471
+
472
+ const text = buildBashResult(snap);
473
+ if (
474
+ snap.status === "failed" ||
475
+ snap.status === "timed_out" ||
476
+ snap.status === "killed"
477
+ ) {
478
+ // Match Pi's built-in bash contract: unsuccessful foreground results
479
+ // are tool errors. Yielded failures arrive later as completion messages.
480
+ throw new Error(text);
481
+ }
482
+ return {
483
+ content: [{ type: "text", text }],
484
+ // Exact BashToolDetails-compatible shape. Paths remain in bounded text
485
+ // because stdout and stderr have separate complete spill files.
486
+ details: undefined,
487
+ };
488
+ },
489
+ });
490
+
491
+ // --- Result message rendering ------------------------------------------
492
+
493
+ pi.registerMessageRenderer(
494
+ "background-terminal-result",
495
+ (message, { expanded }, theme) => {
496
+ const details = (message.details ?? {}) as {
497
+ id?: string;
498
+ title?: string;
499
+ status?: string;
500
+ exitCode?: number;
501
+ signal?: string;
502
+ };
503
+ const failed = details.status === "failed";
504
+ const timedOut = details.status === "timed_out";
505
+ const killed = details.status === "killed";
506
+ const icon = failed || timedOut
507
+ ? theme.fg("error", "x")
508
+ : killed
509
+ ? theme.fg("muted", "■")
510
+ : theme.fg("success", "■");
511
+ const how = killed
512
+ ? "killed"
513
+ : timedOut
514
+ ? "timed out"
515
+ : (details.signal ?? `exit ${details.exitCode ?? "?"}`);
516
+ const header =
517
+ `${icon} ` +
518
+ theme.fg("accent", theme.bold(`terminal ${details.id ?? "?"}`)) +
519
+ theme.fg("muted", ` · ${details.title ?? ""} · ${how}`);
520
+
521
+ const content =
522
+ typeof message.content === "string" ? message.content : "";
523
+ // Remove only the summary line; the Error line (when present) is part
524
+ // of the actual result and must remain visible. The body carries raw
525
+ // process output — sanitize ANSI/control chars or the transcript smears.
526
+ const body = sanitizeText(content.split("\n").slice(1).join("\n").trim());
527
+
528
+ if (expanded) {
529
+ const md = new Markdown(`${body}`, 0, 0, getMarkdownTheme());
530
+ const container = new Text(header, 0, 0);
531
+ return {
532
+ render: (width: number) => [
533
+ ...container.render(width),
534
+ ...md.render(width),
535
+ ],
536
+ invalidate: () => {
537
+ container.invalidate();
538
+ md.invalidate();
539
+ },
540
+ };
541
+ }
542
+
543
+ const previewLines = body.split("\n").slice(0, 8);
544
+ let text = header;
545
+ for (const line of previewLines)
546
+ text += `\n${theme.fg("toolOutput", line)}`;
547
+ if (body.split("\n").length > 8)
548
+ text += `\n${theme.fg("dim", "... (ctrl+o to expand)")}`;
549
+ return new Text(text, 0, 0);
550
+ },
551
+ );
552
+
553
+ // --- Command ------------------------------------------------------------
554
+
555
+ pi.registerCommand("ps", {
556
+ description: "List and inspect background terminals",
557
+ handler: async (_args, ctx) => {
558
+ const manager = await getManager();
559
+ if (ctx.mode !== "tui") {
560
+ if (ctx.hasUI) {
561
+ const terminals = manager.view.list();
562
+ ctx.ui.notify(
563
+ terminals.length === 0
564
+ ? "No background terminals."
565
+ : terminals.map((snap) => describeTerminal(snap)).join("\n"),
566
+ "info",
567
+ );
568
+ }
569
+ return;
570
+ }
571
+ if (manager.view.size() === 0) {
572
+ ctx.ui.notify(
573
+ "No background terminals yet. Long bash runs appear here.",
574
+ "info",
575
+ );
576
+ return;
577
+ }
578
+ await openTerminalPicker(ctx, manager.view);
579
+ },
580
+ });
581
+ };
582
+ }
583
+
584
+ export default createBackgroundTerminalsExtension();
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "pi-tian-background-terminals",
3
+ "version": "0.1.0",
4
+ "description": "Replace Pi's built-in Bash with automatic background yielding, exactly-once completion notifications, and a /ps viewer.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi",
8
+ "pi-package",
9
+ "pi-extension",
10
+ "pi-coding-agent",
11
+ "background",
12
+ "terminal",
13
+ "process",
14
+ "shell"
15
+ ],
16
+ "author": "Tian Zuo",
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/TianZuo555/pi-tian-extensions.git",
21
+ "directory": "packages/pi-background-terminals"
22
+ },
23
+ "homepage": "https://github.com/TianZuo555/pi-tian-extensions#background-terminals",
24
+ "bugs": {
25
+ "url": "https://github.com/TianZuo555/pi-tian-extensions/issues"
26
+ },
27
+ "files": [
28
+ "index.ts",
29
+ "src",
30
+ "docs"
31
+ ],
32
+ "pi": {
33
+ "extensions": [
34
+ "./index.ts"
35
+ ]
36
+ },
37
+ "scripts": {
38
+ "check": "tsc --noEmit -p .",
39
+ "prepare": "effect-tsgo patch",
40
+ "test": "node --test --experimental-strip-types index.test.ts manager.test.ts output.test.ts prompt.test.ts result-delivery.test.ts ps.test.ts"
41
+ },
42
+ "dependencies": {
43
+ "effect": "^4.0.0-beta.99"
44
+ },
45
+ "peerDependencies": {
46
+ "@earendil-works/pi-ai": "*",
47
+ "@earendil-works/pi-coding-agent": "*",
48
+ "@earendil-works/pi-tui": "*",
49
+ "typebox": "*"
50
+ },
51
+ "devDependencies": {
52
+ "@effect/language-service": "^0.87.0",
53
+ "@effect/tsgo": "^0.24.2",
54
+ "typescript": "^7.0.2"
55
+ },
56
+ "publishConfig": {
57
+ "access": "public"
58
+ }
59
+ }