@mohou/runtime-pi 1.0.20

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/README.md ADDED
@@ -0,0 +1,10 @@
1
+ ---
2
+ status: locked
3
+ updated: 2026-10-01
4
+ ---
5
+
6
+ # @mohou/runtime-pi
7
+
8
+ Role: `provider`.
9
+
10
+ Pi brain. `llm` is one `ModelRuntime.completeSimple` call. `models` is `ModelRuntime.getModels()`. Pi owns the catalog path and the provider API. This package does not read `models.json` and does not send its own HTTP request. `agent` is one in-memory coding-agent session and does not write a session file. Shell injects it. Host does not import this package. Product: [ctx.agent](../../../docs/product/runtime/ctx-agent.md).
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@mohou/runtime-pi",
3
+ "version": "1.0.20",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": "^22.19.0 || >=24.0.0"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "lib/types",
15
+ "README.md",
16
+ "!**/*.tsbuildinfo"
17
+ ],
18
+ "main": "./src/index.ts",
19
+ "types": "./src/index.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./src/index.ts",
23
+ "default": "./src/index.ts"
24
+ },
25
+ "./src/*": "./src/*",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "dependencies": {
29
+ "@mohou/contract": "^1.0.20",
30
+ "@mohou/runtime-provider": "^1.0.20"
31
+ },
32
+ "peerDependencies": {
33
+ "@earendil-works/pi-ai": ">=0.85.0",
34
+ "@earendil-works/pi-coding-agent": ">=0.85.0"
35
+ },
36
+ "peerDependenciesMeta": {
37
+ "@earendil-works/pi-ai": {
38
+ "optional": true
39
+ },
40
+ "@earendil-works/pi-coding-agent": {
41
+ "optional": true
42
+ }
43
+ },
44
+ "devDependencies": {
45
+ "@earendil-works/pi-ai": "0.85.1",
46
+ "@earendil-works/pi-coding-agent": "0.85.1"
47
+ }
48
+ }
@@ -0,0 +1,144 @@
1
+ import type { ModelRuntime } from '@earendil-works/pi-coding-agent'
2
+ import { ProviderError, type RuntimeAgentOptions, type RuntimeProviderConfig } from '@mohou/runtime-provider'
3
+
4
+ /**
5
+ * One Pi coding-agent run. The session is in memory. The conversation is not written.
6
+ * The loader uses Pi's agent directory. No tool allowlist is passed.
7
+ * @param config - configured provider and model. Both absent leaves Pi's own default.
8
+ * @param goal - the user turn
9
+ * @param call - cwd, system text, model override, abort, and the observer
10
+ * @returns the last assistant text
11
+ * @throws ProviderError `unknown-model` when the provider/model pair is missing or unknown.
12
+ * `empty-completion` when the run ends with no text. `cancelled` when the signal aborts.
13
+ * `provider-unhealthy` when the session fails.
14
+ */
15
+ export async function runPiAgent(
16
+ config: RuntimeProviderConfig,
17
+ goal: string,
18
+ call: RuntimeAgentOptions | undefined,
19
+ ): Promise<string> {
20
+ const { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager, getAgentDir } = await import('@earendil-works/pi-coding-agent')
21
+ const agentDir = getAgentDir()
22
+ const cwd = call?.cwd ?? process.cwd()
23
+ observe(call, { type: 'status', status: 'running' })
24
+ const runtime = await ModelRuntime.create({ refreshOnCreate: false }).catch(unhealthy)
25
+ const model = selectedModel(runtime, config, call) ?? piDefaultModel(runtime, SettingsManager.create(cwd, agentDir))
26
+ const loader = new DefaultResourceLoader({
27
+ cwd,
28
+ agentDir,
29
+ ...call?.system === undefined ? {} : { appendSystemPrompt: [call.system] },
30
+ })
31
+ await loader.reload().catch(unhealthy)
32
+ const { session } = await createAgentSession({
33
+ cwd,
34
+ agentDir,
35
+ modelRuntime: runtime,
36
+ resourceLoader: loader,
37
+ sessionManager: SessionManager.inMemory(cwd),
38
+ ...model === undefined ? {} : { model },
39
+ }).catch(unhealthy)
40
+ let turn = 0
41
+ const unsubscribe = session.subscribe((event) => {
42
+ if (event.type === 'turn_start') {
43
+ turn += 1
44
+ observe(call, { type: 'turn', phase: 'start', turn })
45
+ return
46
+ }
47
+ if (event.type === 'turn_end') {
48
+ observe(call, {
49
+ type: 'turn',
50
+ phase: 'end',
51
+ turn,
52
+ reason: { kind: call?.signal?.aborted ? 'aborted' : 'completed' },
53
+ })
54
+ return
55
+ }
56
+ if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
57
+ observe(call, { type: 'text-delta', text: event.assistantMessageEvent.delta })
58
+ return
59
+ }
60
+ if (event.type === 'tool_execution_start') {
61
+ observe(call, { type: 'tool', phase: 'start', name: event.toolName, args: event.args })
62
+ return
63
+ }
64
+ if (event.type === 'tool_execution_end') {
65
+ observe(call, { type: 'tool', phase: 'end', name: event.toolName, result: event.result })
66
+ }
67
+ })
68
+ const abort = (): void => {
69
+ void session.abort().catch(() => {
70
+ // Abort is reported through the signal, not this rejection.
71
+ })
72
+ }
73
+ call?.signal?.addEventListener('abort', abort, { once: true })
74
+ try {
75
+ await session.prompt(goal)
76
+ if (call?.signal?.aborted) throw new ProviderError('cancelled', 'cancelled')
77
+ const text = assistantText(session.messages)
78
+ if (text.length === 0) throw new ProviderError('empty-completion', 'agent returned an empty completion')
79
+ observe(call, { type: 'done', text })
80
+ return text
81
+ } catch (error) {
82
+ if (error instanceof ProviderError) throw error
83
+ if (call?.signal?.aborted) throw new ProviderError('cancelled', 'cancelled', { cause: error })
84
+ throw new ProviderError('provider-unhealthy', 'pi agent failed', { cause: error })
85
+ } finally {
86
+ call?.signal?.removeEventListener('abort', abort)
87
+ unsubscribe()
88
+ session.dispose()
89
+ }
90
+ }
91
+
92
+ function unhealthy(error: unknown): never {
93
+ throw new ProviderError('provider-unhealthy', 'pi agent failed', { cause: error })
94
+ }
95
+
96
+ export function selectedModel(
97
+ runtime: Pick<ModelRuntime, 'getModel'>,
98
+ config: RuntimeProviderConfig,
99
+ call: { readonly provider?: string; readonly model?: string } | undefined,
100
+ ): ReturnType<ModelRuntime['getModel']> {
101
+ const provider = call?.provider ?? config.provider
102
+ const model = call?.model ?? config.model
103
+ if (provider === undefined && model === undefined) return undefined
104
+ if (provider === undefined || model === undefined) {
105
+ throw new ProviderError('unknown-model', 'pi model selection is incomplete')
106
+ }
107
+ const found = runtime.getModel(provider, model)
108
+ if (found === undefined) throw new ProviderError('unknown-model', `unknown model: ${provider}/${model}`)
109
+ return found
110
+ }
111
+
112
+ export function piDefaultModel(
113
+ runtime: Pick<ModelRuntime, 'getModel'>,
114
+ settings: { getDefaultProvider(): string | undefined; getDefaultModel(): string | undefined },
115
+ ): ReturnType<ModelRuntime['getModel']> {
116
+ const provider = settings.getDefaultProvider()
117
+ const model = settings.getDefaultModel()
118
+ if (provider === undefined || model === undefined) return undefined
119
+ const found = runtime.getModel(provider, model)
120
+ if (found === undefined) throw new ProviderError('unknown-model', `unknown model: ${provider}/${model}`)
121
+ return found
122
+ }
123
+
124
+ function assistantText(messages: readonly { readonly role: string; readonly content?: unknown }[]): string {
125
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
126
+ const message = messages[index]
127
+ if (message === undefined || message.role !== 'assistant' || !Array.isArray(message.content)) continue
128
+ const text = message.content.flatMap((part) => {
129
+ if (typeof part !== 'object' || part === null) return []
130
+ const record = part as { type?: unknown; text?: unknown }
131
+ return record.type === 'text' && typeof record.text === 'string' ? [record.text] : []
132
+ }).join('').trim()
133
+ if (text.length > 0) return text
134
+ }
135
+ return ''
136
+ }
137
+
138
+ function observe(options: RuntimeAgentOptions | undefined, event: Parameters<NonNullable<RuntimeAgentOptions['onEvent']>>[0]): void {
139
+ try {
140
+ options?.onEvent?.(event)
141
+ } catch {
142
+ // A throwing observer does not abort the run.
143
+ }
144
+ }
@@ -0,0 +1,16 @@
1
+ import type { Api, Model, ThinkingLevel } from '@earendil-works/pi-ai'
2
+
3
+ /**
4
+ * Thinking level for one completion.
5
+ * Loads `pi-ai` only when a completion runs (optional peer).
6
+ * `clampThinkingLevel(model, 'off')` is Pi's off for this model.
7
+ * `off` is omitted: Pi sends `thinkingLevelMap.off` when that token exists, otherwise the call keeps the server default.
8
+ * @param model - the resolved catalog model
9
+ */
10
+ export async function completionReasoning(model: Model<Api>): Promise<ThinkingLevel | undefined> {
11
+ if (!model.reasoning) return undefined
12
+ const { clampThinkingLevel } = await import('@earendil-works/pi-ai')
13
+ const level = clampThinkingLevel(model, 'off')
14
+ if (level === 'off') return undefined
15
+ return level
16
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ /** Pi catalog brain. @module @mohou/runtime-pi */
2
+
3
+ export { createPiProvider, probePiRuntime } from './pi.ts'
4
+ export { createPiLoad, ensurePiLoaded, installRoot, linkPiPeers, peerRoots, piPackage, registerPiRuntime } from './register.ts'
package/src/pi.ts ADDED
@@ -0,0 +1,156 @@
1
+ import type { AppLlmEvent } from '@mohou/contract'
2
+ import type { RuntimeLlmOptions } from '@mohou/runtime-provider'
3
+ import { ProviderError, type ModelListing, type RuntimeProvider, type RuntimeProviderConfig } from '@mohou/runtime-provider'
4
+
5
+ import { piDefaultModel, runPiAgent, selectedModel } from './agent-session.ts'
6
+ import { completionReasoning } from './completion-reasoning.ts'
7
+
8
+ /**
9
+ * True when Node can resolve the optional Pi packages (peer / environment).
10
+ * Does not scan install paths — ordinary package resolution only.
11
+ */
12
+ export async function probePiRuntime(): Promise<boolean> {
13
+ try {
14
+ await import('@earendil-works/pi-coding-agent')
15
+ await import('@earendil-works/pi-ai')
16
+ return true
17
+ } catch {
18
+ return false
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Pi catalog brain. Pi owns the catalog path and the provider API.
24
+ * `llm` calls `ModelRuntime.completeSimple`. `models` calls `getModels`.
25
+ * Requires optional peers `@earendil-works/pi-coding-agent` and `pi-ai` at call time.
26
+ */
27
+ export function createPiProvider(): RuntimeProvider {
28
+ let running = false
29
+ let config: RuntimeProviderConfig = {}
30
+ return {
31
+ id: 'pi',
32
+ label: 'Pi',
33
+ configure(next) {
34
+ config = next
35
+ },
36
+ start() {
37
+ running = true
38
+ return Promise.resolve()
39
+ },
40
+ stop() {
41
+ running = false
42
+ return Promise.resolve()
43
+ },
44
+ healthy() {
45
+ return running
46
+ },
47
+ async models() {
48
+ const runtime = await openRuntime().catch(() => undefined)
49
+ return runtime === undefined ? [] : listings(runtime.getModels())
50
+ },
51
+ async llm(prompt, call) {
52
+ return complete(config, running, prompt, call)
53
+ },
54
+ async agent(goal, call) {
55
+ const failed = notRunning(running, call?.signal)
56
+ if (failed) return failed
57
+ if (goal.length === 0) throw new ProviderError('empty-completion', 'agent returned an empty completion')
58
+ return runPiAgent(config, goal, call)
59
+ },
60
+ }
61
+ }
62
+
63
+ async function openRuntime() {
64
+ const { ModelRuntime } = await import('@earendil-works/pi-coding-agent')
65
+ return ModelRuntime.create({ refreshOnCreate: false })
66
+ }
67
+
68
+ async function complete(
69
+ config: RuntimeProviderConfig,
70
+ running: boolean,
71
+ prompt: string,
72
+ call: RuntimeLlmOptions | undefined,
73
+ ): Promise<string> {
74
+ const failed = notRunning(running, call?.signal)
75
+ if (failed) return failed
76
+ if (prompt.length === 0) {
77
+ throw new ProviderError('empty-completion', 'llm returned an empty completion')
78
+ }
79
+ const { SettingsManager, getAgentDir } = await import('@earendil-works/pi-coding-agent')
80
+ const runtime = await openRuntime().catch((error: unknown) => {
81
+ throw new ProviderError('provider-unhealthy', 'pi completion failed', { cause: error })
82
+ })
83
+ const model = selectedModel(runtime, config, call) ?? piDefaultModel(runtime, SettingsManager.create(process.cwd(), getAgentDir()))
84
+ if (model === undefined) throw new ProviderError('unknown-model', 'pi model selection is incomplete')
85
+ const reasoning = await completionReasoning(model)
86
+ const context = {
87
+ messages: [{ role: 'user' as const, content: prompt, timestamp: Date.now() }],
88
+ ...call?.system === undefined ? {} : { systemPrompt: call.system },
89
+ }
90
+ const request = {
91
+ ...reasoning === undefined ? {} : { reasoning },
92
+ ...call?.maxTokens === undefined ? {} : { maxTokens: call.maxTokens },
93
+ ...call?.signal === undefined ? {} : { signal: call.signal },
94
+ }
95
+ const message = await (call?.stream === true
96
+ ? readStream(runtime.streamSimple(model, context, request), call)
97
+ : runtime.completeSimple(model, context, request)
98
+ ).catch((error: unknown) => {
99
+ if (call?.signal?.aborted) throw new ProviderError('cancelled', 'cancelled', { cause: error })
100
+ throw new ProviderError('provider-unhealthy', 'pi completion failed', { cause: error })
101
+ })
102
+ if (call?.signal?.aborted || message.stopReason === 'aborted') throw new ProviderError('cancelled', 'cancelled')
103
+ if (message.stopReason === 'error') throw new ProviderError('provider-unhealthy', 'pi completion failed')
104
+ const text = messageText(message.content)
105
+ if (text.length === 0) throw new ProviderError('empty-completion', 'llm returned an empty completion')
106
+ return text
107
+ }
108
+
109
+ async function readStream(
110
+ stream: AsyncIterable<{ type: string; delta?: string }> & { result: () => Promise<{ stopReason: string; content: unknown }> },
111
+ call: RuntimeLlmOptions,
112
+ ): Promise<{ stopReason: string; content: unknown }> {
113
+ observeLlm(call, { type: 'status', status: 'running' })
114
+ for await (const event of stream) {
115
+ if (event.type === 'text_delta' && typeof event.delta === 'string' && event.delta.length > 0) {
116
+ observeLlm(call, { type: 'text-delta', text: event.delta })
117
+ }
118
+ }
119
+ const message = await stream.result()
120
+ const text = messageText(message.content)
121
+ if (text.length > 0) observeLlm(call, { type: 'done', text })
122
+ return message
123
+ }
124
+
125
+ function observeLlm(call: RuntimeLlmOptions, event: AppLlmEvent): void {
126
+ try {
127
+ call.onEvent?.(event)
128
+ } catch {
129
+ // A throwing observer does not abort the completion.
130
+ }
131
+ }
132
+
133
+ function listings(models: readonly { readonly provider: string; readonly id: string }[]): ModelListing[] {
134
+ const groups = new Map<string, string[]>()
135
+ for (const model of models) {
136
+ const list = groups.get(model.provider)
137
+ if (list === undefined) groups.set(model.provider, [model.id])
138
+ else list.push(model.id)
139
+ }
140
+ return [...groups].map(([provider, names]) => ({ provider, models: names }))
141
+ }
142
+
143
+ function messageText(content: unknown): string {
144
+ if (!Array.isArray(content)) return ''
145
+ return content.flatMap((part) => {
146
+ if (typeof part !== 'object' || part === null) return []
147
+ const record = part as { type?: unknown; text?: unknown }
148
+ return record.type === 'text' && typeof record.text === 'string' ? [record.text] : []
149
+ }).join('').trim()
150
+ }
151
+
152
+ function notRunning(running: boolean, signal: AbortSignal | undefined): Promise<never> | undefined {
153
+ if (signal?.aborted) return Promise.reject(new ProviderError('cancelled', 'cancelled'))
154
+ if (!running) return Promise.reject(new ProviderError('provider-unhealthy', 'runtime provider is not started'))
155
+ return undefined
156
+ }
@@ -0,0 +1,207 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { existsSync, lstatSync, mkdirSync, rmSync, symlinkSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ import { ProviderError, type ProviderRegistry, type RuntimeProvider } from '@mohou/runtime-provider'
8
+
9
+ import { createPiProvider } from './pi.ts'
10
+
11
+ const peers = ['@earendil-works/pi-coding-agent', '@earendil-works/pi-ai'] as const
12
+
13
+ /**
14
+ * The three things one load does, injectable so a test can watch their order and the retry.
15
+ * `present` answers from the filesystem on purpose: see {@link createPiLoad}.
16
+ */
17
+ export interface PiLoadSteps {
18
+ present(): boolean
19
+ link(): void | Promise<void>
20
+ load(): Promise<void>
21
+ }
22
+
23
+ export interface PiLoad {
24
+ /** One attempt, shared with any concurrent caller. A failure is not remembered. */
25
+ ensure(): Promise<boolean>
26
+ /** Whether the last finished attempt loaded both peers. */
27
+ loaded(): boolean
28
+ /** Why the last finished attempt failed, for the message a caller shows. */
29
+ failure(): string | undefined
30
+ }
31
+
32
+ /**
33
+ * One loader for the Pi peers.
34
+ *
35
+ * The first step is a filesystem question, never a module-loader one. Node remembers a failed
36
+ * resolution for the rest of the process, so asking the loader whether `@earendil-works/…` resolves
37
+ * and only then creating the link poisons every later attempt in that process: the host would repair
38
+ * its own prefix and still report Pi as unavailable until the next restart. Repair first, then the
39
+ * one loader call an attempt makes.
40
+ * @param steps - presence check, repair, and the load itself
41
+ */
42
+ export function createPiLoad(steps: PiLoadSteps): PiLoad {
43
+ let loading: Promise<boolean> | undefined
44
+ let loaded = false
45
+ let failure: string | undefined
46
+
47
+ async function attempt(): Promise<boolean> {
48
+ try {
49
+ // The repair is inside the guard too: a prefix the user cannot write must leave the id
50
+ // registered and unhealthy, not reject the promise every caller awaits.
51
+ if (!steps.present()) await steps.link()
52
+ await steps.load()
53
+ failure = undefined
54
+ return true
55
+ } catch (error) {
56
+ failure = error instanceof Error && error.message.length > 0 ? error.message : 'the peers did not load'
57
+ return false
58
+ }
59
+ }
60
+
61
+ return {
62
+ ensure() {
63
+ loading ??= attempt().then((ok) => {
64
+ loaded = ok
65
+ // A failed attempt is forgotten, so the next caller retries instead of inheriting it.
66
+ if (!ok) loading = undefined
67
+ return ok
68
+ })
69
+ return loading
70
+ },
71
+ loaded: () => loaded,
72
+ failure: () => failure,
73
+ }
74
+ }
75
+
76
+ /** The real steps: the prefix's own `node_modules`, this machine's Pi installs, and the two imports. */
77
+ function realSteps(): PiLoadSteps {
78
+ const prefix = installRoot()
79
+ const roots = () => peerRoots(process.execPath, homedir(), process.env)
80
+ return {
81
+ present: () => peersPresent(prefix),
82
+ async link() {
83
+ linkPiPeers(prefix, roots())
84
+ if (peersPresent(prefix)) return
85
+ const global = await npmRootGlobal(process.env)
86
+ if (global !== undefined) linkPiPeers(prefix, [global])
87
+ },
88
+ async load() {
89
+ await import('@earendil-works/pi-coding-agent')
90
+ await import('@earendil-works/pi-ai')
91
+ },
92
+ }
93
+ }
94
+
95
+ const piLoad = createPiLoad(realSteps())
96
+
97
+ /**
98
+ * Register Pi and return immediately. Linking and loading run after this call.
99
+ * A failed load leaves the id registered and unhealthy. It does not fail boot, and a later
100
+ * caller retries it.
101
+ */
102
+ export function registerPiRuntime(registry: ProviderRegistry): RuntimeProvider {
103
+ void piLoad.ensure()
104
+ const inner = createPiProvider()
105
+ const provider: RuntimeProvider = {
106
+ id: inner.id,
107
+ ...inner.label === undefined ? {} : { label: inner.label },
108
+ configure: (config) => { inner.configure?.(config) },
109
+ start: () => inner.start(),
110
+ stop: () => inner.stop(),
111
+ healthy: () => inner.healthy() && piLoad.loaded(),
112
+ models: () => inner.models?.() ?? Promise.resolve([]),
113
+ async llm(prompt, options) {
114
+ if (!await piLoad.ensure()) throw new ProviderError('provider-unhealthy', unavailable(piLoad.failure()))
115
+ return inner.llm(prompt, options)
116
+ },
117
+ async agent(goal, options) {
118
+ if (!await piLoad.ensure()) throw new ProviderError('provider-unhealthy', unavailable(piLoad.failure()))
119
+ return inner.agent(goal, options)
120
+ },
121
+ }
122
+ registry.register(provider)
123
+ return provider
124
+ }
125
+
126
+ export function ensurePiLoaded(): Promise<boolean> {
127
+ return piLoad.ensure()
128
+ }
129
+
130
+ function unavailable(reason: string | undefined): string {
131
+ return reason === undefined ? 'pi is not available' : `pi is not available: ${reason}`
132
+ }
133
+
134
+ /**
135
+ * Whether both peers are reachable through the prefix's `node_modules`. Filesystem only: this runs
136
+ * before the repair, and a loader probe here is what {@link createPiLoad} exists to avoid.
137
+ */
138
+ function peersPresent(prefix: string): boolean {
139
+ return peers.every(peer => existsSync(path.join(prefix, 'node_modules', ...peer.split('/'))))
140
+ }
141
+
142
+ /** Prefix that contains this package's `node_modules`. */
143
+ export function installRoot(): string {
144
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..')
145
+ }
146
+
147
+ export function peerRoots(node: string, home: string, env: NodeJS.ProcessEnv): string[] {
148
+ const nodeDir = path.dirname(node)
149
+ const prefix = path.dirname(nodeDir)
150
+ const roots = [
151
+ path.join(prefix, 'lib', 'node_modules'),
152
+ path.join(nodeDir, 'node_modules'),
153
+ path.join(nodeDir, 'lib', 'node_modules'),
154
+ path.join(home, 'AppData', 'Roaming', 'npm', 'node_modules'),
155
+ ]
156
+ if (typeof env.APPDATA === 'string' && env.APPDATA.length > 0) {
157
+ const appData = path.join(env.APPDATA, 'npm', 'node_modules')
158
+ if (!roots.includes(appData)) roots.push(appData)
159
+ }
160
+ return roots
161
+ }
162
+
163
+ export function piPackage(root: string, pkg: string): string | undefined {
164
+ const direct = path.join(root, '@earendil-works', pkg)
165
+ if (existsSync(direct)) return direct
166
+ const nested = path.join(root, '@earendil-works', 'pi-coding-agent', 'node_modules', '@earendil-works', pkg)
167
+ return existsSync(nested) ? nested : undefined
168
+ }
169
+
170
+ /** Link both peers into `prefix/node_modules`. A missing package is not an error. */
171
+ export function linkPiPeers(prefix: string, roots: readonly string[]): boolean {
172
+ const destRoot = path.join(prefix, 'node_modules', '@earendil-works')
173
+ mkdirSync(destRoot, { recursive: true })
174
+ let linked = false
175
+ for (const pkg of ['pi-coding-agent', 'pi-ai'] as const) {
176
+ const src = roots.map(root => piPackage(root, pkg)).find(item => item !== undefined)
177
+ if (src === undefined) continue
178
+ placeLink(path.join(destRoot, pkg), src)
179
+ linked = true
180
+ }
181
+ return linked
182
+ }
183
+
184
+ function npmRootGlobal(env: NodeJS.ProcessEnv): Promise<string | undefined> {
185
+ const npm = path.join(path.dirname(process.execPath), process.platform === 'win32' ? 'npm.cmd' : 'npm')
186
+ if (!existsSync(npm)) return Promise.resolve(undefined)
187
+ return new Promise((resolve) => {
188
+ execFile(npm, ['root', '-g'], { env, timeout: 5_000 }, (error, stdout) => {
189
+ if (error) {
190
+ resolve(undefined)
191
+ return
192
+ }
193
+ resolve(stdout.split(/\r?\n/).find(line => line.length > 0))
194
+ })
195
+ })
196
+ }
197
+
198
+ function placeLink(dest: string, src: string): void {
199
+ try {
200
+ const stat = lstatSync(dest)
201
+ if (!stat.isSymbolicLink()) return
202
+ rmSync(dest)
203
+ } catch {
204
+ // missing dest
205
+ }
206
+ symlinkSync(src, dest, process.platform === 'win32' ? 'junction' : 'dir')
207
+ }