@oh-my-pi/pi-coding-agent 17.1.0 → 17.1.1

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 (112) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/cli.js +3686 -4013
  3. package/dist/types/config/settings-schema.d.ts +58 -0
  4. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +3 -0
  5. package/dist/types/live/attestation.d.ts +2 -0
  6. package/dist/types/live/controller.d.ts +10 -2
  7. package/dist/types/live/protocol.d.ts +1 -1
  8. package/dist/types/live/transport.d.ts +6 -19
  9. package/dist/types/live/visualizer.d.ts +8 -11
  10. package/dist/types/modes/components/assistant-message.d.ts +1 -0
  11. package/dist/types/modes/components/custom-message.d.ts +1 -1
  12. package/dist/types/modes/components/message-frame.d.ts +8 -4
  13. package/dist/types/modes/components/session-account-selector.d.ts +11 -0
  14. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  15. package/dist/types/modes/interactive-mode.d.ts +1 -0
  16. package/dist/types/modes/types.d.ts +1 -0
  17. package/dist/types/session/agent-session-types.d.ts +8 -1
  18. package/dist/types/session/agent-session.d.ts +20 -1
  19. package/dist/types/session/auth-storage.d.ts +1 -1
  20. package/dist/types/session/eval-runner.d.ts +2 -0
  21. package/dist/types/session/messages.d.ts +2 -0
  22. package/dist/types/session/session-tools.d.ts +15 -0
  23. package/dist/types/session/streaming-output.d.ts +8 -0
  24. package/dist/types/slash-commands/helpers/session-pin.d.ts +9 -0
  25. package/dist/types/stt/index.d.ts +0 -2
  26. package/dist/types/stt/stt-controller.d.ts +7 -0
  27. package/dist/types/tiny/title-client.d.ts +10 -0
  28. package/dist/types/tools/builtin-names.d.ts +1 -1
  29. package/dist/types/tools/computer/protocol.d.ts +43 -0
  30. package/dist/types/tools/computer/supervisor.d.ts +32 -0
  31. package/dist/types/tools/computer/worker-entry.d.ts +1 -0
  32. package/dist/types/tools/computer/worker.d.ts +15 -0
  33. package/dist/types/tools/computer-renderer.d.ts +22 -0
  34. package/dist/types/tools/computer.d.ts +71 -0
  35. package/dist/types/tools/context.d.ts +2 -0
  36. package/dist/types/tools/default-renderer.d.ts +21 -0
  37. package/dist/types/tools/index.d.ts +2 -0
  38. package/dist/types/tts/streaming-player.d.ts +10 -43
  39. package/dist/types/utils/tools-manager.d.ts +1 -2
  40. package/package.json +12 -12
  41. package/src/cli/args.ts +1 -0
  42. package/src/cli/setup-cli.ts +2 -14
  43. package/src/cli.ts +8 -0
  44. package/src/config/model-registry.ts +6 -0
  45. package/src/config/settings-schema.ts +61 -0
  46. package/src/eval/executor-base.ts +1 -0
  47. package/src/eval/js/executor.ts +2 -0
  48. package/src/exec/bash-executor.ts +1 -0
  49. package/src/extensibility/extensions/wrapper.ts +68 -12
  50. package/src/extensibility/legacy-pi-coding-agent-shim.ts +7 -1
  51. package/src/live/attestation.ts +91 -0
  52. package/src/live/controller.ts +76 -23
  53. package/src/live/protocol.test.ts +3 -3
  54. package/src/live/protocol.ts +1 -1
  55. package/src/live/transport.ts +72 -140
  56. package/src/live/visualizer.ts +114 -134
  57. package/src/modes/components/assistant-message.ts +7 -2
  58. package/src/modes/components/custom-message.ts +4 -1
  59. package/src/modes/components/message-frame.ts +14 -8
  60. package/src/modes/components/session-account-selector.ts +62 -0
  61. package/src/modes/components/tool-execution.ts +17 -110
  62. package/src/modes/controllers/input-controller.ts +47 -39
  63. package/src/modes/controllers/live-command-controller.ts +82 -5
  64. package/src/modes/controllers/selector-controller.ts +62 -0
  65. package/src/modes/interactive-mode.ts +19 -0
  66. package/src/modes/types.ts +1 -0
  67. package/src/prompts/system/computer-safety.md +14 -0
  68. package/src/prompts/tools/computer.md +26 -0
  69. package/src/sdk.ts +5 -0
  70. package/src/session/agent-session-types.ts +9 -0
  71. package/src/session/agent-session.ts +56 -0
  72. package/src/session/auth-storage.ts +1 -0
  73. package/src/session/eval-runner.ts +5 -0
  74. package/src/session/messages.ts +3 -0
  75. package/src/session/session-tools.ts +37 -0
  76. package/src/session/streaming-output.ts +52 -5
  77. package/src/slash-commands/builtin-registry.ts +165 -9
  78. package/src/slash-commands/helpers/session-pin.ts +44 -0
  79. package/src/stt/downloader.ts +0 -2
  80. package/src/stt/index.ts +0 -2
  81. package/src/stt/stt-controller.ts +57 -146
  82. package/src/system-prompt.ts +4 -0
  83. package/src/tiny/title-client.ts +22 -0
  84. package/src/tools/bash-interactive.ts +90 -86
  85. package/src/tools/builtin-names.ts +1 -0
  86. package/src/tools/computer/protocol.ts +28 -0
  87. package/src/tools/computer/supervisor.ts +258 -0
  88. package/src/tools/computer/worker-entry.ts +25 -0
  89. package/src/tools/computer/worker.ts +135 -0
  90. package/src/tools/computer-renderer.ts +108 -0
  91. package/src/tools/computer.ts +433 -0
  92. package/src/tools/context.ts +2 -0
  93. package/src/tools/default-renderer.ts +139 -0
  94. package/src/tools/essential-tools.ts +1 -0
  95. package/src/tools/index.ts +5 -0
  96. package/src/tools/renderers.ts +2 -0
  97. package/src/tools/xdev.ts +54 -26
  98. package/src/tts/streaming-player.ts +81 -340
  99. package/src/utils/clipboard.ts +1 -30
  100. package/src/utils/mac-file-urls.applescript +37 -0
  101. package/src/utils/tool-choice.ts +14 -0
  102. package/src/utils/tools-manager.ts +1 -19
  103. package/dist/types/stt/recorder.d.ts +0 -30
  104. package/dist/types/stt/transcriber.d.ts +0 -14
  105. package/dist/types/stt/wav.d.ts +0 -29
  106. package/dist/types/tts/player.d.ts +0 -32
  107. package/src/live/audio-worklet.txt +0 -59
  108. package/src/live/browser-runtime.txt +0 -221
  109. package/src/stt/recorder.ts +0 -551
  110. package/src/stt/transcriber.ts +0 -60
  111. package/src/stt/wav.ts +0 -173
  112. package/src/tts/player.ts +0 -137
@@ -1,22 +1,10 @@
1
- import * as fs from "node:fs/promises";
2
- import * as os from "node:os";
3
- import * as path from "node:path";
4
- import { logger, Snowflake } from "@oh-my-pi/pi-utils";
1
+ import { AudioCapture } from "@oh-my-pi/pi-natives";
2
+ import { logger } from "@oh-my-pi/pi-utils";
5
3
  import { settings } from "../config/settings";
6
4
  import { type SttStreamHandle, sttClient } from "./asr-client";
7
5
  import { downloadSttModel, isSttModelCached } from "./downloader";
8
6
  import { resolveSttModelSpec } from "./models";
9
- import {
10
- detectRecorder,
11
- ensureRecorder,
12
- type RecordingHandle,
13
- type StreamingRecordingHandle,
14
- startRecording,
15
- startStreamingRecording,
16
- verifyRecordingFile,
17
- } from "./recorder";
18
7
  import { evaluateSubmitTrigger } from "./submit-trigger";
19
- import { transcribe } from "./transcriber";
20
8
 
21
9
  export type SttState = "idle" | "recording" | "transcribing";
22
10
 
@@ -38,26 +26,34 @@ interface Editor {
38
26
  deleteBeforeCursor(count: number): void;
39
27
  }
40
28
 
29
+ interface CaptureHandle {
30
+ stop(): void;
31
+ }
32
+
33
+ type CaptureFactory = (onAudio: (error: Error | null, samples: Float32Array) => void) => CaptureHandle;
34
+
35
+ /** Coordinates native microphone capture with incremental local transcription. */
41
36
  export class STTController {
42
37
  #state: SttState = "idle";
43
38
  #resolvedModelKey: string | null = null;
44
39
  #toggling = false;
45
40
  #stopAfterStart = false;
46
41
  #disposed = false;
47
-
48
- // Batch (single-shot) capture.
49
- #recordingHandle: RecordingHandle | null = null;
50
- #tempFile: string | null = null;
51
- #transcriptionAbort: AbortController | null = null;
42
+ readonly #createCapture: CaptureFactory;
52
43
 
53
44
  // Live streaming capture.
54
45
  #stream: SttStreamHandle | null = null;
55
- #streamRecorder: StreamingRecordingHandle | null = null;
46
+ #streamRecorder: CaptureHandle | null = null;
56
47
  #streamEditor: Editor | null = null;
57
48
  #streamCommitted = false;
58
49
  #streamAbort: AbortController | null = null;
59
50
  #streamUtterance = "";
60
51
 
52
+ /** Creates a controller; tests may replace the hardware capture boundary. */
53
+ constructor(createCapture: CaptureFactory = onAudio => new AudioCapture(16_000, onAudio)) {
54
+ this.#createCapture = createCapture;
55
+ }
56
+
61
57
  get state(): SttState {
62
58
  return this.#state;
63
59
  }
@@ -79,7 +75,7 @@ export class STTController {
79
75
  await this.#start(editor, options);
80
76
  break;
81
77
  case "recording":
82
- await this.#stop(editor, options);
78
+ await this.#stop(options);
83
79
  break;
84
80
  case "transcribing":
85
81
  options.showStatus("Transcription in progress...");
@@ -87,7 +83,7 @@ export class STTController {
87
83
  }
88
84
  if (this.#stopAfterStart && this.#state === "recording") {
89
85
  this.#stopAfterStart = false;
90
- await this.#stop(editor, options);
86
+ await this.#stop(options);
91
87
  } else if (this.#state !== "recording") {
92
88
  this.#stopAfterStart = false;
93
89
  }
@@ -103,19 +99,13 @@ export class STTController {
103
99
  // (with progress) instead of blocking silently at stop.
104
100
  if (this.#resolvedModelKey === modelKey) return true;
105
101
  try {
106
- // Only clear the status line if we actually wrote to it: the cached
107
- // fast path (recorder on PATH, model present) emits nothing, so an
108
- // unconditional clear would be a stray write.
102
+ // Only clear the status line when preflight emitted progress; the
103
+ // cached-model fast path emits nothing.
109
104
  let wroteStatus = false;
110
105
  const status = (msg: string): void => {
111
106
  wroteStatus = true;
112
107
  options.showStatus(msg);
113
108
  };
114
- // A recorder is required to capture audio; startRecording /
115
- // startStreamingRecording only *detect* a recorder and throw when none
116
- // exists, so provision one here. Instant when sox/ffmpeg/arecord is on
117
- // PATH — only a first-run static-ffmpeg download actually blocks.
118
- await ensureRecorder(p => status(p.stage + (p.percent != null ? ` (${p.percent}%)` : "")));
119
109
  // Loading the multi-hundred-MB speech model into the worker is what made
120
110
  // the old "Checking STT dependencies…" step slow. Don't pay it before
121
111
  // recording: when the weights are already cached, start now and warm the
@@ -157,30 +147,15 @@ export class STTController {
157
147
 
158
148
  async #start(editor: Editor, options: ToggleOptions): Promise<void> {
159
149
  if (!(await this.#ensureDeps(options))) return;
160
- // Live transcription needs a recorder that can pipe PCM; the Windows
161
- // PowerShell mci fallback records to a file, so it stays single-shot.
162
- if (this.#recorderCanStream()) {
163
- await this.#startStreaming(editor, options);
164
- return;
165
- }
166
- await this.#startBatchRecording(options);
150
+ await this.#startStreaming(editor, options);
167
151
  }
168
152
 
169
- async #stop(editor: Editor, options: ToggleOptions): Promise<void> {
170
- if (this.#stream) {
171
- await this.#stopStreaming(options);
172
- return;
173
- }
174
- await this.#stopBatch(editor, options);
153
+ async #stop(options: ToggleOptions): Promise<void> {
154
+ await this.#stopStreaming(options);
175
155
  }
176
156
 
177
157
  // ── Live streaming ──────────────────────────────────────────────
178
158
 
179
- #recorderCanStream(): boolean {
180
- const recorder = detectRecorder();
181
- return recorder !== null && recorder.tool !== "powershell";
182
- }
183
-
184
159
  /** Segment text gets a leading space once a prior segment is committed, so
185
160
  * phrases join naturally; the first phrase is inserted at the cursor as-is. */
186
161
  #prefixed(text: string): string {
@@ -218,18 +193,38 @@ export class STTController {
218
193
  },
219
194
  });
220
195
  this.#stream = stream;
221
- let recorder: StreamingRecordingHandle | null = null;
196
+ let recorder: CaptureHandle;
222
197
  try {
223
- recorder = await startStreamingRecording(samples => stream.pushAudio(samples));
224
- } catch (err) {
225
- logger.warn("STT streaming recorder failed to start; falling back to batch recording", {
226
- error: err instanceof Error ? err.message : String(err),
198
+ recorder = this.#createCapture((error, samples) => {
199
+ if (this.#disposed || this.#stream !== stream || this.#state !== "recording") return;
200
+ if (error) {
201
+ logger.error("Native microphone capture failed", { error: error.message });
202
+ const activeRecorder = this.#streamRecorder;
203
+ this.#streamRecorder = null;
204
+ try {
205
+ activeRecorder?.stop();
206
+ } catch (cause) {
207
+ logger.debug("stt: microphone cleanup failed", {
208
+ error: cause instanceof Error ? cause.message : String(cause),
209
+ });
210
+ }
211
+ this.#streamAbort?.abort(error);
212
+ stream.cancel();
213
+ this.#streamEditor?.clearVolatileText();
214
+ options.requestRender?.();
215
+ this.#cleanupStream();
216
+ this.#setState("idle", options);
217
+ options.showWarning(error.message);
218
+ return;
219
+ }
220
+ stream.pushAudio(samples);
227
221
  });
228
- }
229
- if (!recorder) {
222
+ } catch (err) {
230
223
  stream.cancel();
231
224
  this.#cleanupStream();
232
- await this.#startBatchRecording(options);
225
+ const msg = err instanceof Error ? err.message : "Failed to start microphone capture";
226
+ options.showWarning(msg);
227
+ logger.error("STT recording failed to start", { error: msg });
233
228
  return;
234
229
  }
235
230
  this.#streamRecorder = recorder;
@@ -247,7 +242,7 @@ export class STTController {
247
242
  this.#setState("transcribing", options);
248
243
  // Stop the mic first so no further audio is fed, then flush the worker.
249
244
  try {
250
- await recorder?.stop();
245
+ recorder?.stop();
251
246
  } catch (err) {
252
247
  logger.debug("stt: streaming recorder stop failed", {
253
248
  error: err instanceof Error ? err.message : String(err),
@@ -306,103 +301,19 @@ export class STTController {
306
301
  this.#streamUtterance = "";
307
302
  }
308
303
 
309
- // ── Batch (single-shot) ─────────────────────────────────────────
310
-
311
- async #startBatchRecording(options: ToggleOptions): Promise<void> {
312
- const id = Snowflake.next();
313
- this.#tempFile = path.join(os.tmpdir(), `omp-stt-${id}.wav`);
314
- try {
315
- this.#recordingHandle = await startRecording(this.#tempFile);
316
- this.#setState("recording", options);
317
- logger.debug("STT recording started", { tempFile: this.#tempFile });
318
- } catch (err) {
319
- this.#tempFile = null;
320
- const msg = err instanceof Error ? err.message : "Failed to start recording";
321
- options.showWarning(msg);
322
- logger.error("STT recording failed to start", { error: msg });
323
- }
324
- }
325
-
326
- async #stopBatch(editor: Editor, options: ToggleOptions): Promise<void> {
327
- const handle = this.#recordingHandle;
328
- const tempFile = this.#tempFile;
329
- this.#recordingHandle = null;
330
-
331
- if (!handle || !tempFile) {
332
- this.#setState("idle", options);
333
- return;
334
- }
335
-
336
- try {
337
- await handle.stop();
338
- // Validate the recording produced a usable file
339
- await verifyRecordingFile(tempFile);
340
- this.#setState("transcribing", options);
341
-
342
- const sttSettings = {
343
- modelName: settings.get("stt.modelName") as string | undefined,
344
- language: settings.get("stt.language") as string | undefined,
345
- };
346
- this.#transcriptionAbort = new AbortController();
347
- const text = await transcribe(tempFile, { ...sttSettings, signal: this.#transcriptionAbort.signal });
348
- this.#transcriptionAbort = null;
349
- if (this.#disposed) return;
350
- if (text.length > 0) {
351
- const trigger = settings.get("stt.submitTrigger");
352
- const { submit, trimTrailing } = evaluateSubmitTrigger(text, trigger);
353
- const textToInsert = trimTrailing > 0 ? text.slice(0, -trimTrailing) : text;
354
- if (textToInsert.length > 0) {
355
- editor.insertText(textToInsert);
356
- }
357
- options.showStatus("");
358
- if (submit) {
359
- editor.submit();
360
- }
361
- } else {
362
- options.showStatus("No speech detected.");
363
- }
364
- if (!this.#disposed) this.#setState("idle", options);
365
- } catch (err) {
366
- if (this.#disposed) return;
367
- if (err instanceof DOMException && err.name === "AbortError") {
368
- this.#setState("idle", options);
369
- return;
370
- }
371
- const msg = err instanceof Error ? err.message : "Transcription failed";
372
- options.showWarning(msg);
373
- logger.error("STT transcription failed", { error: msg });
374
- this.#setState("idle", options);
375
- } finally {
376
- try {
377
- await fs.rm(tempFile, { force: true });
378
- } catch {
379
- // best effort cleanup
380
- }
381
- this.#tempFile = null;
382
- }
383
- }
384
-
385
304
  dispose(): void {
386
305
  this.#disposed = true;
387
- if (this.#transcriptionAbort) {
388
- this.#transcriptionAbort.abort();
389
- this.#transcriptionAbort = null;
390
- }
391
306
  if (this.#streamAbort) {
392
307
  this.#streamAbort.abort();
393
308
  this.#streamAbort = null;
394
309
  }
395
310
  this.#stream?.cancel();
396
- this.#streamRecorder?.stop().catch(() => {});
397
- this.#cleanupStream();
398
- if (this.#recordingHandle) {
399
- this.#recordingHandle.stop().catch(() => {});
400
- this.#recordingHandle = null;
401
- }
402
- if (this.#tempFile) {
403
- fs.rm(this.#tempFile, { force: true }).catch(() => {});
404
- this.#tempFile = null;
311
+ try {
312
+ this.#streamRecorder?.stop();
313
+ } catch {
314
+ // best effort cleanup
405
315
  }
316
+ this.#cleanupStream();
406
317
  this.#state = "idle";
407
318
  this.#resolvedModelKey = null;
408
319
  }
@@ -17,6 +17,7 @@ import { expandAtImports } from "./discovery/at-imports";
17
17
  import { loadSkills, type Skill } from "./extensibility/skills";
18
18
  import { hasObsidian } from "./internal-urls/vault-protocol";
19
19
  import activeRepoContextTemplate from "./prompts/system/active-repo-context.md" with { type: "text" };
20
+ import computerSafetyPrompt from "./prompts/system/computer-safety.md" with { type: "text" };
20
21
  import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
21
22
  import defaultPersonality from "./prompts/system/personalities/default.md" with { type: "text" };
22
23
  import friendlyPersonality from "./prompts/system/personalities/friendly.md" with { type: "text" };
@@ -839,6 +840,9 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
839
840
  };
840
841
  const rendered = prompt.render(resolvedCustomPrompt ? customSystemPromptTemplate : systemPromptTemplate, data);
841
842
  const systemPrompt = [rendered];
843
+ if (toolNames.includes("computer")) {
844
+ systemPrompt.push(computerSafetyPrompt.trim());
845
+ }
842
846
  // Custom prompt templates already render context files and append text; the
843
847
  // project footer still carries environment, cwd, workspace, and dir-context.
844
848
  const projectPrompt = prompt
@@ -196,6 +196,28 @@ export class TinyTitleClient {
196
196
  return () => this.#progressListeners.delete(listener);
197
197
  }
198
198
 
199
+ /**
200
+ * Spawn the tiny-model worker ahead of first use without loading any model.
201
+ * Called from idle TUI startup so the first {@link generate} reuses a live,
202
+ * unref'd subprocess instead of paying subprocess-spawn latency on the submit
203
+ * hot path (issue #6462). No-ops for online / non-local keys and for models
204
+ * already marked failed. A no-op `ping` round-trips the transport to fault in
205
+ * the worker's module graph; no pending request is registered, so
206
+ * {@link #syncWorkerRef} leaves the worker unref'd and idle sessions still exit.
207
+ */
208
+ prewarm(modelKey: string): void {
209
+ if (!isTinyTitleLocalModelKey(modelKey) || this.#failedModels.has(modelKey)) return;
210
+ try {
211
+ const worker = this.#ensureWorker();
212
+ worker.send({ type: "ping", id: String(++this.#nextRequestId) });
213
+ } catch (error) {
214
+ logger.debug("tiny-title: prewarm failed", {
215
+ modelKey,
216
+ error: error instanceof Error ? error.message : String(error),
217
+ });
218
+ }
219
+ }
220
+
199
221
  async generate(modelKey: string, message: string, signal?: AbortSignal): Promise<string | null>;
200
222
  async generate(modelKey: string, message: string, options?: TinyTitleGenerateOptions): Promise<string | null>;
201
223
  async generate(
@@ -340,92 +340,96 @@ export async function runInteractiveBashPty(
340
340
  headBytes: resolveOutputSinkHeadBytes(settings),
341
341
  maxColumns: resolveOutputMaxColumns(settings),
342
342
  });
343
- const result = await ui.custom<BashInteractiveResult>(
344
- (tui, uiTheme, _keybindings, done) => {
345
- const session = new PtySession();
346
- const component = new BashInteractiveOverlayComponent(
347
- options.command,
348
- uiTheme,
349
- () => tui.terminal.rows,
350
- XtermTerminal,
351
- );
352
- component.setSession(session);
353
- let finished = false;
354
- const finalize = (run: PtyRunResult) => {
355
- if (finished) return;
356
- finished = true;
357
- component.setComplete({ exitCode: run.exitCode, cancelled: run.cancelled, timedOut: run.timedOut });
358
- tui.requestRender();
359
- void (async () => {
360
- await component.flushOutput();
361
- const summary = await sink.dump();
362
- done({
363
- exitCode: run.exitCode,
364
- cancelled: run.cancelled,
365
- timedOut: run.timedOut,
366
- ...summary,
367
- });
368
- })();
369
- };
370
- const cols = Math.max(20, tui.terminal.columns - 2);
371
- const rows = Math.max(5, tui.terminal.rows - 4);
372
- component.setHandlers(
373
- data => {
374
- try {
375
- session.write(data);
376
- } catch {
377
- // ignore writes after command exits
378
- }
379
- },
380
- () => {
381
- try {
382
- session.kill();
383
- } catch {
384
- // ignore
385
- }
386
- },
387
- () => {
388
- try {
389
- session.kill();
390
- } catch {
391
- // ignore
392
- }
393
- },
394
- );
395
- void session
396
- .start(
397
- {
398
- command: options.command,
399
- cwd: options.cwd,
400
- timeoutMs: options.timeoutMs,
401
- // Interactive PTY: inherit the user's environment (the Rust side
402
- // applies these as overrides), with a real TERM so editors,
403
- // pagers, and TUIs behave like a normal terminal.
404
- env: {
405
- TERM: "xterm-256color",
406
- ...options.env,
407
- },
408
- signal: options.signal,
409
- cols,
410
- rows,
411
- shell: resolvedShell,
343
+ try {
344
+ const result = await ui.custom<BashInteractiveResult>(
345
+ (tui, uiTheme, _keybindings, done) => {
346
+ const session = new PtySession();
347
+ const component = new BashInteractiveOverlayComponent(
348
+ options.command,
349
+ uiTheme,
350
+ () => tui.terminal.rows,
351
+ XtermTerminal,
352
+ );
353
+ component.setSession(session);
354
+ let finished = false;
355
+ const finalize = (run: PtyRunResult) => {
356
+ if (finished) return;
357
+ finished = true;
358
+ component.setComplete({ exitCode: run.exitCode, cancelled: run.cancelled, timedOut: run.timedOut });
359
+ tui.requestRender();
360
+ void (async () => {
361
+ await component.flushOutput();
362
+ const summary = await sink.dump();
363
+ done({
364
+ exitCode: run.exitCode,
365
+ cancelled: run.cancelled,
366
+ timedOut: run.timedOut,
367
+ ...summary,
368
+ });
369
+ })();
370
+ };
371
+ const cols = Math.max(20, tui.terminal.columns - 2);
372
+ const rows = Math.max(5, tui.terminal.rows - 4);
373
+ component.setHandlers(
374
+ data => {
375
+ try {
376
+ session.write(data);
377
+ } catch {
378
+ // ignore writes after command exits
379
+ }
380
+ },
381
+ () => {
382
+ try {
383
+ session.kill();
384
+ } catch {
385
+ // ignore
386
+ }
412
387
  },
413
- (err, chunk) => {
414
- if (finished || err || !chunk) return;
415
- component.appendOutput(chunk);
416
- const normalizedChunk = normalizeCaptureChunk(chunk);
417
- sink.push(normalizedChunk);
418
- tui.requestRender();
388
+ () => {
389
+ try {
390
+ session.kill();
391
+ } catch {
392
+ // ignore
393
+ }
419
394
  },
420
- )
421
- .then(finalize)
422
- .catch(error => {
423
- sink.push(`PTY error: ${error instanceof Error ? error.message : String(error)}\n`);
424
- finalize({ exitCode: undefined, cancelled: false, timedOut: false });
425
- });
426
- return component;
427
- },
428
- { overlay: true },
429
- );
430
- return result;
395
+ );
396
+ void session
397
+ .start(
398
+ {
399
+ command: options.command,
400
+ cwd: options.cwd,
401
+ timeoutMs: options.timeoutMs,
402
+ // Interactive PTY: inherit the user's environment (the Rust side
403
+ // applies these as overrides), with a real TERM so editors,
404
+ // pagers, and TUIs behave like a normal terminal.
405
+ env: {
406
+ TERM: "xterm-256color",
407
+ ...options.env,
408
+ },
409
+ signal: options.signal,
410
+ cols,
411
+ rows,
412
+ shell: resolvedShell,
413
+ },
414
+ (err, chunk) => {
415
+ if (finished || err || !chunk) return;
416
+ component.appendOutput(chunk);
417
+ const normalizedChunk = normalizeCaptureChunk(chunk);
418
+ sink.push(normalizedChunk);
419
+ tui.requestRender();
420
+ },
421
+ )
422
+ .then(finalize)
423
+ .catch(error => {
424
+ sink.push(`PTY error: ${error instanceof Error ? error.message : String(error)}\n`);
425
+ finalize({ exitCode: undefined, cancelled: false, timedOut: false });
426
+ });
427
+ return component;
428
+ },
429
+ { overlay: true },
430
+ );
431
+ return result;
432
+ } finally {
433
+ await sink.dispose();
434
+ }
431
435
  }
@@ -13,6 +13,7 @@ export const BUILTIN_TOOL_NAMES = [
13
13
  "lsp",
14
14
  "inspect_image",
15
15
  "browser",
16
+ "computer",
16
17
  "checkpoint",
17
18
  "rewind",
18
19
  "task",
@@ -0,0 +1,28 @@
1
+ import type { DesktopAction, DesktopCapabilities, DesktopCapture, DesktopSessionOptions } from "@oh-my-pi/pi-natives";
2
+
3
+ export const COMPUTER_WORKER_ARG = "__omp_worker_computer";
4
+
5
+ export type ComputerWorkerInbound =
6
+ | { type: "ping"; id: string }
7
+ | { type: "init"; options: DesktopSessionOptions }
8
+ | { type: "execute"; id: string; actions: DesktopAction[] }
9
+ | { type: "close" };
10
+
11
+ export type ComputerWorkerOutbound =
12
+ | { type: "pong"; id: string }
13
+ | { type: "ready"; capabilities: DesktopCapabilities }
14
+ | { type: "result"; id: string; capture: DesktopCapture; capabilities: DesktopCapabilities }
15
+ | { type: "error"; id?: string; error: ComputerWorkerError }
16
+ | { type: "closed" };
17
+
18
+ export interface ComputerWorkerError {
19
+ name: string;
20
+ message: string;
21
+ stack?: string;
22
+ }
23
+
24
+ export interface ComputerWorkerTransport {
25
+ send(message: ComputerWorkerOutbound, transfer?: Bun.Transferable[]): void;
26
+ onMessage(handler: (message: ComputerWorkerInbound) => void): () => void;
27
+ close(): void;
28
+ }