@anureo/adapter-pi 0.0.0-stage → 0.3.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.
package/src/adapter.js ADDED
@@ -0,0 +1,730 @@
1
+ /** Owns Pi session admission and ordered host-event publication through the SDK adapter ports. */
2
+ import { randomUUID } from 'node:crypto';
3
+ import { AHP_PROTOCOL_CONTRACT, assertRequest, negotiateVersion, validateJsonSchema, } from '@anureo/protocol';
4
+ import { HarnessSdkError, sdkError } from '@anureo/sdk';
5
+ import { createPiHost, } from "./host.js";
6
+ import { PERMISSION_MODES, completeTool, contentText, createRedactor, historyItems, nativeInput, permissionTools, piTurnError, toolItem, turnStarts, usageOf, } from "./mapping.js";
7
+ import { capabilities, resolvePiProfile } from "./profile.js";
8
+ /** Connect an SDK client to Pi. Hosts open on session admission. Await client disposal to release them. */
9
+ export function createPiAdapter(options) { return new PiAdapter(options); }
10
+ /**
11
+ * Connect createClient to Pi hosts. Applications normally use createPiAdapter and await client
12
+ * disposal. Direct adapter consumers must await disposal of the adapter and its tracked sessions.
13
+ */
14
+ export class PiAdapter {
15
+ /** Capture the version, host factory and redactor without I/O. Omitting the version selects the baseline profile. */
16
+ constructor(options = {}) {
17
+ this.options = options;
18
+ this.version = options.version;
19
+ this.profile = resolvePiProfile(options.version);
20
+ this.factory = options.createHost ?? createPiHost;
21
+ this.redact = createRedactor(options.secrets);
22
+ }
23
+ /** Requested persistence, defaulting to true. Hosts persist only when the version profile supports resume. */
24
+ get persist() { return this.options.persistSessions ?? true; }
25
+ /** Native capability set for negotiation; the SDK may add structured output when a host exposes its tool port. */
26
+ get capabilities() { return capabilities(this.profile, this.persist); }
27
+ /**
28
+ * Negotiate protocol v1 without opening Pi. Return runtime info and native capabilities for
29
+ * Client.initialize through the output wrapper. Invalid or incompatible requests and disposed
30
+ * adapters reject.
31
+ */
32
+ async initialize(params) {
33
+ assertRequest('initialize', params);
34
+ negotiateVersion(params.protocolVersions);
35
+ if (this.disposed)
36
+ throw new HarnessSdkError('RUNTIME_DISPOSED');
37
+ return { protocolVersion: 1, protocolContract: AHP_PROTOCOL_CONTRACT, runtime: { name: 'pi', version: this.version ?? 'unknown', adapter: 'pi-native/0.1.0' }, capabilities: this.capabilities };
38
+ }
39
+ /**
40
+ * Acquire an owned backend for OutputSession.open with host observation registered. A disposed
41
+ * adapter, non-Pi binding or nonempty MCP server list rejects before acquisition. If open fails
42
+ * after construction, attempt backend disposal before rethrowing the setup failure. The output
43
+ * wrapper owns disposal of the returned backend.
44
+ */
45
+ async openSession(params, sink) {
46
+ if (this.disposed)
47
+ throw new HarnessSdkError('RUNTIME_DISPOSED');
48
+ if (params.mcpServers?.length)
49
+ throw new HarnessSdkError('CAPABILITY_UNSUPPORTED', 'Pi uses direct tools and does not accept MCP servers');
50
+ if (params.binding.adapterId !== 'pi')
51
+ throw new HarnessSdkError('SESSION_BINDING_MISMATCH');
52
+ const session = new PiBackendSession(this, params, sink);
53
+ try {
54
+ await session.open();
55
+ }
56
+ catch (cause) {
57
+ await session.dispose().catch(() => { });
58
+ throw cause;
59
+ }
60
+ this.sessions.add(session);
61
+ return session;
62
+ }
63
+ /** Attempt release of all tracked sessions concurrently. Reject the first failure in tracking order after all attempts settle. Later calls are no-ops. */
64
+ async dispose() {
65
+ if (this.disposed)
66
+ return;
67
+ this.disposed = true;
68
+ const results = await Promise.allSettled([...this.sessions].map(session => session.dispose()));
69
+ this.sessions.clear();
70
+ const failed = results.find((value) => value.status === 'rejected');
71
+ if (failed)
72
+ throw failed.reason;
73
+ }
74
+ /** Supply the host factory to PiBackendSession.open. The acquiring backend owns each returned host. */
75
+ hostFactory() { return this.factory; }
76
+ /** PiBackendSession.dispose removes itself after release. Turn mappings remain available for resume and fork. */
77
+ forget(session) { this.sessions.delete(session); }
78
+ /** Find a tracked source for PiBackendSession.forkHost to borrow. The source backend retains disposal ownership. */
79
+ liveHost(nativeId) {
80
+ for (const session of this.sessions) {
81
+ if (session.nativeId === nativeId)
82
+ return session.host;
83
+ }
84
+ }
85
+ /** Original option object retained by reference; host paths/history limits are read when used. */
86
+ options;
87
+ /** Version profile selected at construction. Persistence policy also affects advertised capabilities. */
88
+ profile;
89
+ sessions = new Set();
90
+ /** Shared maps keyed by Pi session ID, then native user entry ID, to retain SDK turn IDs across resume and fork. */
91
+ turnMaps = new Map();
92
+ /** Content redactor shared by backends, captured from the constructor options. */
93
+ redact;
94
+ factory;
95
+ disposed = false;
96
+ version;
97
+ }
98
+ /**
99
+ * Observe and control one Pi session through the SDK backend port. OutputSession adopts the
100
+ * backend returned by PiAdapter.openSession and owns disposal. Input receipts acknowledge native
101
+ * admission. Sink events carry later execution and settlement.
102
+ */
103
+ export class PiBackendSession {
104
+ adapter;
105
+ params;
106
+ sink;
107
+ /**
108
+ * Prepare one backend without acquiring a host. PiAdapter.openSession supplies the request and sink.
109
+ * Unknown config options throw CAPABILITY_UNSUPPORTED and invalid schema values throw
110
+ * INVALID_PARAMS. Await open before dispatching requests and dispose after use or failed open.
111
+ */
112
+ constructor(adapter, params, sink) {
113
+ this.adapter = adapter;
114
+ this.params = params;
115
+ this.sink = sink;
116
+ this.binding = { ...params.binding };
117
+ this.capabilities = adapter.capabilities;
118
+ this.redact = adapter.redact;
119
+ this.config = structuredClone(params.config ?? {});
120
+ this.model = params.model ?? (typeof this.config.model === 'string' ? this.config.model : undefined);
121
+ for (const [key, value] of Object.entries(this.config)) {
122
+ const option = this.capabilities.config?.options.find(entry => entry.id === key);
123
+ if (!option)
124
+ throw new HarnessSdkError('CAPABILITY_UNSUPPORTED', 'Unknown Pi config option');
125
+ if (!validateJsonSchema(option.schema, value))
126
+ throw new HarnessSdkError('INVALID_PARAMS', 'Invalid Pi config value');
127
+ }
128
+ }
129
+ /**
130
+ * Expose direct tool registration when the acquired host supports it. OutputSession supplies the
131
+ * definition and its validation callback before dispatch. It passes undefined on terminal,
132
+ * definite rejection of an unbound submission or release. Host registration failures propagate
133
+ * to the wrapper, which owns candidate validation and publication.
134
+ */
135
+ get structuredOutputTool() {
136
+ return this.host?.setOutputTool ? { configure: tool => this.host.setOutputTool(tool) } : undefined;
137
+ }
138
+ /** Pi session ID after open, or the requested native reference before acquisition. A missing reference returns an empty string. */
139
+ get nativeId() { return this.binding.nativeRef?.value ?? ''; }
140
+ /**
141
+ * PiAdapter.openSession calls once before returning this backend. Acquire and bind the host,
142
+ * retain pre-run entries and subscribe in that order so new user entries can identify live turns.
143
+ * Acquisition failures use sdkError with PI_SESSION_OPEN_FAILED as fallback. The adapter owns
144
+ * disposal if binding or subscription setup fails after acquisition.
145
+ */
146
+ async open() {
147
+ const common = this.hostOptions();
148
+ const factory = this.adapter.hostFactory();
149
+ try {
150
+ this.host = await this.acquireHost({ common, factory });
151
+ }
152
+ catch (cause) {
153
+ throw sdkError(cause, 'PI_SESSION_OPEN_FAILED');
154
+ }
155
+ this.binding = { ...this.binding, nativeRef: { provider: 'pi', value: this.host.sessionId } };
156
+ this.model = this.host.model ?? this.model;
157
+ this.adapter.turnMaps.set(this.nativeId, this.turns);
158
+ this.markKnown();
159
+ this.unsubscribe = this.host.subscribe(event => this.enqueue(event));
160
+ }
161
+ /**
162
+ * Dispatch an SDK-validated request from OutputSession.execute after open. Submission resolves
163
+ * on native admission, interrupt after abort and compact after native compaction. Sink events
164
+ * independently report execution completion. Unsupported requests, closed sessions and native
165
+ * failures reject. Close awaits disposal and reports execution stop as unconfirmed.
166
+ */
167
+ async execute(method, params) {
168
+ if (this.closed)
169
+ throw new HarnessSdkError('SESSION_CLOSED');
170
+ let result;
171
+ switch (method) {
172
+ case 'input/submit':
173
+ return await this.submit(params);
174
+ case 'session/interrupt': {
175
+ try {
176
+ await this.host.abort();
177
+ }
178
+ catch (cause) {
179
+ throw sdkError(cause, 'PI_ABORT_FAILED', true);
180
+ }
181
+ result = { accepted: true };
182
+ break;
183
+ }
184
+ case 'turn/interrupt':
185
+ case 'turn/steer':
186
+ throw new HarnessSdkError('CAPABILITY_UNSUPPORTED', 'Pi controls target the session, not an atomically matched turn');
187
+ case 'session/config/set':
188
+ result = await this.setConfig(params.patch);
189
+ break;
190
+ case 'history/read':
191
+ result = await this.readHistory(params);
192
+ break;
193
+ case 'compact/start': {
194
+ if (!this.capabilities.compact)
195
+ throw new HarnessSdkError('CAPABILITY_UNSUPPORTED');
196
+ try {
197
+ await this.host.compact();
198
+ }
199
+ catch (cause) {
200
+ throw sdkError(cause, 'PI_COMPACT_FAILED', true);
201
+ }
202
+ result = { maintenanceId: randomUUID(), status: 'completed' };
203
+ break;
204
+ }
205
+ case 'session/close':
206
+ await this.dispose();
207
+ result = { closed: true, observationClosed: true, executionStopped: 'unconfirmed' };
208
+ break;
209
+ default:
210
+ throw new HarnessSdkError('CAPABILITY_UNSUPPORTED');
211
+ }
212
+ return result;
213
+ }
214
+ /**
215
+ * Share one cleanup attempt across callers. Stop admission, unsubscribe and discard pending
216
+ * inputs before best-effort abort and awaited host disposal. Host disposal failure rejects the
217
+ * shared promise and leaves adapter tracking intact; cleanup does not await event-tail drainage.
218
+ */
219
+ async dispose() {
220
+ return this.release ??= (async () => {
221
+ this.closed = true;
222
+ this.unsubscribe?.();
223
+ this.unsubscribe = undefined;
224
+ this.pending = [];
225
+ const host = this.host;
226
+ if (host) {
227
+ if (host.busy)
228
+ await host.abort().catch(() => { });
229
+ await host.dispose();
230
+ }
231
+ this.adapter.forget(this);
232
+ })();
233
+ }
234
+ /** Acquired by open; backend disposal owns its release, including partially completed setup. */
235
+ host;
236
+ /** Requested SDK identity, with the native reference replaced after acquisition. */
237
+ binding;
238
+ /** Native capabilities captured for attachment; direct output support is projected by the SDK wrapper. */
239
+ capabilities;
240
+ redact;
241
+ unsubscribe;
242
+ active;
243
+ pending = [];
244
+ closed = false;
245
+ release;
246
+ config;
247
+ model;
248
+ capabilityRevision = 1;
249
+ title;
250
+ tail = Promise.resolve();
251
+ turns = new Map();
252
+ snapshots = new Map();
253
+ compactions = 0;
254
+ /** Entry ids that existed before the current run, so the run's own user entry can be told apart. */
255
+ known = new Set();
256
+ /** Snapshot entry ids at open/turn completion so the next run can associate its first new user entry. */
257
+ markKnown() { this.known = new Set(this.host.entries().map(entry => entry.id)); }
258
+ /** Build host options before native-open error wrapping, preserving validation failures as parameter errors. */
259
+ hostOptions() {
260
+ const { params } = this;
261
+ const tools = permissionTools(this.permissionMode());
262
+ return {
263
+ cwd: params.cwd, persist: this.adapter.persist && this.adapter.profile.resume,
264
+ ...(this.model ? { model: this.model } : {}),
265
+ ...(typeof this.config.thinkingLevel === 'string' ? { thinkingLevel: this.config.thinkingLevel } : {}),
266
+ ...(tools ? { tools } : {}),
267
+ ...(params.instructions ? { instructions: params.instructions } : {}),
268
+ ...(this.adapter.options.agentDir ? { agentDir: this.adapter.options.agentDir } : {}),
269
+ ...(this.adapter.options.sessionDir ? { sessionDir: this.adapter.options.sessionDir } : {}),
270
+ };
271
+ }
272
+ /** Acquire a new, resumed or forked host; open publishes its binding only after acquisition succeeds. */
273
+ async acquireHost({ common, factory }) {
274
+ const { params } = this;
275
+ if (params.mode === 'fork') {
276
+ if (!this.adapter.profile.fork || !common.persist)
277
+ throw new HarnessSdkError('CAPABILITY_UNSUPPORTED', 'Pi fork requires persisted sessions');
278
+ const fork = params.fork;
279
+ if (!fork?.nativeRef?.value)
280
+ throw new HarnessSdkError('SESSION_NOT_FOUND');
281
+ return this.forkHost({ sourceId: fork.nativeRef.value, boundary: fork.boundary, factory, common });
282
+ }
283
+ if (params.mode === 'resume') {
284
+ const id = params.binding.nativeRef?.value;
285
+ if (!id || !this.adapter.profile.resume || !common.persist)
286
+ throw new HarnessSdkError('SESSION_NOT_FOUND');
287
+ const host = await factory({ ...common, mode: 'resume', sessionId: id });
288
+ this.turns = this.adapter.turnMaps.get(id) ?? new Map();
289
+ return host;
290
+ }
291
+ return factory({ ...common, mode: 'new' });
292
+ }
293
+ /** Resolve config before the open request's permission mode; omission keeps native host defaults. */
294
+ permissionMode() {
295
+ const value = this.config.permissionMode ?? this.params.permissionMode;
296
+ assertPermissionMode(value);
297
+ return value === undefined ? undefined : String(value);
298
+ }
299
+ /**
300
+ * Fork from a tracked source or a temporary resumed host. Copy the SDK turn IDs retained by
301
+ * the branch so open can register them for the new session. Always attempt to dispose the
302
+ * temporary source and suppress that cleanup failure. The live source remains borrowed.
303
+ */
304
+ async forkHost({ sourceId, boundary, factory, common }) {
305
+ const live = this.adapter.liveHost(sourceId);
306
+ const source = live ?? await factory({ ...common, mode: 'resume', sessionId: sourceId });
307
+ try {
308
+ const turns = this.adapter.turnMaps.get(sourceId) ?? new Map();
309
+ const entries = source.entries();
310
+ const starts = turnStarts(entries, turns);
311
+ const leaf = forkLeaf({ entries, starts, turns, boundary });
312
+ const forked = await source.fork(leaf);
313
+ this.turns = new Map([...turns].filter(([entryId]) => entries.findIndex(entry => entry.id === entryId) <= entries.findIndex(entry => entry.id === leaf)));
314
+ return forked;
315
+ }
316
+ finally {
317
+ if (!live)
318
+ await source.dispose().catch(() => { });
319
+ }
320
+ }
321
+ /**
322
+ * The listener registered by open appends events here without blocking Pi. Await each handler in
323
+ * arrival order and skip queued work after close. Report mapping or publication failures through
324
+ * OutputSession's forwarding sink, which delegates observation failure to Session.fail. Disposal
325
+ * unsubscribes and leaves any in-flight handler to finish independently.
326
+ */
327
+ enqueue(event) {
328
+ this.tail = this.tail
329
+ .then(() => this.closed ? undefined : this.handle(event))
330
+ .catch(cause => this.sink.fail(sdkError(cause, 'PI_EVENT_FAILED', true)).catch(() => { }));
331
+ }
332
+ /** Return detached binding and config for config receipts and post-terminal state. Capabilities remain shared. */
333
+ snapshot() {
334
+ return {
335
+ ...(this.title !== undefined ? { metadata: { title: this.title } } : {}),
336
+ binding: structuredClone(this.binding), status: this.active ? 'running' : 'ready', config: structuredClone(this.config),
337
+ capabilities: this.capabilities, capabilityRevision: this.capabilityRevision, ...(this.model ? { model: this.model } : {}),
338
+ activeTurnIds: this.active ? [this.active.id] : [],
339
+ };
340
+ }
341
+ /** Publish a new turn before binding pending inputs; retries/continuations reuse the active turn until settlement. */
342
+ async beginTurn() {
343
+ if (this.active)
344
+ return this.active;
345
+ this.active = { id: randomUUID(), assistant: 0, items: new Map(), messages: [], firstUserSeen: false };
346
+ await this.sink.emit({ type: 'turn/started', payload: { turnId: this.active.id } });
347
+ for (const submissionId of this.pending.splice(0))
348
+ await this.sink.bindSubmission(submissionId, this.active.id);
349
+ return this.active;
350
+ }
351
+ /**
352
+ * Route serialized host callbacks to the sink or turn state. Wait for settlement across native
353
+ * retries and continuations. A run error can end the turn early, and source loss fails observation.
354
+ */
355
+ async handle(event) {
356
+ switch (event.type) {
357
+ case 'agent_start':
358
+ await this.beginTurn();
359
+ return;
360
+ case 'agent_end': return;
361
+ case 'message_start': {
362
+ if (event.message.role === 'assistant')
363
+ (await this.beginTurn()).assistant++;
364
+ return;
365
+ }
366
+ case 'message_update': return this.messageUpdate(await this.beginTurn(), event);
367
+ case 'message_end': return this.messageEnd(await this.beginTurn(), event.message);
368
+ case 'tool_start': return this.startTool(event);
369
+ case 'tool_update': return this.updateTool(event);
370
+ case 'tool_end': return this.endTool(event);
371
+ case 'compaction_start': return this.startCompaction(event);
372
+ case 'compaction_end': return this.endCompaction(event);
373
+ case 'retry':
374
+ await this.sink.emit(retryNotice(event), this.active ? { turnId: this.active.id } : {});
375
+ return;
376
+ case 'session_name':
377
+ this.title = event.name ?? null;
378
+ await this.sink.emit({ type: 'session/metadata', payload: { title: this.title } });
379
+ return;
380
+ case 'failed':
381
+ await this.sink.fail(new HarnessSdkError(event.code, 'Pi source failed', false, true));
382
+ return;
383
+ case 'error': {
384
+ if (!this.active) {
385
+ await this.sink.fail(new HarnessSdkError('PI_RUN_FAILED', 'Pi run failed', false, true));
386
+ return;
387
+ }
388
+ this.active.error = { code: 'PI_RUN_FAILED', message: 'Pi run failed', retryable: false, outcomeUnknown: false };
389
+ await this.finishTurn({ aborted: false });
390
+ return;
391
+ }
392
+ case 'settled': return this.settle(event);
393
+ }
394
+ }
395
+ /** Start tool items only at execution, when Pi has finalized the name and arguments. */
396
+ async startTool(event) {
397
+ const active = await this.beginTurn();
398
+ const item = toolItem(this.redact, event.id, event.name, event.args, 'running');
399
+ active.items.set(item.id, item);
400
+ await this.sink.emit({ type: 'item/started', payload: { item } }, { turnId: active.id });
401
+ }
402
+ /** Ignore updates without a running turn's matching tool item; publish redacted output deltas otherwise. */
403
+ async updateTool(event) {
404
+ const active = this.active, previous = active?.items.get(event.id);
405
+ if (!active || previous?.type !== 'toolCall')
406
+ return;
407
+ const text = this.redact.text(typeof event.partial === 'string' ? event.partial : contentText(event.partial?.content ?? ''));
408
+ await this.sink.emit({ type: 'item/delta', payload: { itemId: event.id, kind: 'output', text } }, { turnId: active.id });
409
+ }
410
+ /** Complete only tool items already observed in the active turn. */
411
+ async endTool(event) {
412
+ const active = this.active, previous = active?.items.get(event.id);
413
+ if (!active || previous?.type !== 'toolCall')
414
+ return;
415
+ const item = completeTool(this.redact, previous, event.result, event.isError);
416
+ active.items.set(item.id, item);
417
+ await this.sink.emit({ type: 'item/completed', payload: { item } }, { turnId: active.id });
418
+ }
419
+ /** Outside a turn, report compaction as a notice because item events require a turn scope. */
420
+ async startCompaction(event) {
421
+ const active = this.active;
422
+ if (!active) {
423
+ await this.sink.emit({
424
+ type: 'notice', payload: { severity: 'info', code: 'PI_COMPACTION_STARTED', message: `Pi compaction started (${event.reason})` },
425
+ });
426
+ return;
427
+ }
428
+ const id = `${active.id}:compaction:${++this.compactions}`;
429
+ await this.sink.emit({ type: 'item/started', payload: { item: { id, type: 'compaction', status: 'running', trigger: event.reason } } }, { turnId: active.id });
430
+ }
431
+ /** Match the latest compaction item inside a turn, or publish an unscoped completion/failure notice. */
432
+ async endCompaction(event) {
433
+ const active = this.active;
434
+ const failed = event.aborted || !!event.errorMessage;
435
+ if (!active) {
436
+ await this.sink.emit({
437
+ type: 'notice', payload: {
438
+ severity: failed ? 'warning' : 'info',
439
+ code: failed ? 'PI_COMPACTION_FAILED' : 'PI_COMPACTION_COMPLETED',
440
+ message: `Pi compaction ${failed ? 'did not complete' : 'completed'} (${event.reason})`,
441
+ },
442
+ });
443
+ return;
444
+ }
445
+ const item = {
446
+ id: `${active.id}:compaction:${this.compactions}`, type: 'compaction',
447
+ status: failed ? 'failed' : 'completed', trigger: event.reason,
448
+ ...(event.summary ? { summary: this.redact.text(event.summary) } : {}),
449
+ };
450
+ await this.sink.emit({ type: 'item/completed', payload: { item } }, { turnId: active.id });
451
+ }
452
+ /** Native settlement completes the active turn, or rejects inputs that never acquired an execution. */
453
+ async settle(event) {
454
+ if (this.active) {
455
+ await this.finishTurn(event);
456
+ return;
457
+ }
458
+ for (const submissionId of this.pending.splice(0)) {
459
+ await this.sink.emit({ type: 'submission/status', payload: { submissionId, status: 'rejected', error: { code: 'NATIVE_INPUT_NOT_EXECUTED', message: 'Input ended without an execution', retryable: false, outcomeUnknown: false } } });
460
+ }
461
+ }
462
+ /** Address the same assistant content block in both streaming updates and the final message. */
463
+ itemId(active, index) { return `${active.id}:a${active.assistant}:${index}`; }
464
+ /**
465
+ * Accumulate streamed text and reasoning using stable item IDs. Text completion waits for
466
+ * message_end to select its phase. Tool items start at execution, when arguments are final.
467
+ */
468
+ async messageUpdate(active, event) {
469
+ const { update } = event;
470
+ if (update.kind === 'toolcall')
471
+ return;
472
+ const id = this.itemId(active, update.index);
473
+ const part = Array.isArray(event.message.content) ? event.message.content[update.index] : undefined;
474
+ if (update.phase === 'start') {
475
+ const item = update.kind === 'text' ? { id, type: 'agentMessage', text: '', phase: 'commentary', status: 'running' } : { id, type: 'reasoning', text: '', status: 'running' };
476
+ active.items.set(id, item);
477
+ await this.sink.emit({ type: 'item/started', payload: { item } }, { turnId: active.id });
478
+ }
479
+ else if (update.phase === 'delta') {
480
+ const previous = active.items.get(id);
481
+ if (!previous || (previous.type !== 'agentMessage' && previous.type !== 'reasoning'))
482
+ return;
483
+ const text = this.redact.text(update.delta ?? '');
484
+ previous.text = (previous.text ?? '') + text;
485
+ await this.sink.emit({ type: 'item/delta', payload: { itemId: id, kind: previous.type === 'reasoning' ? 'reasoningSummary' : 'text', text } }, { turnId: active.id });
486
+ }
487
+ else if (update.kind === 'thinking') {
488
+ const previous = active.items.get(id);
489
+ const text = part?.type === 'thinking' ? part.thinking : previous?.type === 'reasoning' ? previous.text ?? '' : '';
490
+ const item = { id, type: 'reasoning', status: 'completed', text: this.redact.text(text) };
491
+ active.items.set(id, item);
492
+ await this.sink.emit({ type: 'item/completed', payload: { item } }, { turnId: active.id });
493
+ }
494
+ }
495
+ /**
496
+ * Associate the first new user entry with this turn. For assistant messages, retain usage and
497
+ * errors and publish completed text blocks with their final or commentary phase. Successful
498
+ * assistant continuations clear earlier attempt failures.
499
+ */
500
+ async messageEnd(active, message) {
501
+ if (message.role === 'user') {
502
+ this.recordTurnStart(active);
503
+ return;
504
+ }
505
+ if (message.role !== 'assistant')
506
+ return;
507
+ active.messages.push(message);
508
+ active.lastAssistant = message;
509
+ const error = piTurnError(message);
510
+ if (error)
511
+ active.error = error;
512
+ else if (message.stopReason === 'stop' || message.stopReason === 'toolUse')
513
+ active.error = undefined;
514
+ const content = Array.isArray(message.content) ? message.content : [];
515
+ const hasTools = content.some(part => part.type === 'toolCall');
516
+ for (let index = 0; index < content.length; index++) {
517
+ const part = content[index];
518
+ if (part.type !== 'text')
519
+ continue;
520
+ const id = this.itemId(active, index);
521
+ const item = {
522
+ id, type: 'agentMessage', status: 'completed', text: this.redact.text(part.text),
523
+ phase: !hasTools && message.stopReason === 'stop' ? 'final' : 'commentary',
524
+ };
525
+ if (!active.items.has(id))
526
+ await this.sink.emit({ type: 'item/started', payload: { item: { ...item, status: 'running', text: '' } } }, { turnId: active.id });
527
+ active.items.set(id, item);
528
+ await this.sink.emit({ type: 'item/completed', payload: { item } }, { turnId: active.id });
529
+ }
530
+ }
531
+ /**
532
+ * On the first user message end, record the new Pi entry so history and fork can resolve the
533
+ * SDK turn ID. Further user messages may belong to queued continuations of the same turn.
534
+ */
535
+ recordTurnStart(active) {
536
+ if (active.firstUserSeen)
537
+ return;
538
+ active.firstUserSeen = true;
539
+ const entry = this.host.entries().find(candidate => candidate.kind === 'message' && candidate.message?.role === 'user' && !this.known.has(candidate.id));
540
+ if (entry)
541
+ this.turns.set(entry.id, active.id);
542
+ }
543
+ /**
544
+ * Complete unfinished tool items, then publish usage, the terminal and ready state in order.
545
+ * Native interruption takes precedence over a recorded failure. Clear active state before terminal
546
+ * publication so the emitted snapshot is ready; publication failures flow to enqueue's sink failure.
547
+ */
548
+ async finishTurn({ aborted }) {
549
+ const active = this.active;
550
+ if (!active)
551
+ return;
552
+ const interrupted = aborted || active.lastAssistant?.stopReason === 'aborted';
553
+ const error = interrupted ? undefined : active.error;
554
+ for (const item of active.items.values()) {
555
+ if (item.type === 'toolCall' && item.status === 'running') {
556
+ const stopped = { ...item, status: interrupted ? 'interrupted' : 'failed' };
557
+ await this.sink.emit({ type: 'item/completed', payload: { item: stopped } }, { turnId: active.id });
558
+ }
559
+ }
560
+ const usage = usageOf(active.messages);
561
+ await this.sink.emit({ type: 'usage/updated', payload: usage }, { turnId: active.id });
562
+ this.active = undefined;
563
+ this.markKnown();
564
+ const status = interrupted ? 'interrupted' : error ? 'failed' : 'completed';
565
+ await this.sink.emit({ type: 'turn/completed', payload: { turnId: active.id, status, usage, ...(error ? { error } : {}) } });
566
+ await this.sink.emit({ type: 'session/state', payload: this.snapshot() });
567
+ }
568
+ /**
569
+ * Convert input before native admission. Reserve idle input before prompt can emit events. Other
570
+ * inputs use followUp or steer, then join an active turn if present or wait for a binding event.
571
+ * Extension handling returns acceptance without a turn. Native failures use PI_SUBMIT_FAILED
572
+ * as a fallback code. OutputSession prepares schema-bearing input before dispatch.
573
+ */
574
+ async submit(start) {
575
+ if (start.outputSchema !== undefined)
576
+ throw new HarnessSdkError('CAPABILITY_UNSUPPORTED', 'Pi does not support structured output schemas');
577
+ const { text, images } = nativeInput(start.input);
578
+ const imageArg = images.length ? images : undefined;
579
+ const { submissionId } = start;
580
+ try {
581
+ const idle = !this.host.busy && !this.active && !this.pending.length;
582
+ if (idle)
583
+ this.pending.push(submissionId);
584
+ // A rejection leaves the default in place so it releases the reservation just like extension handling.
585
+ let disposition = 'handled';
586
+ try {
587
+ if (idle) {
588
+ disposition = await this.host.prompt(text, imageArg);
589
+ }
590
+ else if (start.delivery === 'afterTurn') {
591
+ disposition = await this.host.followUp(text, imageArg);
592
+ }
593
+ else {
594
+ disposition = await this.host.steer(text, imageArg);
595
+ }
596
+ }
597
+ finally {
598
+ if (idle && disposition === 'handled') {
599
+ this.pending = this.pending.filter(entry => entry !== submissionId);
600
+ }
601
+ }
602
+ if (disposition === 'handled')
603
+ return { submissionId, accepted: true, disposition: 'accepted' };
604
+ // The event tail may already have consumed an idle prompt's reservation and published its binding.
605
+ if (!idle) {
606
+ if (this.active)
607
+ return { submissionId, accepted: true, disposition: 'joined', turnId: this.active.id };
608
+ this.pending.push(submissionId);
609
+ }
610
+ return { submissionId, disposition: 'dispatched' };
611
+ }
612
+ catch (cause) {
613
+ throw sdkError(cause, 'PI_SUBMIT_FAILED');
614
+ }
615
+ }
616
+ /**
617
+ * Check the patch before applying model, thinking and tools in order, then publish and return
618
+ * the new state. PI_CONFIG_FAILED is the fallback for host mutations with an unknown outcome.
619
+ * Earlier mutations can survive later failures. The config and revision commit after all host
620
+ * mutations succeed, before publication.
621
+ */
622
+ async setConfig(patch) {
623
+ for (const key of Object.keys(patch)) {
624
+ if (!this.capabilities.config?.options.some(option => option.id === key)) {
625
+ throw new HarnessSdkError('CAPABILITY_UNSUPPORTED', 'Unknown Pi config option');
626
+ }
627
+ }
628
+ const models = patch.model !== undefined || patch.thinkingLevel !== undefined ? await this.host.listModels() : [];
629
+ const { model, thinkingLevel } = this.validateModelConfig(patch, models);
630
+ assertPermissionMode(patch.permissionMode);
631
+ const tools = permissionTools(patch.permissionMode);
632
+ try {
633
+ if (model !== undefined) {
634
+ await this.host.setModel(model);
635
+ this.model = model;
636
+ }
637
+ if (thinkingLevel !== undefined)
638
+ this.host.setThinkingLevel(thinkingLevel);
639
+ if (tools)
640
+ this.host.setActiveTools(tools);
641
+ }
642
+ catch (cause) {
643
+ throw sdkError(cause, 'PI_CONFIG_FAILED', true);
644
+ }
645
+ this.config = { ...this.config, ...patch };
646
+ this.capabilityRevision++;
647
+ const state = this.snapshot();
648
+ await this.sink.emit({ type: 'session/state', payload: state });
649
+ return state;
650
+ }
651
+ /**
652
+ * Return the validated model and thinking level for setConfig to apply, or throw INVALID_PARAMS.
653
+ * A requested model governs the allowed thinking levels, with the host's active levels as fallback.
654
+ */
655
+ validateModelConfig(patch, models) {
656
+ const { model, thinkingLevel } = patch;
657
+ if (model !== undefined && (typeof model !== 'string' || !models.some(candidate => candidate.ref === model))) {
658
+ throw new HarnessSdkError('INVALID_PARAMS', 'Model is not available in Pi');
659
+ }
660
+ if (thinkingLevel !== undefined) {
661
+ const target = models.find(candidate => candidate.ref === (model ?? this.model ?? this.host.model));
662
+ const levels = target?.thinkingLevels ?? this.host.availableThinkingLevels();
663
+ if (typeof thinkingLevel !== 'string' || !levels.includes(thinkingLevel)) {
664
+ throw new HarnessSdkError('INVALID_PARAMS', 'Thinking level is not available for this model');
665
+ }
666
+ }
667
+ return { model, thinkingLevel };
668
+ }
669
+ /**
670
+ * Return detached item pages from redacted active-branch history. Reuse snapshotToken with
671
+ * nextCursor to keep paging over the same snapshot. New reads retain the configured history
672
+ * suffix, keep up to eight snapshots and evict the oldest first. Pages default to 200 items,
673
+ * capped at 1000. Invalid offsets reject CURSOR_INVALID and unavailable tokens reject CURSOR_EXPIRED.
674
+ */
675
+ async readHistory(request) {
676
+ let token = request.snapshotToken;
677
+ if (!token) {
678
+ const limit = this.adapter.options.maxHistoryItems ?? 5000;
679
+ const items = historyItems(this.redact, this.host.entries(), this.turns).slice(-limit);
680
+ token = randomUUID();
681
+ this.snapshots.set(token, items);
682
+ if (this.snapshots.size > 8) {
683
+ const oldest = this.snapshots.keys().next();
684
+ if (!oldest.done)
685
+ this.snapshots.delete(oldest.value);
686
+ }
687
+ }
688
+ const snapshot = this.snapshots.get(token);
689
+ if (!snapshot)
690
+ throw new HarnessSdkError('CURSOR_EXPIRED');
691
+ const offset = request.cursor === undefined ? 0 : Number(request.cursor);
692
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > snapshot.length)
693
+ throw new HarnessSdkError('CURSOR_INVALID');
694
+ const page = snapshot.slice(offset, offset + Math.min(request.limit ?? 200, 1000));
695
+ return { items: structuredClone(page), ...(offset + page.length < snapshot.length ? { nextCursor: String(offset + page.length) } : {}), snapshotToken: token, source: this.binding.nativeRef };
696
+ }
697
+ }
698
+ /** Backend opening and config updates share this check so unsupported modes always fail with INVALID_PARAMS. */
699
+ function assertPermissionMode(value) {
700
+ if (value === undefined)
701
+ return;
702
+ if (!PERMISSION_MODES.includes(String(value)))
703
+ throw new HarnessSdkError('INVALID_PARAMS', 'Unsupported Pi permission mode');
704
+ }
705
+ /**
706
+ * Verify a requested turn against the source branch before forkHost forks it. An explicit native
707
+ * turn ID takes precedence over lookup by SDK turn ID. Return the last entry to retain, or null
708
+ * for an empty branch. An unverified boundary throws TURN_NOT_FOUND.
709
+ */
710
+ function forkLeaf({ entries, starts, turns, boundary }) {
711
+ const startId = boundary.nativeTurnId ?? [...turns].find(([, turn]) => turn === boundary.turnId)?.[0];
712
+ if (!startId || !starts.has(startId))
713
+ throw new HarnessSdkError('TURN_NOT_FOUND', 'No verified native turn boundary');
714
+ const index = entries.findIndex(entry => entry.id === startId);
715
+ if (boundary.relation === 'before')
716
+ return entries[index].parentId;
717
+ const next = entries.findIndex((entry, position) => position > index && starts.has(entry.id));
718
+ return entries[(next >= 0 ? next : entries.length) - 1].id;
719
+ }
720
+ /** Map a Pi retry to a notice without settling the turn. Failed and starting attempts warn. */
721
+ function retryNotice(event) {
722
+ const starting = event.phase === 'start';
723
+ return {
724
+ type: 'notice', payload: {
725
+ severity: starting || event.success === false ? 'warning' : 'info',
726
+ code: starting ? 'PI_AUTO_RETRY' : 'PI_AUTO_RETRY_END',
727
+ message: `Pi retry attempt ${event.attempt}`,
728
+ },
729
+ };
730
+ }