@xingular/harness 0.0.0-stage → 0.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.
- package/README.md +269 -2
- package/dist/cli.js +11510 -0
- package/dist/terminal-pi.js +326 -0
- package/package.json +41 -4
- package/src/accounts/accounts.test.ts +223 -0
- package/src/accounts/accounts.ts +522 -0
- package/src/accounts/env.test.ts +95 -0
- package/src/accounts/env.ts +338 -0
- package/src/accounts/secrets.ts +50 -0
- package/src/accounts/store.ts +338 -0
- package/src/capabilities/api.ts +75 -0
- package/src/capabilities/runSnapshot.ts +59 -0
- package/src/capabilities/types.ts +34 -0
- package/src/cli.ts +110 -0
- package/src/core/bus.ts +34 -0
- package/src/core/changes.ts +33 -0
- package/src/core/env.ts +27 -0
- package/src/core/errors.ts +32 -0
- package/src/core/frontmatter.ts +47 -0
- package/src/core/fs.test.ts +38 -0
- package/src/core/fs.ts +92 -0
- package/src/core/index.ts +193 -0
- package/src/core/path.ts +45 -0
- package/src/core/projects.ts +90 -0
- package/src/core/text.ts +39 -0
- package/src/harness.ts +299 -0
- package/src/index.ts +70 -0
- package/src/mcp/connections.ts +614 -0
- package/src/mcp/manager.ts +130 -0
- package/src/mcp/oauth.ts +195 -0
- package/src/mcp/scopeMigration.ts +204 -0
- package/src/mcp/service.test.ts +376 -0
- package/src/mcp/service.ts +549 -0
- package/src/mcp/types.ts +108 -0
- package/src/memory/api.test.ts +82 -0
- package/src/memory/api.ts +289 -0
- package/src/memory/blocks.test.ts +78 -0
- package/src/memory/blocks.ts +406 -0
- package/src/memory/caller.ts +57 -0
- package/src/memory/dispatch.ts +59 -0
- package/src/memory/facts.test.ts +41 -0
- package/src/memory/facts.ts +248 -0
- package/src/memory/graph.ts +103 -0
- package/src/memory/health.test.ts +130 -0
- package/src/memory/health.ts +74 -0
- package/src/memory/maintainer.ts +272 -0
- package/src/memory/maintenance.test.ts +176 -0
- package/src/memory/maintenance.ts +330 -0
- package/src/memory/migrate.ts +79 -0
- package/src/memory/operations.ts +138 -0
- package/src/memory/proposals.test.ts +168 -0
- package/src/memory/proposals.ts +576 -0
- package/src/memory/recall.test.ts +104 -0
- package/src/memory/recall.ts +271 -0
- package/src/memory/references.ts +77 -0
- package/src/memory/runContext.ts +93 -0
- package/src/memory/secrets.ts +6 -0
- package/src/memory/store.test.ts +66 -0
- package/src/memory/store.ts +385 -0
- package/src/memory/wiki.test.ts +47 -0
- package/src/memory/wiki.ts +422 -0
- package/src/memory/wikiIndex.test.ts +88 -0
- package/src/memory/wikiIndex.ts +116 -0
- package/src/plugins/api.ts +181 -0
- package/src/plugins/attachments.test.ts +84 -0
- package/src/plugins/attachments.ts +141 -0
- package/src/plugins/builtin.ts +82 -0
- package/src/plugins/catalogue.test.ts +254 -0
- package/src/plugins/catalogue.ts +251 -0
- package/src/plugins/cli.ts +184 -0
- package/src/plugins/discover.ts +151 -0
- package/src/plugins/firstParty.test.ts +143 -0
- package/src/plugins/firstParty.ts +129 -0
- package/src/plugins/hooks.test.ts +250 -0
- package/src/plugins/hooks.ts +239 -0
- package/src/plugins/identity.ts +37 -0
- package/src/plugins/install.test.ts +107 -0
- package/src/plugins/install.ts +106 -0
- package/src/plugins/inventory.ts +123 -0
- package/src/plugins/manifest.ts +76 -0
- package/src/plugins/plugins.test.ts +205 -0
- package/src/plugins/plugins.ts +189 -0
- package/src/plugins/proposal.ts +121 -0
- package/src/plugins/runtime.test.ts +40 -0
- package/src/plugins/runtime.ts +82 -0
- package/src/plugins/trust.ts +142 -0
- package/src/plugins/types.ts +149 -0
- package/src/prompt/compile.test.ts +261 -0
- package/src/prompt/compile.ts +732 -0
- package/src/server/methods.ts +314 -0
- package/src/server/remote.ts +77 -0
- package/src/server/rpc.ts +116 -0
- package/src/server/serve.ts +28 -0
- package/src/server/server.test.ts +174 -0
- package/src/sessions/approvals.ts +118 -0
- package/src/sessions/contracts.ts +299 -0
- package/src/sessions/conversation.test.ts +150 -0
- package/src/sessions/extensionSession.test.ts +74 -0
- package/src/sessions/extensionUi.test.ts +65 -0
- package/src/sessions/extensionUi.ts +102 -0
- package/src/sessions/history.ts +51 -0
- package/src/sessions/lineageJoin.ts +75 -0
- package/src/sessions/policy.ts +32 -0
- package/src/sessions/pricing.ts +60 -0
- package/src/sessions/reasoningSummaries.ts +59 -0
- package/src/sessions/registry.ts +294 -0
- package/src/sessions/runIndex.ts +99 -0
- package/src/sessions/runStore.test.ts +152 -0
- package/src/sessions/runStore.ts +332 -0
- package/src/sessions/runner.ts +556 -0
- package/src/sessions/runtimeResources.test.ts +61 -0
- package/src/sessions/sessions.test.ts +434 -0
- package/src/sessions/sessions.ts +295 -0
- package/src/skills/registry.test.ts +104 -0
- package/src/skills/registry.ts +407 -0
- package/src/terminal/cli.ts +149 -0
- package/src/terminal/client.ts +74 -0
- package/src/terminal/controller.ts +227 -0
- package/src/terminal/piExtension.ts +208 -0
- package/src/terminal/pluginControl.test.ts +146 -0
- package/src/terminal/pluginMenu.ts +112 -0
- package/src/terminal/socket.ts +182 -0
- package/src/terminal/standalone.test.ts +115 -0
- package/src/terminal/standalone.ts +126 -0
- package/src/terminal/terminal.test.ts +302 -0
- package/src/terminal/terminals.ts +429 -0
- package/src/tools/askUser.ts +45 -0
- package/src/tools/definitions.ts +187 -0
- package/src/tools/memoryTools.ts +22 -0
- package/src/tools/pluginPropose.ts +41 -0
- package/src/tools/types.ts +36 -0
- package/src/web/safeRequest.ts +171 -0
- package/src/web/text.ts +53 -0
- package/src/web/tools.ts +134 -0
- package/src/web/web.test.ts +141 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// One `pi` command running in a terminal, as the harness sees it. The terminal itself belongs
|
|
2
|
+
// to whoever embeds the harness; this follows the native process through its extension's
|
|
3
|
+
// telemetry (never by reading the screen), turns each native turn into a run, and answers the
|
|
4
|
+
// extension's questions about policy, approvals, and what to do next.
|
|
5
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
6
|
+
|
|
7
|
+
import { HarnessError } from '../core/errors'
|
|
8
|
+
import type { ActiveRun, ApprovalDecision, QuestionAnswer } from '../sessions/registry'
|
|
9
|
+
|
|
10
|
+
export interface PolicyReply {
|
|
11
|
+
allowed: boolean
|
|
12
|
+
reason?: string | null
|
|
13
|
+
requiresApproval?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ControllerHooks {
|
|
17
|
+
sessionId: string
|
|
18
|
+
nativeSessionId: string | null
|
|
19
|
+
/** Reserves or creates the run for a native turn and starts tracking it. */
|
|
20
|
+
begin(input: { reservedRunId: string | null; nativeTurnId: string; text: string }): ActiveRun
|
|
21
|
+
/** Whether a native tool call may run. */
|
|
22
|
+
policy(run: ActiveRun, tool: string, args: unknown): Promise<PolicyReply>
|
|
23
|
+
/** The native conversation identity changed. */
|
|
24
|
+
native(nativeSessionId: string): void
|
|
25
|
+
telemetry(event: string): void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Delivery {
|
|
29
|
+
runId: string
|
|
30
|
+
text: string
|
|
31
|
+
resolve: () => void
|
|
32
|
+
reject: (error: Error) => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Command = { type: 'interrupt' } | { type: 'send'; text: string }
|
|
36
|
+
|
|
37
|
+
const READY_TIMEOUT_MS = 90_000
|
|
38
|
+
const ACK_TIMEOUT_MS = 25_000
|
|
39
|
+
|
|
40
|
+
export class TerminalController {
|
|
41
|
+
private run: ActiveRun | null = null
|
|
42
|
+
private ready = false
|
|
43
|
+
private disposed = false
|
|
44
|
+
private dirty = false
|
|
45
|
+
private multilineDraft = false
|
|
46
|
+
private startupError: string | null = null
|
|
47
|
+
private startupPhase = 'Waiting for the native extension'
|
|
48
|
+
private delivery: Delivery | null = null
|
|
49
|
+
private nativeId: string | null
|
|
50
|
+
private readonly seen = new Map<string, Promise<unknown>>()
|
|
51
|
+
private readonly commands: Command[] = []
|
|
52
|
+
|
|
53
|
+
constructor(private readonly hooks: ControllerHooks) {
|
|
54
|
+
this.nativeId = hooks.nativeSessionId
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get nativeSessionId(): string | null {
|
|
58
|
+
return this.nativeId
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get activeRun(): ActiveRun | null {
|
|
62
|
+
return this.run && !this.run.finished ? this.run : null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
state(): { ready: boolean; dirty: boolean; active: boolean } {
|
|
66
|
+
return { ready: this.ready, dirty: this.dirty, active: !!this.activeRun || !!this.delivery }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private requestId(nativeId: string): string {
|
|
70
|
+
return createHash('sha256').update(`${this.hooks.sessionId}:${this.run?.runId}:${nativeId}`).digest('hex')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private begin(nativeTurnId: string, text: string): ActiveRun {
|
|
74
|
+
if (this.startupError) throw new Error(this.startupError)
|
|
75
|
+
this.dirty = false
|
|
76
|
+
this.multilineDraft = false
|
|
77
|
+
if (this.run && !this.run.finished) return this.run
|
|
78
|
+
const delivery = this.delivery
|
|
79
|
+
if (delivery && text && delivery.text !== text) throw new Error('The native prompt did not match the reserved delivery. Inspect the terminal before continuing.')
|
|
80
|
+
const run = this.hooks.begin({ reservedRunId: delivery?.runId ?? null, nativeTurnId, text })
|
|
81
|
+
this.run = run
|
|
82
|
+
run.onInterrupt(() => void this.commands.push({ type: 'interrupt' }))
|
|
83
|
+
if (text) run.emit('message.completed', { messageId: nativeTurnId, role: 'user', text })
|
|
84
|
+
delivery?.resolve()
|
|
85
|
+
this.delivery = null
|
|
86
|
+
return run
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private finish(outcome: 'completed' | 'failed' | 'interrupted', message?: string): void {
|
|
90
|
+
this.run?.finish(outcome, message ? { code: 'terminal_attention', message } : undefined)
|
|
91
|
+
this.run = null
|
|
92
|
+
this.seen.clear()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Hands the native process an instruction and waits until it acknowledges the prompt. */
|
|
96
|
+
async send(runId: string, text: string): Promise<void> {
|
|
97
|
+
if (this.disposed) throw new HarnessError('terminal_not_ready', 'Reopen the terminal before sending instructions.')
|
|
98
|
+
if (this.activeRun || this.delivery) throw new HarnessError('session_busy', 'This terminal has an active task or delivery.')
|
|
99
|
+
const deadline = Date.now() + READY_TIMEOUT_MS
|
|
100
|
+
while (!this.ready && !this.startupError && !this.disposed && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 50))
|
|
101
|
+
if (this.disposed) throw new HarnessError('terminal_not_ready', 'The terminal stopped during initialization.')
|
|
102
|
+
if (this.startupError) throw new HarnessError('terminal_not_ready', this.startupError)
|
|
103
|
+
if (!this.ready) throw new HarnessError('terminal_not_ready', `The native telemetry handshake is missing (${this.startupPhase}). Inspect the terminal sign-in or trust screen.`)
|
|
104
|
+
if (this.dirty) throw new HarnessError('terminal_has_draft', 'The terminal contains user input. Submit or clear it before handing back control.')
|
|
105
|
+
let timer: ReturnType<typeof setTimeout>
|
|
106
|
+
const ack = new Promise<void>((resolve, reject) => {
|
|
107
|
+
timer = setTimeout(() => reject(new HarnessError('delivery_uncertain', 'The native prompt acknowledgement was not received. Inspect the terminal; this instruction will not be resent.')), ACK_TIMEOUT_MS)
|
|
108
|
+
this.delivery = { runId, text, resolve, reject }
|
|
109
|
+
})
|
|
110
|
+
// Delivery can fail while the native process is still starting the turn. Observe it immediately.
|
|
111
|
+
void ack.catch(() => {})
|
|
112
|
+
try {
|
|
113
|
+
this.commands.push({ type: 'send', text })
|
|
114
|
+
await ack
|
|
115
|
+
} catch (error) {
|
|
116
|
+
this.ready = false
|
|
117
|
+
this.startupError = error instanceof Error ? error.message : String(error)
|
|
118
|
+
this.delivery = null
|
|
119
|
+
this.commands.length = 0
|
|
120
|
+
this.commands.push({ type: 'interrupt' })
|
|
121
|
+
this.finish('failed', this.startupError)
|
|
122
|
+
throw error
|
|
123
|
+
} finally {
|
|
124
|
+
clearTimeout(timer!)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** What the person typed into the terminal, so an instruction is never sent over their draft. */
|
|
129
|
+
onInput(data: string): void {
|
|
130
|
+
if (!this.ready || this.activeRun || data === '\u0003') return
|
|
131
|
+
if (data.includes('\n') || data.includes('\r')) this.multilineDraft = true
|
|
132
|
+
this.dirty = data !== '\u0015' || this.multilineDraft
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The native process hung up while it was waiting for a decision. */
|
|
136
|
+
disconnected(): void {
|
|
137
|
+
if (this.disposed) return
|
|
138
|
+
this.ready = false
|
|
139
|
+
this.commands.push({ type: 'interrupt' })
|
|
140
|
+
this.finish('failed', 'The native decision callback disconnected. Inspect the terminal before continuing.')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async accept(event: string, data: Record<string, any>): Promise<unknown> {
|
|
144
|
+
if (this.disposed) throw new Error('This terminal controller was disposed.')
|
|
145
|
+
if (event !== 'poll') this.hooks.telemetry(event)
|
|
146
|
+
if (event === 'startup') {
|
|
147
|
+
this.startupPhase = String(data.phase)
|
|
148
|
+
return {}
|
|
149
|
+
}
|
|
150
|
+
if (event === 'startupFailed') {
|
|
151
|
+
this.startupError = String(data.reason ?? 'The native extension could not initialize.')
|
|
152
|
+
this.ready = false
|
|
153
|
+
this.finish('failed', this.startupError)
|
|
154
|
+
return {}
|
|
155
|
+
}
|
|
156
|
+
if (event === 'poll') return { command: this.commands.shift() ?? null }
|
|
157
|
+
if (event === 'ready') {
|
|
158
|
+
if (!this.ready) {
|
|
159
|
+
this.dirty = false
|
|
160
|
+
this.multilineDraft = false
|
|
161
|
+
}
|
|
162
|
+
this.ready = true
|
|
163
|
+
if (typeof data.nativeSessionId === 'string' && data.nativeSessionId) {
|
|
164
|
+
this.nativeId = data.nativeSessionId
|
|
165
|
+
this.hooks.native(data.nativeSessionId)
|
|
166
|
+
}
|
|
167
|
+
return {}
|
|
168
|
+
}
|
|
169
|
+
if (event === 'begin') {
|
|
170
|
+
this.begin(String(data.turnId ?? randomUUID()), String(data.text ?? ''))
|
|
171
|
+
return {}
|
|
172
|
+
}
|
|
173
|
+
if (event === 'shutdown') {
|
|
174
|
+
this.ready = false
|
|
175
|
+
this.finish('interrupted')
|
|
176
|
+
return {}
|
|
177
|
+
}
|
|
178
|
+
if (event === 'settled') {
|
|
179
|
+
this.finish(data.error ? 'failed' : 'completed', data.error ? String(data.error) : undefined)
|
|
180
|
+
return {}
|
|
181
|
+
}
|
|
182
|
+
const run = this.run
|
|
183
|
+
if (!run) throw new Error('Native telemetry arrived without an active turn.')
|
|
184
|
+
if (event === 'policy') return this.hooks.policy(run, String(data.tool), data.args ?? {})
|
|
185
|
+
if (event === 'approval' || event === 'question') {
|
|
186
|
+
const key = this.requestId(String(data.requestId ?? JSON.stringify(data)))
|
|
187
|
+
const existing = this.seen.get(key)
|
|
188
|
+
if (existing) return event === 'approval' ? { decision: await existing } : existing
|
|
189
|
+
if (event === 'question') {
|
|
190
|
+
const response: Promise<QuestionAnswer> = run.requestAnswer({ requestId: key, question: String(data.question ?? ''), options: data.options ?? [], multiSelect: !!data.multiSelect, allowText: true })
|
|
191
|
+
this.seen.set(key, response)
|
|
192
|
+
return response
|
|
193
|
+
}
|
|
194
|
+
const response: Promise<ApprovalDecision> = run.requestApproval({
|
|
195
|
+
requestId: key,
|
|
196
|
+
target: String(data.target ?? 'Native action'),
|
|
197
|
+
action: String(data.action ?? '{}'),
|
|
198
|
+
operation: { tool: String(data.tool ?? data.target), args: data.args ?? {} },
|
|
199
|
+
rememberable: false
|
|
200
|
+
})
|
|
201
|
+
this.seen.set(key, response)
|
|
202
|
+
return { decision: await response }
|
|
203
|
+
}
|
|
204
|
+
if (event === 'usage') run.emit('usage.updated', data)
|
|
205
|
+
else if (event === 'file') run.emit('file.changed', data)
|
|
206
|
+
else if (event === 'message') run.emit('message.completed', { messageId: data.id ?? randomUUID(), role: data.role ?? 'assistant', text: String(data.text) })
|
|
207
|
+
else if (event === 'tool') {
|
|
208
|
+
run.emit(data.phase === 'start' ? 'tool.started' : 'tool.completed', {
|
|
209
|
+
toolCallId: String(data.id),
|
|
210
|
+
name: String(data.name),
|
|
211
|
+
...(data.phase === 'start'
|
|
212
|
+
? { summary: String(data.summary ?? '') }
|
|
213
|
+
: { ok: data.ok !== false, ...(typeof data.durationMs === 'number' ? { durationMs: data.durationMs } : {}), ...(typeof data.resultChars === 'number' ? { resultChars: data.resultChars } : {}) })
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
return {}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
dispose(): void {
|
|
220
|
+
if (this.disposed) return
|
|
221
|
+
this.disposed = true
|
|
222
|
+
this.ready = false
|
|
223
|
+
this.delivery?.reject(new Error('The terminal stopped before prompt acknowledgement.'))
|
|
224
|
+
this.delivery = null
|
|
225
|
+
this.finish('interrupted')
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Loaded by the `pi` command in a terminal session (`--extension`). It reports what the native
|
|
2
|
+
// session does to the harness, asks before each tool call, takes instructions the harness
|
|
3
|
+
// queues for it, and gives the session the harness's own tools. It runs in the `pi` process,
|
|
4
|
+
// so everything it knows about the harness comes through the session socket.
|
|
5
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
6
|
+
import { Type } from 'typebox'
|
|
7
|
+
|
|
8
|
+
import { connectionFromEnv, socketCall, terminalCall } from './client'
|
|
9
|
+
import { runPluginsMenu } from './pluginMenu'
|
|
10
|
+
|
|
11
|
+
interface SocketTool {
|
|
12
|
+
name: string
|
|
13
|
+
label: string
|
|
14
|
+
description: string
|
|
15
|
+
parameters: Record<string, unknown>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** How much text a tool handed back to the model. */
|
|
19
|
+
function resultSize(result: unknown): number | null {
|
|
20
|
+
const content = (result as { content?: unknown } | null | undefined)?.content
|
|
21
|
+
if (!Array.isArray(content)) return typeof result === 'string' ? result.length : null
|
|
22
|
+
return content.reduce((total: number, part: { type?: string; text?: unknown }) => total + (part?.type === 'text' && typeof part.text === 'string' ? part.text.length : 0), 0)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default async function (pi: ExtensionAPI) {
|
|
26
|
+
await terminalCall('startup', { phase: 'Pi extension loaded' })
|
|
27
|
+
let context: ExtensionContext | null = null
|
|
28
|
+
let timer: ReturnType<typeof setInterval> | null = null
|
|
29
|
+
let polling = false
|
|
30
|
+
let failure: string | undefined
|
|
31
|
+
const emptyUsage = () => ({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, requests: 0, costUsd: 0 })
|
|
32
|
+
let usage = emptyUsage()
|
|
33
|
+
let reasoningTokens: number | undefined
|
|
34
|
+
let costReported = false
|
|
35
|
+
let requestStartedAt: number | null = null
|
|
36
|
+
const toolStartedAt = new Map<string, number>()
|
|
37
|
+
const fileTools = new Map<string, string>()
|
|
38
|
+
|
|
39
|
+
// The harness's own tools for this session (connected apps and what the application offers).
|
|
40
|
+
let stage = 'Tool discovery'
|
|
41
|
+
try {
|
|
42
|
+
await terminalCall('startup', { phase: stage })
|
|
43
|
+
const connection = connectionFromEnv()
|
|
44
|
+
// Claimed here, before the model has run anything, and kept in this process's memory: a
|
|
45
|
+
// command the model runs can read the session token from its environment, never this key.
|
|
46
|
+
const control = await socketCall<{ key: string }>(connection, 'control.claim', {}, 15_000).then((claimed) => claimed.key).catch(() => null)
|
|
47
|
+
pi.registerCommand('plugins', {
|
|
48
|
+
description: 'See what this session loads, and switch a plugin on or off or trust its code',
|
|
49
|
+
handler: async (_args, ctx) => {
|
|
50
|
+
if (!control) return ctx.ui.notify('This session cannot change plugins.', 'warning')
|
|
51
|
+
const { projectId } = await socketCall<{ projectId: string | null }>(connection, 'plugins.session', {}, 15_000, control).catch(() => ({ projectId: null }))
|
|
52
|
+
await runPluginsMenu(
|
|
53
|
+
{ select: (title, options) => ctx.ui.select(title, options), confirm: (title, message) => ctx.ui.confirm(title, message), notify: (message, level) => ctx.ui.notify(message, level) },
|
|
54
|
+
(method, params) => socketCall(connection, method, params ?? {}, 60_000, control),
|
|
55
|
+
projectId
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
const { tools } = await socketCall<{ tools: SocketTool[] }>(connection, 'tools.list', {}, 15_000)
|
|
60
|
+
stage = 'Tool registration'
|
|
61
|
+
for (const tool of tools) {
|
|
62
|
+
pi.registerTool({
|
|
63
|
+
name: tool.name,
|
|
64
|
+
label: tool.label,
|
|
65
|
+
description: tool.description,
|
|
66
|
+
parameters: Type.Unsafe(tool.parameters),
|
|
67
|
+
async execute(_id, input) {
|
|
68
|
+
const result = await socketCall<{ text: string }>(connection, 'tools.call', { name: tool.name, input: input as Record<string, unknown> }, 1_230_000)
|
|
69
|
+
return { content: [{ type: 'text', text: result.text }], details: undefined }
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
} catch (error) {
|
|
74
|
+
await terminalCall('startupFailed', { reason: `${stage} failed: ${error instanceof Error ? error.message : 'Unavailable'}` }).catch(() => {})
|
|
75
|
+
throw error
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
79
|
+
context = ctx
|
|
80
|
+
await terminalCall('ready', { nativeSessionId: ctx.sessionManager.getSessionFile() })
|
|
81
|
+
timer = setInterval(() => {
|
|
82
|
+
if (polling || !context) return
|
|
83
|
+
polling = true
|
|
84
|
+
void terminalCall<{ command: { type: string; text?: string } | null }>('poll', {}, 5_000)
|
|
85
|
+
.then(({ command }) => {
|
|
86
|
+
if (command?.type === 'interrupt') context?.abort()
|
|
87
|
+
else if (command?.type === 'send') {
|
|
88
|
+
if (context?.ui.getEditorText().trim()) throw new Error('The terminal contains a user draft.')
|
|
89
|
+
pi.sendUserMessage(command.text!)
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
.catch(() => {
|
|
93
|
+
context?.abort()
|
|
94
|
+
if (timer) clearInterval(timer)
|
|
95
|
+
})
|
|
96
|
+
.finally(() => {
|
|
97
|
+
polling = false
|
|
98
|
+
})
|
|
99
|
+
}, 100)
|
|
100
|
+
})
|
|
101
|
+
pi.on('before_agent_start', async (event, ctx) => {
|
|
102
|
+
context = ctx
|
|
103
|
+
failure = undefined
|
|
104
|
+
usage = emptyUsage()
|
|
105
|
+
reasoningTokens = undefined
|
|
106
|
+
costReported = false
|
|
107
|
+
await terminalCall('begin', { text: event.prompt })
|
|
108
|
+
})
|
|
109
|
+
pi.on('message_end', async (event) => {
|
|
110
|
+
if (event.message.role !== 'assistant') return
|
|
111
|
+
if (event.message.stopReason === 'error' || event.message.stopReason === 'aborted') failure = event.message.errorMessage ?? `Pi stopped: ${event.message.stopReason}`
|
|
112
|
+
const text = event.message.content.filter((part) => part.type === 'text').map((part) => part.text).join('\n')
|
|
113
|
+
if (text) await terminalCall('message', { text })
|
|
114
|
+
if (event.message.usage) {
|
|
115
|
+
const reported = event.message.usage
|
|
116
|
+
usage.inputTokens += reported.input ?? 0
|
|
117
|
+
usage.outputTokens += reported.output ?? 0
|
|
118
|
+
usage.cacheReadTokens += reported.cacheRead ?? 0
|
|
119
|
+
usage.cacheWriteTokens += reported.cacheWrite ?? 0
|
|
120
|
+
usage.requests += 1
|
|
121
|
+
if (typeof reported.reasoning === 'number') reasoningTokens = (reasoningTokens ?? 0) + reported.reasoning
|
|
122
|
+
const cost = reported.cost?.total
|
|
123
|
+
if (typeof cost === 'number') {
|
|
124
|
+
usage.costUsd += cost
|
|
125
|
+
costReported = true
|
|
126
|
+
}
|
|
127
|
+
const identity = { model: event.message.model, provider: event.message.provider }
|
|
128
|
+
const { costUsd, ...tokens } = usage
|
|
129
|
+
await terminalCall('usage', {
|
|
130
|
+
...tokens,
|
|
131
|
+
...(costReported ? { costUsd, costSource: 'harness' } : {}),
|
|
132
|
+
...(reasoningTokens === undefined ? {} : { reasoningTokens }),
|
|
133
|
+
contextTokens: (reported.input ?? 0) + (reported.cacheRead ?? 0) + (reported.cacheWrite ?? 0),
|
|
134
|
+
...(context?.model?.contextWindow ? { contextWindow: context.model.contextWindow } : {}),
|
|
135
|
+
...identity,
|
|
136
|
+
request: {
|
|
137
|
+
index: usage.requests,
|
|
138
|
+
...identity,
|
|
139
|
+
responseId: event.message.responseId,
|
|
140
|
+
inputTokens: reported.input ?? 0,
|
|
141
|
+
cacheReadTokens: reported.cacheRead ?? 0,
|
|
142
|
+
cacheWriteTokens: reported.cacheWrite ?? 0,
|
|
143
|
+
outputTokens: reported.output ?? 0,
|
|
144
|
+
...(typeof reported.reasoning === 'number' ? { reasoningTokens: reported.reasoning } : {}),
|
|
145
|
+
...(typeof cost === 'number' ? { costUsd: cost, costSource: 'harness' } : {}),
|
|
146
|
+
...(requestStartedAt === null ? {} : { durationMs: Date.now() - requestStartedAt }),
|
|
147
|
+
stopReason: event.message.stopReason
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
requestStartedAt = null
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
pi.on('turn_start', async () => {
|
|
154
|
+
requestStartedAt = Date.now()
|
|
155
|
+
})
|
|
156
|
+
pi.on('tool_call', async (event) => {
|
|
157
|
+
try {
|
|
158
|
+
const policy = await terminalCall<{ allowed: boolean; reason?: string; requiresApproval?: boolean }>('policy', { tool: event.toolName, args: event.input })
|
|
159
|
+
if (policy.requiresApproval) {
|
|
160
|
+
const approval = await terminalCall<{ decision: string }>('approval', { requestId: event.toolCallId, target: event.toolName, action: JSON.stringify(event.input), tool: event.toolName, args: event.input })
|
|
161
|
+
if (approval.decision === 'approved') return
|
|
162
|
+
return { block: true, reason: 'The user denied this action. Ask how they would like to proceed.' }
|
|
163
|
+
}
|
|
164
|
+
if (!policy.allowed) return { block: true, reason: policy.reason ?? 'Outside what this session may do.' }
|
|
165
|
+
} catch {
|
|
166
|
+
return { block: true, reason: 'The terminal controller is unavailable.' }
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
pi.on('tool_execution_start', async (event) => {
|
|
170
|
+
if (['edit', 'write'].includes(event.toolName) && typeof event.args.path === 'string') fileTools.set(event.toolCallId, event.args.path)
|
|
171
|
+
toolStartedAt.set(event.toolCallId, Date.now())
|
|
172
|
+
await terminalCall('tool', { phase: 'start', id: event.toolCallId, name: event.toolName, summary: JSON.stringify(event.args).slice(0, 200) })
|
|
173
|
+
})
|
|
174
|
+
pi.on('tool_execution_end', async (event) => {
|
|
175
|
+
const started = toolStartedAt.get(event.toolCallId)
|
|
176
|
+
toolStartedAt.delete(event.toolCallId)
|
|
177
|
+
const resultChars = resultSize(event.result)
|
|
178
|
+
await terminalCall('tool', { phase: 'end', id: event.toolCallId, name: event.toolName, ok: !event.isError, ...(started === undefined ? {} : { durationMs: Date.now() - started }), ...(resultChars === null ? {} : { resultChars }) })
|
|
179
|
+
const path = fileTools.get(event.toolCallId)
|
|
180
|
+
fileTools.delete(event.toolCallId)
|
|
181
|
+
if (!event.isError && path) await terminalCall('file', { path, change: 'modified' })
|
|
182
|
+
})
|
|
183
|
+
pi.on('agent_settled', async () => {
|
|
184
|
+
await terminalCall('settled', { error: failure })
|
|
185
|
+
})
|
|
186
|
+
pi.on('user_bash', async () => {
|
|
187
|
+
throw new Error('Use the bash tool so this action can be checked.')
|
|
188
|
+
})
|
|
189
|
+
pi.on('session_shutdown', async () => {
|
|
190
|
+
if (timer) clearInterval(timer)
|
|
191
|
+
await terminalCall('shutdown').catch(() => {})
|
|
192
|
+
})
|
|
193
|
+
pi.registerTool({
|
|
194
|
+
name: 'ask_user_question',
|
|
195
|
+
label: 'Ask a question',
|
|
196
|
+
description: 'Ask the user a question and wait for their answer.',
|
|
197
|
+
parameters: Type.Object({
|
|
198
|
+
question: Type.String(),
|
|
199
|
+
options: Type.Optional(Type.Array(Type.Object({ label: Type.String(), description: Type.Optional(Type.String()) }))),
|
|
200
|
+
multiSelect: Type.Optional(Type.Boolean())
|
|
201
|
+
}),
|
|
202
|
+
async execute(id, input) {
|
|
203
|
+
const answer = await terminalCall('question', { requestId: id, ...input })
|
|
204
|
+
return { content: [{ type: 'text', text: JSON.stringify(answer) }], details: undefined }
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
await terminalCall('startup', { phase: 'Pi hooks registered' })
|
|
208
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { memorySecretStore } from '../accounts/secrets'
|
|
7
|
+
import { createHarness, type Harness } from '../harness'
|
|
8
|
+
import { runPluginsCli } from '../plugins/cli'
|
|
9
|
+
import type { Plugin } from '../plugins/types'
|
|
10
|
+
import { rootWithProject, writeFile } from '../../tests/helpers'
|
|
11
|
+
import { SOCKET_ENV, TOKEN_ENV, socketCall, type SocketConnection } from './client'
|
|
12
|
+
import { menuRows, rowActions, runPluginsMenu, type MenuUi } from './pluginMenu'
|
|
13
|
+
|
|
14
|
+
const open: Harness[] = []
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
for (const harness of open.splice(0)) await harness.close()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
function kit(folder: string): void {
|
|
20
|
+
writeFile(join(folder, 'package.json'), JSON.stringify({ name: 'review-kit', description: 'Review helpers.', version: '1.0.0' }))
|
|
21
|
+
writeFile(join(folder, 'extensions/guard.ts'), 'export default function () {}\n')
|
|
22
|
+
writeFile(join(folder, 'skills/audit/SKILL.md'), '---\nname: audit\ndescription: Audit a change.\n---\nSteps.\n')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function session() {
|
|
26
|
+
const root = rootWithProject()
|
|
27
|
+
const base = mkdtempSync(join(tmpdir(), 'control-'))
|
|
28
|
+
writeFile(join(root, 'registry/rules/tests.md'), '---\nname: Run tests\ndescription: Always test.\n---\nAlways run the tests.\n')
|
|
29
|
+
const harness = createHarness({ dataRoot: root, stateDir: join(base, 'state'), secrets: memorySecretStore(), piProgram: () => '/opt/pi/bin/pi', assets: { node: '/opt/node', cli: '/opt/harness/cli.js', piExtension: '/opt/harness/terminal-pi.js' } })
|
|
30
|
+
open.push(harness)
|
|
31
|
+
const account = harness.accounts.create('Main', 'apiKey').accounts[0]
|
|
32
|
+
harness.plugins.trust.adopt([])
|
|
33
|
+
kit(join(root, 'plugins/review-kit'))
|
|
34
|
+
const launch = await harness.terminals.prepare({ sessionId: 'term-1', accountId: account.id, projectId: 'p1', cwd: base, sessionFolder: join(root, 'sessions', 'p1', 'term-1') })
|
|
35
|
+
const connection: SocketConnection = { socket: String(launch.env[SOCKET_ENV]), token: String(launch.env[TOKEN_ENV]) }
|
|
36
|
+
return { harness, root, base, connection }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A person at the terminal, answering each dialog in turn from a script. */
|
|
40
|
+
function person(answers: (string | boolean | undefined)[]) {
|
|
41
|
+
const shown: string[] = []
|
|
42
|
+
const said: string[] = []
|
|
43
|
+
const ui: MenuUi = {
|
|
44
|
+
select: async (title, options) => {
|
|
45
|
+
shown.push(`${title} → ${options.join(' | ')}`)
|
|
46
|
+
return answers.shift() as string | undefined
|
|
47
|
+
},
|
|
48
|
+
confirm: async (title, message) => {
|
|
49
|
+
shown.push(`${title} ${message}`)
|
|
50
|
+
return answers.shift() === true
|
|
51
|
+
},
|
|
52
|
+
notify: (message) => void said.push(message)
|
|
53
|
+
}
|
|
54
|
+
return { ui, shown, said }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('plugin control from a terminal session', () => {
|
|
58
|
+
it("lists for anyone in the session, and changes only for the session's own extension", async () => {
|
|
59
|
+
const { connection } = await session()
|
|
60
|
+
const listed = await socketCall<Plugin[]>(connection, 'plugins.list')
|
|
61
|
+
const plugin = listed.find((candidate) => candidate.id === 'package:review-kit')!
|
|
62
|
+
expect(plugin).toMatchObject({ trust: 'untrusted' })
|
|
63
|
+
// A command the model runs in the shell has the session token and nothing more.
|
|
64
|
+
await expect(socketCall(connection, 'plugins.trust', { hash: plugin.code!.hash })).rejects.toThrow('Only a person at this terminal can change plugins')
|
|
65
|
+
await expect(socketCall(connection, 'plugins.detach', { id: plugin.id })).rejects.toThrow('Only a person at this terminal')
|
|
66
|
+
await expect(socketCall(connection, 'plugins.trust', { hash: plugin.code!.hash }, 5_000, 'guessed')).rejects.toThrow('Only a person at this terminal')
|
|
67
|
+
|
|
68
|
+
// The extension claims the key as it loads. It is given out once.
|
|
69
|
+
const { key } = await socketCall<{ key: string }>(connection, 'control.claim')
|
|
70
|
+
await expect(socketCall(connection, 'control.claim')).rejects.toThrow('already claimed')
|
|
71
|
+
const trusted = await socketCall<Plugin[]>(connection, 'plugins.trust', { hash: plugin.code!.hash }, 5_000, key)
|
|
72
|
+
expect(trusted.find((candidate) => candidate.id === plugin.id)).toMatchObject({ trust: 'trusted' })
|
|
73
|
+
// Without "everywhere", a switch from a terminal is for the session's project.
|
|
74
|
+
const off = await socketCall<Plugin[]>(connection, 'plugins.detach', { id: plugin.id }, 5_000, key)
|
|
75
|
+
expect(off.find((candidate) => candidate.id === plugin.id)!.attachment).toEqual({ global: true, on: { agentIds: [], projectIds: [] }, off: { agentIds: [], projectIds: ['p1'] } })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('walks a person through switching and trusting, and changes nothing they did not choose', async () => {
|
|
79
|
+
const { harness, connection } = await session()
|
|
80
|
+
const { key } = await socketCall<{ key: string }>(connection, 'control.claim')
|
|
81
|
+
const call = <T>(method: string, params?: Record<string, unknown>) => socketCall<T>(connection, method, params ?? {}, 5_000, key)
|
|
82
|
+
const rows = menuRows(await call<Plugin[]>('plugins.list'), 'p1')
|
|
83
|
+
expect(rows.map((row) => row.label)).toEqual(['● review-kit · code not trusted', '○ Run tests · instruction in Yours', '○ Budget guard · runs code', '○ Output trim · runs code'])
|
|
84
|
+
expect(rowActions(rows[0], 'p1')).toEqual(['Switch off for this project', 'Switch off everywhere', 'Trust its code', 'Back'])
|
|
85
|
+
expect(rowActions(rows[1], null)).toEqual(['Switch on everywhere', 'Back'])
|
|
86
|
+
// A part that ships with the harness needs no trust: it is the harness's own code.
|
|
87
|
+
expect(rowActions(rows[2], 'p1')).toEqual(['Switch on for this project', 'Switch on everywhere', 'Back'])
|
|
88
|
+
|
|
89
|
+
// Declining the trust question leaves the code untrusted; then they trust it, switch a rule on, and leave.
|
|
90
|
+
const { ui, shown, said } = person(['● review-kit · code not trusted', 'Trust its code', false, '● review-kit · code not trusted', 'Trust its code', true, '○ Run tests · instruction in Yours', 'Switch on for this project', 'Done'])
|
|
91
|
+
await runPluginsMenu(ui, call, 'p1')
|
|
92
|
+
expect(shown.find((line) => line.startsWith('Trust this code?'))).toContain('extensions/guard.ts')
|
|
93
|
+
expect(shown.find((line) => line.startsWith('Trust this code?'))).toContain('can do anything this app can')
|
|
94
|
+
expect(said).toEqual(['Saved. It applies when this session restarts.'])
|
|
95
|
+
const after = await call<Plugin[]>('plugins.list')
|
|
96
|
+
expect(after.find((plugin) => plugin.id === 'package:review-kit')).toMatchObject({ trust: 'trusted' })
|
|
97
|
+
expect(after.flatMap((plugin) => plugin.contributions).find((entry) => entry.id === 'yours/rules/tests')).toMatchObject({ state: 'on', scope: 'App' })
|
|
98
|
+
expect(harness.plugins.trust.list()).toHaveLength(1)
|
|
99
|
+
|
|
100
|
+
// Leaving at once changes nothing and says nothing.
|
|
101
|
+
const idle = person([undefined])
|
|
102
|
+
await runPluginsMenu(idle.ui, call, 'p1')
|
|
103
|
+
expect(idle.said).toEqual([])
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('the plugins command', () => {
|
|
108
|
+
it('lists, installs, switches, trusts only on a yes, and removes, on its own data', async () => {
|
|
109
|
+
const base = mkdtempSync(join(tmpdir(), 'plugins-cli-'))
|
|
110
|
+
kit(join(base, 'kit'))
|
|
111
|
+
const lines: string[] = []
|
|
112
|
+
const run = (argv: string[], answer?: string) => runPluginsCli([...argv, '--data', join(base, 'data'), '--state', join(base, 'state')], { home: base, cwd: base, write: (text) => void lines.push(text), ...(answer === undefined ? {} : { ask: async () => answer }) })
|
|
113
|
+
const last = (): string => lines.at(-1) ?? ''
|
|
114
|
+
|
|
115
|
+
expect(await run(['list'])).toBe(0)
|
|
116
|
+
expect(last()).toMatch(/^memory\s+required\s+Memory$/m)
|
|
117
|
+
expect(await run(['install', join(base, 'kit')])).toBe(0)
|
|
118
|
+
expect(last()).toContain('Installed, switched off.')
|
|
119
|
+
expect(last()).toContain('trust its code with "plugins trust <id>"')
|
|
120
|
+
expect(await run(['list'])).toBe(0)
|
|
121
|
+
expect(last()).toMatch(/^package:review-kit\s+off, code not trusted\s+review-kit$/m)
|
|
122
|
+
|
|
123
|
+
expect(await run(['attach', 'package:review-kit'])).toBe(0)
|
|
124
|
+
expect(last()).toBe('package:review-kit is on everywhere. It applies from the next session start.\n')
|
|
125
|
+
// No terminal to ask on and no --yes: the files are shown and nothing is trusted.
|
|
126
|
+
expect(await run(['trust', 'package:review-kit'])).toBe(1)
|
|
127
|
+
expect(lines.at(-2)).toContain('extensions/guard.ts')
|
|
128
|
+
expect(last()).toBe('Read the files, then run this again with --yes to trust them.\n')
|
|
129
|
+
expect(await run(['trust', 'package:review-kit'], 'no')).toBe(1)
|
|
130
|
+
expect(last()).toBe('Not trusted.\n')
|
|
131
|
+
expect(await run(['trust', 'package:review-kit'], 'yes')).toBe(0)
|
|
132
|
+
expect(await run(['show', 'package:review-kit'])).toBe(0)
|
|
133
|
+
expect(last()).toContain('State: on')
|
|
134
|
+
expect(last()).toContain('Code (trusted)')
|
|
135
|
+
expect(last()).toContain('● skill audit (package:review-kit/skill/audit)')
|
|
136
|
+
|
|
137
|
+
expect(await run(['detach', 'memory'])).toBe(1)
|
|
138
|
+
expect(last()).toContain('Memory cannot be switched off.')
|
|
139
|
+
expect(await run(['untrust', 'package:review-kit'])).toBe(0)
|
|
140
|
+
expect(await run(['remove', 'package:review-kit'])).toBe(0)
|
|
141
|
+
expect(await run(['show', 'package:review-kit'])).toBe(1)
|
|
142
|
+
expect(await run(['nonsense'])).toBe(2)
|
|
143
|
+
// The folder that was installed in place is still the person's.
|
|
144
|
+
expect(existsSync(join(base, 'kit/package.json'))).toBe(true)
|
|
145
|
+
})
|
|
146
|
+
})
|