@xingular/harness 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +269 -2
  2. package/dist/cli.js +11046 -0
  3. package/dist/terminal-pi.js +326 -0
  4. package/package.json +41 -4
  5. package/src/accounts/accounts.test.ts +223 -0
  6. package/src/accounts/accounts.ts +522 -0
  7. package/src/accounts/env.ts +204 -0
  8. package/src/accounts/secrets.ts +50 -0
  9. package/src/accounts/store.ts +338 -0
  10. package/src/capabilities/api.ts +75 -0
  11. package/src/capabilities/runSnapshot.ts +59 -0
  12. package/src/capabilities/types.ts +34 -0
  13. package/src/cli.ts +110 -0
  14. package/src/core/bus.ts +34 -0
  15. package/src/core/changes.ts +33 -0
  16. package/src/core/env.ts +27 -0
  17. package/src/core/errors.ts +32 -0
  18. package/src/core/frontmatter.ts +47 -0
  19. package/src/core/fs.test.ts +38 -0
  20. package/src/core/fs.ts +92 -0
  21. package/src/core/index.ts +193 -0
  22. package/src/core/path.ts +45 -0
  23. package/src/core/projects.ts +90 -0
  24. package/src/core/text.ts +39 -0
  25. package/src/harness.ts +289 -0
  26. package/src/index.ts +69 -0
  27. package/src/mcp/connections.ts +441 -0
  28. package/src/mcp/manager.ts +130 -0
  29. package/src/mcp/oauth.ts +195 -0
  30. package/src/mcp/service.test.ts +376 -0
  31. package/src/mcp/service.ts +567 -0
  32. package/src/mcp/types.ts +105 -0
  33. package/src/memory/api.test.ts +82 -0
  34. package/src/memory/api.ts +287 -0
  35. package/src/memory/blocks.test.ts +78 -0
  36. package/src/memory/blocks.ts +406 -0
  37. package/src/memory/caller.ts +57 -0
  38. package/src/memory/dispatch.ts +59 -0
  39. package/src/memory/facts.test.ts +41 -0
  40. package/src/memory/facts.ts +248 -0
  41. package/src/memory/graph.ts +103 -0
  42. package/src/memory/health.test.ts +130 -0
  43. package/src/memory/health.ts +74 -0
  44. package/src/memory/maintainer.ts +272 -0
  45. package/src/memory/maintenance.test.ts +176 -0
  46. package/src/memory/maintenance.ts +330 -0
  47. package/src/memory/migrate.ts +79 -0
  48. package/src/memory/operations.ts +138 -0
  49. package/src/memory/proposals.test.ts +168 -0
  50. package/src/memory/proposals.ts +576 -0
  51. package/src/memory/recall.test.ts +104 -0
  52. package/src/memory/recall.ts +271 -0
  53. package/src/memory/references.ts +77 -0
  54. package/src/memory/runContext.ts +93 -0
  55. package/src/memory/secrets.ts +6 -0
  56. package/src/memory/store.test.ts +66 -0
  57. package/src/memory/store.ts +385 -0
  58. package/src/memory/wiki.test.ts +47 -0
  59. package/src/memory/wiki.ts +422 -0
  60. package/src/memory/wikiIndex.test.ts +88 -0
  61. package/src/memory/wikiIndex.ts +116 -0
  62. package/src/plugins/api.ts +181 -0
  63. package/src/plugins/attachments.test.ts +84 -0
  64. package/src/plugins/attachments.ts +128 -0
  65. package/src/plugins/builtin.ts +82 -0
  66. package/src/plugins/catalogue.test.ts +254 -0
  67. package/src/plugins/catalogue.ts +250 -0
  68. package/src/plugins/cli.ts +184 -0
  69. package/src/plugins/discover.ts +151 -0
  70. package/src/plugins/firstParty.test.ts +143 -0
  71. package/src/plugins/firstParty.ts +129 -0
  72. package/src/plugins/hooks.test.ts +250 -0
  73. package/src/plugins/hooks.ts +239 -0
  74. package/src/plugins/identity.ts +37 -0
  75. package/src/plugins/install.test.ts +107 -0
  76. package/src/plugins/install.ts +106 -0
  77. package/src/plugins/inventory.ts +123 -0
  78. package/src/plugins/manifest.ts +44 -0
  79. package/src/plugins/plugins.test.ts +205 -0
  80. package/src/plugins/plugins.ts +184 -0
  81. package/src/plugins/proposal.ts +121 -0
  82. package/src/plugins/runtime.test.ts +40 -0
  83. package/src/plugins/runtime.ts +82 -0
  84. package/src/plugins/trust.ts +142 -0
  85. package/src/plugins/types.ts +130 -0
  86. package/src/prompt/compile.test.ts +261 -0
  87. package/src/prompt/compile.ts +732 -0
  88. package/src/server/methods.ts +308 -0
  89. package/src/server/remote.ts +77 -0
  90. package/src/server/rpc.ts +116 -0
  91. package/src/server/serve.ts +28 -0
  92. package/src/server/server.test.ts +174 -0
  93. package/src/sessions/approvals.ts +118 -0
  94. package/src/sessions/contracts.ts +299 -0
  95. package/src/sessions/conversation.test.ts +150 -0
  96. package/src/sessions/extensionSession.test.ts +74 -0
  97. package/src/sessions/extensionUi.test.ts +65 -0
  98. package/src/sessions/extensionUi.ts +102 -0
  99. package/src/sessions/history.ts +51 -0
  100. package/src/sessions/lineageJoin.ts +75 -0
  101. package/src/sessions/policy.ts +32 -0
  102. package/src/sessions/pricing.ts +60 -0
  103. package/src/sessions/reasoningSummaries.ts +59 -0
  104. package/src/sessions/registry.ts +294 -0
  105. package/src/sessions/runIndex.ts +99 -0
  106. package/src/sessions/runStore.test.ts +152 -0
  107. package/src/sessions/runStore.ts +332 -0
  108. package/src/sessions/runner.ts +556 -0
  109. package/src/sessions/runtimeResources.test.ts +61 -0
  110. package/src/sessions/sessions.test.ts +434 -0
  111. package/src/sessions/sessions.ts +295 -0
  112. package/src/skills/registry.test.ts +104 -0
  113. package/src/skills/registry.ts +394 -0
  114. package/src/terminal/cli.ts +149 -0
  115. package/src/terminal/client.ts +74 -0
  116. package/src/terminal/controller.ts +227 -0
  117. package/src/terminal/piExtension.ts +208 -0
  118. package/src/terminal/pluginControl.test.ts +146 -0
  119. package/src/terminal/pluginMenu.ts +112 -0
  120. package/src/terminal/socket.ts +182 -0
  121. package/src/terminal/standalone.test.ts +115 -0
  122. package/src/terminal/standalone.ts +126 -0
  123. package/src/terminal/terminal.test.ts +302 -0
  124. package/src/terminal/terminals.ts +429 -0
  125. package/src/tools/askUser.ts +45 -0
  126. package/src/tools/definitions.ts +187 -0
  127. package/src/tools/memoryTools.ts +22 -0
  128. package/src/tools/pluginPropose.ts +41 -0
  129. package/src/tools/types.ts +36 -0
  130. package/src/web/safeRequest.ts +171 -0
  131. package/src/web/text.ts +53 -0
  132. package/src/web/tools.ts +134 -0
  133. 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
+ })