forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpiMono.ts
More file actions
1425 lines (1298 loc) · 50.8 KB
/
Copy pathpiMono.ts
File metadata and controls
1425 lines (1298 loc) · 50.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// PiMonoAdapter — Windows port of desktop/macos/agent/src/adapters/pi-mono.ts.
//
// pi-mono is Omi's managed-cloud chat harness: the bundled
// `@earendil-works/pi-coding-agent` CLI run as a `--mode rpc` subprocess whose
// model calls route through Omi's own backend using the user's Firebase token
// (server-billed). This file is a near-verbatim port of the macOS adapter — the
// RPC event loop, generation/pending-request correlation, required-control-tool
// tracking, model mapping, and deferred-restart lifecycle are unchanged.
//
// The adapter is in ADAPTER_CAPABILITY_MATRIX and registered into the kernel
// registry on session relay (agentKernel/controlPlane.ts). It is now the LIVE
// default chat path: chatEngine defaults to 'pi_mono' (appSettings.ts) and
// mainChat.ts routes each turn through kernel.sendAgentMessage -> executeAttempt.
// executeAttempt carries this turn's host tool-relay pipe/token in the per-turn
// context file so the pi extension can reach the product/control tool plane
// (the toolplane wire, this PR). Control-tool spawns still refuse managed-cloud
// adapters (pi-mono is never a control-spawn target).
//
// Windows deviations from the macOS source, each load-bearing:
// - Subprocess spawn: macOS spawns pi's `dist/cli.js` directly; Windows spawns
// Electron's own binary as plain Node (ELECTRON_RUN_AS_NODE=1) with the
// resolved cli.js as argv[0], mirroring the ACP bridge (acp.ts:288-297).
// - resolveBundledPi(): macOS walks import.meta.url and prefers the flat
// cli.js to dodge a ditto `.bin` symlink quirk that does not exist on
// Windows; Windows resolves the real cli.js via Node module resolution
// (createRequire), the same pattern agentKernel/store.ts uses for
// better-sqlite3.
// - Event vocabulary: Windows' AdapterEventSink is the narrow AdapterStreamEvent
// union (no `tool_use` / `error` variants), so the RuntimeAdapter wrapper
// forwards only canonical stream events to the sink; harness `error` still
// propagates via the rejected sendPrompt promise (matching the ACP adapter),
// and `tool_use` is display-redundant with `tool_activity`.
// - Capabilities: the wrapper reads `adapterCapabilitiesFor('pi-mono')` from
// the shared ADAPTER_CAPABILITY_MATRIX (PR-D added the entry; it previously
// held a local static set equal to what the macOS matrix entry produces).
// - The small HarnessConfig / HarnessFeature / HarnessAdapter / SessionOpts /
// PromptResult / ToolExecutor / EventCallback / WarmupSessionConfig types
// were trimmed from Windows' interface.ts; they are re-declared locally so
// the port stays self-contained and does not widen the shared contract.
import { ChildProcess, spawn } from 'child_process'
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { dirname, join } from 'path'
import { createInterface, Interface as ReadlineInterface } from 'readline'
import { fileURLToPath } from 'url'
import { adapterCapabilitiesFor } from './interface'
import {
WINDOWS_SERVICEABLE_PRODUCT_TOOLS,
type ToolRelayRegistration
} from '../agentKernel/toolRelayBridge'
import type {
AdapterAttemptContext,
AdapterAttemptResult,
AdapterBindingHandle,
AdapterCapabilities,
AdapterEventSink,
AdapterStreamEvent,
CancelAttemptContext,
CancelDispatchResult,
OpenBindingInput,
OpenedBinding,
PromptBlock,
ResumeBindingInput,
RuntimeAdapter,
ToolDef
} from './interface'
// === Harness-config types (trimmed from Windows interface.ts) ================
// Re-declared locally so the pi-mono port stays self-contained — it does not widen
// the shared adapter contract. These mirror the macOS interface.ts definitions the
// harness class depends on.
/** Configuration for creating the pi-mono harness adapter. */
export interface HarnessConfig {
/** Omi API base URL for the pi-mono provider (wired in PR-B). */
omiApiBaseUrl?: string
/** Firebase auth token for Omi API authentication (wired in PR-B). */
authToken?: string
/**
* The complete `OMI_BYOK_*` env set to inject at spawn when the user has all
* four BYOK provider keys, or undefined/`{}` for Omi-managed billing. Built by
* the pi-mono session store from `ByokKeyStore` (`byokEnvVars`, all-or-nothing)
* and passed through at spawn — the bundled omi-provider extension reads these
* and re-emits them as `X-BYOK-*` headers. Separate from `authToken`: the
* managed `OMI_API_KEY` is always the Firebase token, never a BYOK key.
*/
byokEnv?: Record<string, string>
}
interface SessionOpts {
cwd: string
model?: string
systemPrompt?: string
mcpServers?: Record<string, unknown>[]
executionRole?: 'coordinator' | 'leaf'
}
interface PromptResult {
text: string
sessionId: string
costUsd?: number
inputTokens?: number
outputTokens?: number
cacheReadTokens?: number
cacheWriteTokens?: number
}
/** Callback for tool execution — harness calls this, host returns the result. */
type ToolExecutor = (name: string, input: Record<string, unknown>) => Promise<string>
interface WarmupSessionConfig {
model?: string
systemPrompt?: string
}
/**
* The harness's own event vocabulary. It is a superset of Windows'
* AdapterStreamEvent: pi additionally emits `tool_use` (display-redundant with
* `tool_activity`) and `error` (which also rejects the pending prompt). The
* RuntimeAdapter wrapper forwards only the AdapterStreamEvent-compatible events
* to the narrow kernel sink.
*/
type PiStreamEvent =
| AdapterStreamEvent
| { type: 'tool_use'; callId: string; name: string; input?: Record<string, unknown> }
| { type: 'error'; message: string; adapterSessionId?: string }
type EventCallback = (event: PiStreamEvent) => void
/** Features a harness may or may not support (parity with macOS HarnessFeature). */
export enum HarnessFeature {
MCP_CLIENT = 'mcp_client',
BIDIRECTIONAL_RPC = 'bidirectional_rpc',
SESSION_RESUME = 'session_resume',
COST_TRACKING = 'cost_tracking',
OAUTH = 'oauth',
MODEL_SWITCH = 'model_switch'
}
type PiMonoConfig = HarnessConfig & {
onRestart?: (reason: string) => void
/**
* Resolve (mint-or-reuse) this session's host tool-relay registration —
* `{ pipePath, token }` — so the pi subprocess can reach the product/control
* tool relay. Injected as a callback (not a direct import) to avoid the
* controlPlane → piMono import cycle: controlPlane owns the relay singleton and
* passes `(sessionId) => getAgentToolRelayBridge()?.register(sessionId, 'pi-mono')`.
* Returns null when the relay is unavailable (tools then degrade gracefully).
*/
registerToolRelay?: (sessionId: string) => ToolRelayRegistration | null
}
/**
* Test/wiring seams for the harness. `nodeBin` defaults to Electron's own
* binary (run as Node); `piPath` / `extensionPath` default to the bundled
* resolvers. Tests inject all three so no real subprocess or package resolution
* is exercised.
*/
export interface PiMonoAdapterOptions {
piPath?: string
extensionPath?: string
nodeBin?: string
}
// Pi-mono RPC command/event types
interface PiRpcCommand {
id?: string
type: string
[key: string]: unknown
}
interface PiRpcEvent {
type: string
[key: string]: unknown
}
interface PiMonoRelayContext {
protocolVersion: 2
requestId: string
clientId: string
sessionId: string
runId: string
attemptId: string
adapterSessionId?: string
disableSwiftBackedTools?: boolean
// The host tool-relay target for THIS turn. Written into the per-turn context
// file (like sessionId/runId already are), NOT the spawn env — so it survives
// resume + pool-eviction remint with zero subprocess restarts. Host-derived
// authority: the extension reads these to reach the relay; the model never
// asserts them. Kept OUT of the on-wire tool_use correlation (the token is a
// secret and the host resolves identity from the token→binding, not the frame).
bridgePipe?: string
bridgeToken?: string
}
interface PiAssistantMessageEvent {
type: string
contentIndex?: number
delta?: string
content?: string
partial?: PiAssistantMessage
message?: PiAssistantMessage
toolCall?: PiToolCall
reason?: string
error?: PiAssistantMessage
}
interface PiAssistantMessage {
role: string
content: PiContentBlock[]
usage?: PiUsage
stopReason?: string
errorMessage?: string
}
interface PiContentBlock {
type: string
text?: string
thinking?: string
id?: string
name?: string
arguments?: Record<string, unknown>
}
interface PiToolCall {
id: string
name: string
arguments: Record<string, unknown>
}
interface PiUsage {
input: number
output: number
cacheRead: number
cacheWrite: number
totalTokens: number
cost?: {
input: number
output: number
cacheRead: number
cacheWrite: number
total: number
}
}
const REQUIRED_AGENT_CONTROL_TOOLS = new Set([
'send_agent_message',
'spawn_background_agent',
'spawn_agent',
'run_agent_and_wait'
])
function requiredAgentControlFailure(toolName: string, output: string): string | undefined {
if (!REQUIRED_AGENT_CONTROL_TOOLS.has(toolName)) return undefined
if (output.startsWith('Error:')) return output
try {
const parsed = JSON.parse(output) as { ok?: unknown; error?: { message?: unknown } }
if (parsed.ok === false) {
const detail = typeof parsed.error?.message === 'string' ? parsed.error.message : output
return `Required ${toolName} operation failed: ${detail}`
}
} catch {
// A successful control tool always returns the canonical JSON envelope.
// Preserve a prior failure until an explicit successful retry clears it.
}
return undefined
}
function requiredControlOperationKey(
toolName: string,
input: Record<string, unknown> | undefined
): string {
const ignored = new Set(['adapterId', 'provider', 'defaultAdapterId', 'requestId', 'clientId'])
const normalized = Object.fromEntries(
Object.entries(input ?? {})
.filter(([key]) => !ignored.has(key))
.sort(([left], [right]) => left.localeCompare(right))
)
return `${toolName}:${JSON.stringify(normalized)}`
}
// Map desktop model IDs (claude-*) to omi provider model IDs.
// Covers short aliases and dated versions used by the chat provider/ChatLab.
const MODEL_MAP: Record<string, string> = {
'claude-opus-4-6': 'omi-opus',
'claude-sonnet-4-6': 'omi-sonnet',
'claude-sonnet-4': 'omi-sonnet',
'claude-opus-4': 'omi-opus',
'claude-sonnet-4-20250514': 'omi-sonnet',
'claude-opus-4-20250514': 'omi-opus'
}
function mapModel(model: string): string {
return MODEL_MAP[model] ?? model
}
/** Ordered candidate paths for pi's `dist/cli.js`, resolved on the filesystem.
*
* Pure so it can be unit-tested without a running Electron app. `moduleDir` is
* this module's directory; `resourcesPath` is Electron's `process.resourcesPath`
* (undefined outside Electron → the packaged candidate is skipped).
*
* - Packaged: `<resourcesPath>/app.asar.unpacked/node_modules/@earendil-works/
* pi-coding-agent/dist/cli.js` (the package is asar-unpacked in
* electron-builder.yml so the plain-Node child can read it).
* - Dev / vitest: walk up from `moduleDir` to the hoisted node_modules.
*/
export function piCliCandidates(
moduleDir: string,
resourcesPath: string | undefined = process.resourcesPath
): string[] {
const rel = join('node_modules', '@earendil-works', 'pi-coding-agent', 'dist', 'cli.js')
const candidates: string[] = []
if (resourcesPath) {
candidates.push(join(resourcesPath, 'app.asar.unpacked', rel))
}
for (let dir = moduleDir; ; ) {
candidates.push(join(dir, rel))
const parent = dirname(dir)
if (parent === dir) break
dir = parent
}
return candidates
}
/** Resolve the pi CLI (`dist/cli.js`) bundled inside the app, on the filesystem.
*
* We CANNOT use `import.meta.resolve` here: the electron-vite MAIN bundle is CJS,
* and esbuild compiles `import.meta.resolve(x)` to `(void 0)(x)` — calling it
* throws "(void 0) is not a function" at adapter construction (before openBinding,
* so the adapter's own catch never fires). Nor can we use
* `createRequire(...).resolve(...)`: pi's package.json `exports` map exposes only
* the ESM `import` condition for "." (→ dist/index.js) and "./rpc-entry" — no CJS
* `require` condition, no `./package.json`, no `./dist/*` subpath — so every
* bare/subpath resolve throws ERR_PACKAGE_PATH_NOT_EXPORTED.
*
* So we resolve on the filesystem (see piCliCandidates), which the exports map
* can't gate, and which works in dev, vitest, and packaged builds. First existing
* candidate wins. `import.meta.url` (not `.resolve`) is safe — esbuild shims it to
* `pathToFileURL(__filename)` in the CJS bundle.
*/
export function resolveBundledPi(): string {
const moduleDir = dirname(fileURLToPath(import.meta.url))
const candidates = piCliCandidates(moduleDir)
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate
}
throw new Error(
`resolveBundledPi: @earendil-works/pi-coding-agent/dist/cli.js not found. Looked in:\n ${candidates.join('\n ')}`
)
}
/** Ordered candidate paths for the omi-provider extension's `index.ts`.
*
* Pure so it can be unit-tested. `moduleDir` is this module's directory;
* `resourcesPath` is Electron's `process.resourcesPath`.
*
* - Packaged: the extension is bundled to `out/main/pi-mono-extension/index.ts`
* (scripts/bundle-pimono-extension.mjs) and asar-unpacked (electron-builder)
* so pi's plain-Node child can read it →
* `<resourcesPath>/app.asar.unpacked/out/main/pi-mono-extension/index.ts`.
* - Dev / vitest: the bundle step does NOT run, so pi loads the raw `.ts` from
* its SOURCE location (jiti resolves the relative `./node-tools` etc. imports
* from there). We can't use `dirname(import.meta.url)` — in the electron-vite
* bundle that is `out/main`, where nothing was copied in dev. So walk up to the
* checkout that holds `src/main/codingAgent/pi-mono-extension/index.ts`.
*/
export function piExtensionCandidates(
moduleDir: string,
resourcesPath: string | undefined = process.resourcesPath
): string[] {
const candidates: string[] = []
if (resourcesPath) {
candidates.push(
join(resourcesPath, 'app.asar.unpacked', 'out', 'main', 'pi-mono-extension', 'index.ts')
)
}
const srcRel = join('src', 'main', 'codingAgent', 'pi-mono-extension', 'index.ts')
for (let dir = moduleDir; ; ) {
candidates.push(join(dir, srcRel))
const parent = dirname(dir)
if (parent === dir) break
dir = parent
}
return candidates
}
/** Resolve the omi-provider extension file (`index.ts`) pi loads via jiti.
*
* The pi-mono-extension registers the `omi` provider + Windows denylist + the
* OMI_BRIDGE_PIPE relay client. Resolved on the filesystem (see
* piExtensionCandidates) so it works in dev, vitest, and packaged builds; first
* existing candidate wins. `import.meta.url` (not `.resolve`) is safe — esbuild
* shims it to `pathToFileURL(__filename)` in the CJS bundle.
*/
export function resolveBundledExtension(): string {
const moduleDir = dirname(fileURLToPath(import.meta.url))
const candidates = piExtensionCandidates(moduleDir)
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate
}
throw new Error(
`resolveBundledExtension: pi-mono-extension/index.ts not found. Looked in:\n ${candidates.join('\n ')}`
)
}
/**
* PiMonoAdapter spawns pi-mono in RPC mode and translates its events into the
* normalized adapter events.
*
* Tool execution flows:
* 1. Pi-mono executes its built-in tools internally (bash, read, write, edit).
* 2. Custom Omi tools are registered via the extension, which routes them
* through the Omi API backend / the OMI_BRIDGE_PIPE relay (PR-C).
*/
export class PiMonoAdapter {
private static nextAdapterInstanceId = 1
readonly name = 'pi-mono'
private config: PiMonoConfig
private process: ChildProcess | null = null
private readline: ReadlineInterface | null = null
private sessions: Map<string, { cwd: string; model?: string; systemPrompt?: string }> = new Map()
private nextSessionId = 1
/** Per-prompt state — keyed by monotonic prompt generation ID, not session ID.
* Pi-mono RPC only processes one prompt at a time, so a generation counter
* is sufficient for correlation. Late/stray turn_end events that don't
* match the in-flight generation are dropped. */
private pendingRequests: Map<
number,
{
sessionId: string
resolve: (value: unknown) => void
reject: (err: Error) => void
}
> = new Map()
/** Generation of the currently-in-flight prompt (0 = none) */
private activePromptGeneration = 0
/** Monotonic counter for prompt generations */
private nextPromptGeneration = 1
private nextRequestId = 1
private eventHandler: EventCallback | null = null
/** Unresolved required control obligations for the active turn. */
private requiredAgentControlFailures = new Map<string, string>()
private requiredControlInputs = new Map<string, Record<string, unknown>>()
private currentAbortController: AbortController | null = null
private readonly nodeBin: string
private piPath: string
private extensionPath: string
private readonly contextFilePath = join(
tmpdir(),
`omi-pi-mono-context-${process.pid}-${Math.random().toString(36).slice(2)}.json`
)
/** Current system prompt baked into the spawned pi process via --system-prompt.
* Pi has no set_system_prompt RPC, so changing this requires a subprocess restart. */
private currentSystemPrompt: string | undefined
private currentExecutionRole: 'coordinator' | 'leaf' = 'coordinator'
private readonly sessionPrefix: string
/** True when a token refresh was deferred because a prompt was active */
private pendingTokenRefresh = false
/** True when a system-prompt change was deferred because a prompt was active */
private pendingSystemPromptRefresh = false
constructor(config: PiMonoConfig, options: PiMonoAdapterOptions = {}) {
this.config = config
this.sessionPrefix = `pi-worker-${PiMonoAdapter.nextAdapterInstanceId++}`
this.nodeBin = options.nodeBin ?? process.execPath
this.piPath = options.piPath || process.env.PI_MONO_PATH || resolveBundledPi()
this.extensionPath =
options.extensionPath || process.env.PI_EXTENSION_PATH || resolveBundledExtension()
}
async start(): Promise<void> {
if (this.process) {
return
}
const args = [
'--mode',
'rpc',
'-e',
this.extensionPath,
'--provider',
'omi',
'--model',
'omi-sonnet'
// Auto-discover extensions and MCP servers from the user's machine
// to maximize pi-mono's capabilities (e.g. Playwright, filesystem tools).
// SECURITY NOTE: auto-discovered extensions run in the pi subprocess and
// can read process.env (including OMI_API_KEY). This is acceptable because:
// 1. OMI_API_KEY is a short-lived Firebase ID token (~1 hour expiry)
// 2. Extensions are user-installed — the trust boundary is the user's machine
// 3. ANTHROPIC_API_KEY is always scrubbed (never exposed to extensions)
]
// Pi has no set_system_prompt RPC — system prompt must be baked at spawn
// time via the --system-prompt CLI flag. To change it, restart the process.
if (this.currentSystemPrompt) {
args.push('--system-prompt', this.currentSystemPrompt)
}
// SECURITY: require a Firebase ID token. We MUST NOT fall back to
// ANTHROPIC_API_KEY — the Omi backend rejects provider keys and forwarding
// one here would leak the upstream secret to api.omi.me.
if (!this.config.authToken) {
throw new Error('pi-mono adapter requires config.authToken (Firebase ID token)')
}
// Scrub any ANTHROPIC_API_KEY from the child env so the extension cannot
// accidentally read it as a credential. pi-mono talks to api.omi.me with
// OMI_API_KEY only.
const env: Record<string, string> = {
...(process.env as Record<string, string>)
}
delete env.ANTHROPIC_API_KEY
// SECURITY: OMI_YOLO_MODE bypasses the extension's entire tool denylist.
// Scrub it from the subprocess env, then only re-inject when explicitly
// set in the parent. Log when active so usage is auditable.
delete env.OMI_YOLO_MODE
if (process.env.OMI_YOLO_MODE === '1') {
env.OMI_YOLO_MODE = '1'
process.stderr.write('[pi-mono] WARNING: OMI_YOLO_MODE=1 — denylist bypass active\n')
}
// BYOK: scrub every inherited OMI_BYOK_* first (parity with macOS
// removeInheritedBYOKEnvironment), so a stale/partial set from the parent env
// can never leak into the subprocess, then inject only the complete set the
// session store built (all-or-nothing — see byokEnvVars). Key material is
// never logged.
for (const key of Object.keys(env)) {
if (key.toUpperCase().startsWith('OMI_BYOK_')) delete env[key]
}
if (this.config.byokEnv) {
Object.assign(env, this.config.byokEnv)
}
// In Electron, process.execPath is the app binary, not node. This flag makes
// the spawned copy run as plain Node so it executes pi's cli.js (and is
// inherited, so pi's own nested spawns work too). Mirrors acp.ts.
env.ELECTRON_RUN_AS_NODE = '1'
env.NODE_NO_WARNINGS = '1'
// Pass the raw Firebase ID token. pi's openai-completions client already
// prepends `Authorization: Bearer ${apiKey}` — adding our own "Bearer "
// prefix here would produce a malformed `Bearer Bearer <token>` header.
env.OMI_API_KEY = this.config.authToken
if (this.config.omiApiBaseUrl) {
env.OMI_API_BASE_URL = this.config.omiApiBaseUrl
}
env.OMI_ADAPTER_ID = 'pi-mono'
env.OMI_EXECUTION_ROLE = this.currentExecutionRole
env.OMI_CONTEXT_FILE = this.contextFilePath
// Serviceable product-tool projection (role-static, so spawn env is the right
// carrier — like OMI_EXECUTION_ROLE, unlike the per-turn pipe/token which ride
// the context file). Derived from the executor registry so the advertised set
// can't drift from what the relay can actually service. The extension filters
// its swiftTool advertisement to exactly this set (absent/empty ⇒ fail-closed
// to control + load_skill only). The subprocess extension must NOT import
// toolRelayBridge (it pulls Electron-touching modules), hence env.
env.OMI_SERVICEABLE_PRODUCT_TOOLS = [...WINDOWS_SERVICEABLE_PRODUCT_TOOLS].join(',')
// The host tool-relay pipe/token are NOT injected here: they flow per-turn
// through the context file (writeRelayContext), so they survive resume and
// pool-eviction remint with no subprocess restart. See PiMonoRelayContext.
this.process = spawn(this.nodeBin, [this.piPath, ...args], {
stdio: ['pipe', 'pipe', 'pipe'],
env,
shell: false,
windowsHide: true
})
if (!this.process.stdout || !this.process.stdin) {
throw new Error('Failed to create pi-mono subprocess pipes')
}
// Read JSONL events from stdout
this.readline = createInterface({ input: this.process.stdout })
this.readline.on('line', (line: string) => {
this.handleEvent(line)
})
// Log stderr
if (this.process.stderr) {
this.process.stderr.on('data', (data: Buffer) => {
const msg = data.toString().trim()
if (msg) {
process.stderr.write(`[pi-mono] ${msg}\n`)
}
})
}
this.process.on('exit', (code: number | null) => {
process.stderr.write(`[pi-mono] process exited with code ${code}\n`)
this.process = null
this.readline = null
this.sessions.clear()
// Reject pending requests
for (const [, req] of this.pendingRequests) {
req.reject(new Error(`pi-mono process exited (code ${code})`))
}
this.pendingRequests.clear()
this.activePromptGeneration = 0
rmSync(this.contextFilePath, { force: true })
})
}
async stop(): Promise<void> {
if (this.process) {
// Remove all listeners from the old process FIRST so its delayed exit
// event can't fire the exit handler after we've already spawned a
// replacement. Without this, a stop()/start() cycle that interleaves
// with an incoming sendPrompt can race: the old process's exit event
// arrives after the new pendingRequest is registered, and the handler
// rejects the fresh request with "pi-mono process exited (code null)".
this.process.removeAllListeners('exit')
if (this.process.stdout) this.process.stdout.removeAllListeners()
if (this.process.stderr) this.process.stderr.removeAllListeners()
this.process.kill('SIGTERM')
this.process = null
if (this.readline) {
this.readline.removeAllListeners()
this.readline.close()
this.readline = null
}
}
this.sessions.clear()
this.pendingRequests.clear()
this.activePromptGeneration = 0
rmSync(this.contextFilePath, { force: true })
}
async createSession(opts: SessionOpts): Promise<string> {
const mapped = opts.model ? mapModel(opts.model) : undefined
await this.setExecutionRole(opts.executionRole ?? 'coordinator')
// Pi bakes the system prompt at spawn time via --system-prompt. If the
// caller requested a different prompt than the currently-running process,
// restart the subprocess with the new flag. Callers that want this handled
// eagerly across session switches should call setSystemPrompt() before
// createSession().
if (opts.systemPrompt && opts.systemPrompt !== this.currentSystemPrompt) {
await this.setSystemPrompt(opts.systemPrompt)
}
const sessionId = `${this.sessionPrefix}-session-${this.nextSessionId++}`
this.sessions.set(sessionId, {
cwd: opts.cwd,
model: mapped,
systemPrompt: opts.systemPrompt
})
await this.start()
// Set model if specified (map claude-* → omi-*)
if (mapped) {
this.sendCommand({
type: 'set_model',
provider: 'omi',
modelId: mapped
})
}
return sessionId
}
async setExecutionRole(role: 'coordinator' | 'leaf'): Promise<void> {
if (role === this.currentExecutionRole) return
this.currentExecutionRole = role
if (this.process) {
await this.stop()
}
}
async sendPrompt(
sessionId: string,
prompt: PromptBlock[],
_tools: ToolDef[],
_mode: 'ask' | 'act',
onEvent: EventCallback,
// Tools are relayed to the host via the extension over OMI_BRIDGE_PIPE
// (PR-C), not through this in-process executor, so the callback is accepted
// for signature parity but not invoked here.
_onToolCall: ToolExecutor,
signal?: AbortSignal,
relayContext?: PiMonoRelayContext
): Promise<PromptResult> {
if (!this.sessions.has(sessionId)) {
throw new Error(`pi-mono session is no longer active: ${sessionId}`)
}
// Serialization invariant: pi-mono RPC only handles one prompt at a time.
// Do not supersede an in-flight prompt: pi-mono turn_end events do not carry
// a request id, so a late completion could be misattributed to the new prompt.
if (this.activePromptGeneration !== 0) {
throw new Error('pi-mono prompt already in flight')
}
this.eventHandler = onEvent
this.requiredAgentControlFailures.clear()
this.requiredControlInputs.clear()
this.currentAbortController = new AbortController()
this.writeRelayContext(relayContext)
const generation = this.nextPromptGeneration++
this.activePromptGeneration = generation
if (signal) {
signal.addEventListener('abort', () => {
this.abort(sessionId)
})
}
// Extract text and image from prompt blocks. Images ride pi's separate
// `cmd.images` RPC field — they are NEVER concatenated into the text
// `message`, so raw screenshot bytes can't leak into the text context.
const textParts: string[] = []
const images: { type: string; data: string; mimeType: string }[] = []
for (const block of prompt) {
if (block.type === 'text') {
textParts.push(block.text)
} else if (block.type === 'image') {
images.push({
type: 'image',
data: block.data,
mimeType: block.mimeType
})
}
}
const message = textParts.join('\n')
const cmd: PiRpcCommand = {
type: 'prompt',
message
}
if (images.length > 0) {
cmd.images = images
}
this.sendCommand(cmd)
// Wait for turn_end event mapped to THIS generation
return new Promise<PromptResult>((resolve, reject) => {
this.pendingRequests.set(generation, {
sessionId,
resolve: (value: unknown) => resolve(value as PromptResult),
reject
})
})
}
abort(sessionId: string): void {
this.sendCommand({ type: 'abort' })
this.currentAbortController?.abort()
// Resolve the in-flight prompt (by generation) with a partial result and
// CLEAR activePromptGeneration so a stray late turn_end is dropped instead
// of completing whatever comes next.
const generation = this.activePromptGeneration
if (generation === 0) return
const pending = this.pendingRequests.get(generation)
if (pending) {
this.pendingRequests.delete(generation)
pending.resolve({
text: '',
sessionId: pending.sessionId || sessionId,
costUsd: 0,
inputTokens: 0,
outputTokens: 0
})
}
this.activePromptGeneration = 0
}
clearRelayContextForAttempt(attemptId: string): void {
this.clearRelayContext(attemptId)
}
async setModel(sessionId: string, model: string): Promise<void> {
const mapped = mapModel(model)
const session = this.sessions.get(sessionId)
if (session) {
session.model = mapped
}
this.sendCommand({
type: 'set_model',
provider: 'omi',
modelId: mapped
})
}
async warmup(cwd: string, sessions: WarmupSessionConfig[]): Promise<void> {
// Pre-create sessions
for (const config of sessions) {
await this.createSession({
cwd,
model: config.model,
systemPrompt: config.systemPrompt
})
}
}
invalidateSession(sessionKey: string): void {
this.sessions.delete(sessionKey)
}
/**
* Resolve this kernel session's host tool-relay registration via the injected
* callback. Idempotent host-side (`register()` mints-or-reuses per binding), so
* safe to call every attempt. Returns null when no relay/callback is wired.
*/
resolveToolRelay(sessionId: string): ToolRelayRegistration | null {
return this.config.registerToolRelay?.(sessionId) ?? null
}
hasSession(sessionId: string): boolean {
return this.sessions.has(sessionId)
}
/** Update the system prompt baked into the pi subprocess.
*
* Pi's RPC protocol has no set_system_prompt command — the system prompt
* is a startup-only CLI flag (--system-prompt). To change it, we must
* restart the subprocess. If a prompt is currently in flight, we stash the
* new value and restart after turn_end via the same pending-refresh path
* used by auth token rotation.
*
* Returns true if the restart happened immediately, false if deferred. */
async setSystemPrompt(systemPrompt: string | undefined): Promise<boolean> {
if (systemPrompt === this.currentSystemPrompt) {
return true // no-op
}
this.currentSystemPrompt = systemPrompt
if (!this.process) {
// Not started yet — nothing to restart; start() will bake the new value.
return true
}
if (this.pendingRequests.size > 0) {
this.pendingSystemPromptRefresh = true
process.stderr.write('[pi-mono] system prompt stored (restart deferred, prompt active)\n')
return false
}
await this.stop()
await this.start()
this.config.onRestart?.('systemPrompt')
this.pendingSystemPromptRefresh = false
process.stderr.write('[pi-mono] subprocess restarted with new system prompt\n')
return true
}
/** Update auth token by restarting the subprocess when idle.
* The pi-mono extension bakes OMI_API_KEY at startup, so the only way
* to refresh is to restart the process. If a prompt is active, marks a
* pending restart that handleTurnEnd will execute after the prompt completes.
* Returns true if restart happened immediately, false if deferred. */
async updateAuthToken(token: string): Promise<boolean> {
this.config.authToken = token
if (this.pendingRequests.size > 0) {
this.pendingTokenRefresh = true
process.stderr.write('[pi-mono] auth token stored (restart deferred, prompt active)\n')
return false
}
await this.stop()
await this.start()
this.config.onRestart?.('token_refresh')
this.pendingTokenRefresh = false
process.stderr.write('[pi-mono] subprocess restarted with refreshed auth token\n')
return true
}
/** Whether a prompt is currently in-flight */
get isIdle(): boolean {
return this.pendingRequests.size === 0
}
/** Whether the pi subprocess is currently spawned. */
get isRunning(): boolean {
return this.process !== null
}
/** Force pi to start a fresh conversation, discarding all accumulated turns.
*
* pi holds ONE accumulating conversation per subprocess (rpc.md: every
* `prompt` continues the same message list). When a pinned worker's live
* subprocess is reassigned to a DIFFERENT chat binding (pool eviction under
* multichat load), reusing it without a reset would let the new chat's model
* see the evicted chat's turns — a narrow same-user context bleed. pi
* natively supports `new_session` (rpc.md) and the omi extension registers no
* `session_before_switch` handler, so it is never cancelled. No-op when the
* subprocess is not running (a fresh spawn already starts with no history);
* the kernel's full-tail injection (resumeFidelity:'none') then re-seeds the
* reassigned chat's own history from the durable transcript. */
resetConversation(): void {
if (!this.process) return
this.sendCommand({ type: 'new_session' })
process.stderr.write('[pi-mono] new_session sent (worker reassigned to a new chat)\n')
}
/** Whether a deferred restart is pending (token or system prompt) */
get hasPendingRestart(): boolean {
return this.pendingTokenRefresh || this.pendingSystemPromptRefresh
}
/** Execute the deferred restart (call after prompt completes).
* Handles both token refresh and system-prompt change — both baked at
* spawn time, both requiring a restart. */
async executePendingRestart(): Promise<void> {
if (!this.pendingTokenRefresh && !this.pendingSystemPromptRefresh) return
const reasons: string[] = []
if (this.pendingTokenRefresh) reasons.push('token')
if (this.pendingSystemPromptRefresh) reasons.push('systemPrompt')
this.pendingTokenRefresh = false
this.pendingSystemPromptRefresh = false
await this.stop()
await this.start()
this.config.onRestart?.(reasons.join('+'))
process.stderr.write(
`[pi-mono] deferred restart executed (${reasons.join('+')}; subprocess restarted)\n`
)
}
supportsFeature(feature: HarnessFeature): boolean {
switch (feature) {
case HarnessFeature.BIDIRECTIONAL_RPC:
return true
case HarnessFeature.MODEL_SWITCH:
return true
case HarnessFeature.COST_TRACKING:
return true // Server-side via Omi API
case HarnessFeature.MCP_CLIENT:
return false // Pi-mono doesn't use MCP
case HarnessFeature.SESSION_RESUME:
return false
case HarnessFeature.OAUTH:
return false // Uses Firebase token, not OAuth
default:
return false
}
}
// ── Private helpers ──────────────────────────────────────────────────
private sendCommand(cmd: PiRpcCommand): void {
if (!this.process?.stdin?.writable) {
throw new Error('pi-mono process not running')
}
const id = `req-${this.nextRequestId++}`
cmd.id = id
this.process.stdin.write(JSON.stringify(cmd) + '\n')
}
private writeRelayContext(context: PiMonoRelayContext | undefined): void {
if (!context) {
rmSync(this.contextFilePath, { force: true })
return
}
mkdirSync(dirname(this.contextFilePath), { recursive: true })
// 0o600: the context file carries this turn's bridge token (an authority
// credential), so keep it owner-only on POSIX. No-op on Windows FS, harmless.
writeFileSync(
this.contextFilePath,
JSON.stringify({
adapterId: 'pi-mono',
...context
}),
{ mode: 0o600 }
)
}
private clearRelayContext(expectedAttemptId?: string): void {
if (!expectedAttemptId) {
rmSync(this.contextFilePath, { force: true })
return
}
if (!existsSync(this.contextFilePath)) return
try {
const parsed = JSON.parse(readFileSync(this.contextFilePath, 'utf8')) as Record<
string,
unknown
>
if (parsed.attemptId !== expectedAttemptId) return
} catch {
// Invalid context is unusable by the extension; remove it as stale.
}
rmSync(this.contextFilePath, { force: true })
}
private handleEvent(line: string): void {
let event: PiRpcEvent
try {
event = JSON.parse(line)
} catch {
process.stderr.write(`[pi-mono] invalid JSON: ${line}\n`)
return
}