@pontia/pi-client-plugin 0.2.0 → 0.2.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/src/index.ts CHANGED
@@ -1,19 +1,61 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { defaultHookLogFile, loadTurnContext, type EnvLike, type LoadTurnContextResult, type TurnContext } from "./context.js";
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { connectPi, RpcError, type PiConnection, type ControlInput } from "./control-socket.js";
4
+ import { defaultHookLogFile, type EnvLike, type TurnContext } from "./context.js";
3
5
  import { appendDiagnostic, type DiagnosticEntry } from "./diagnostics.js";
4
- import { pontiaHomeFromEnv, resolvePontiaConnection } from "./discovery.js";
5
- import { buildSessionContextUsageUpdatedEvent, buildSessionExitedEvent, buildSessionMessageUpdatedEvent, buildSessionReadyEvent, buildTurnCompletedEvent, buildTurnFailedEvent, buildTurnInterruptedEvent, buildTurnOutputEvent, buildTurnStartedEvent, contextUsageFromPiHook, type InternalEvent, type PiTopologyContext, type PiTopologyEntryKind, type SessionMessageUpdatedReason } from "./events.js";
6
- import { asRecord, optionalString, parseJsonResponse } from "./internal-api.js";
7
- import { hasTmuxPaneEnvironment, isPontiaManagedTmuxPane, loadPontiaManagedRuntimeIdentity, type ManagedRuntimeIdentity } from "./managed-runtime.js";
8
- import { agentEndWasInterrupted, assistantDeltaFromEvent, assistantTextFromMessage, errorMessageFromAgentEnd, isTranscriptBoundaryMessageUpdate, lastAssistantTextFromMessages } from "./pi-message.js";
6
+ import { pontiaHomeFromEnv } from "./discovery.js";
7
+ import {
8
+ buildSessionContextUsageUpdatedEvent,
9
+ buildSessionExitedEvent,
10
+ buildSessionMessageUpdatedEvent,
11
+ buildSessionReadyEvent,
12
+ buildTurnCompletedEvent,
13
+ buildTurnFailedEvent,
14
+ buildTurnInterruptedEvent,
15
+ buildTurnOutputEvent,
16
+ buildTurnStartedEvent,
17
+ contextUsageFromPiHook,
18
+ type InternalEvent,
19
+ type PiTopologyContext,
20
+ type PiTopologyEntryKind,
21
+ type SessionMessageUpdatedReason,
22
+ } from "./events.js";
23
+ import { asRecord, optionalString } from "./values.js";
24
+ import {
25
+ completeToolCallFromMessageUpdate,
26
+ LiveOutputPublisher,
27
+ type LiveOutputPublisherLike,
28
+ } from "./live-output.js";
29
+ import {
30
+ hasTmuxPaneEnvironment,
31
+ isPontiaManagedTmuxPane,
32
+ loadPontiaManagedRuntimeIdentity,
33
+ type ManagedRuntimeIdentity,
34
+ } from "./managed-runtime.js";
35
+ import {
36
+ agentEndWasInterrupted,
37
+ assistantDeltaFromEvent,
38
+ assistantTextFromMessage,
39
+ errorMessageFromAgentEnd,
40
+ isTranscriptBoundaryMessageUpdate,
41
+ lastAssistantTextFromMessages,
42
+ } from "./pi-message.js";
9
43
  import { loadProfileSystemPrompt } from "./profile.js";
10
44
  import { EventReporter, type EventReportResult } from "./reporter.js";
11
- import { bindSession, loadExistingSessionContext, piSessionDetailsFromHookContext, type PiSessionDetails } from "./runtime-binding.js";
45
+ import {
46
+ bindSession,
47
+ loadExistingSessionContext,
48
+ piSessionDetailsFromHookContext,
49
+ type PiSessionDetails,
50
+ } from "./runtime-binding.js";
12
51
  import type { SessionContext } from "./session.js";
13
52
  import { isActiveRegisteredWorkspace } from "./workspace.js";
14
53
 
15
54
  interface ReporterLike {
16
- report(context: { internalEventUrl: string }, event: InternalEvent): Promise<EventReportResult | boolean>;
55
+ report(
56
+ context: { runtimeInstanceId: string },
57
+ event: InternalEvent,
58
+ ): Promise<EventReportResult | boolean>;
17
59
  }
18
60
 
19
61
  function reportAccepted(result: EventReportResult | boolean): boolean {
@@ -28,18 +70,19 @@ function isPersistentTuiContext(ctx: unknown): boolean {
28
70
 
29
71
  export interface PontiaPiExtensionDependencies {
30
72
  env?: EnvLike;
31
- loadContext?: (env: EnvLike, sessionContext?: SessionContext) => Promise<LoadTurnContextResult>;
32
73
  makeReporter?: (logFile: string) => ReporterLike;
33
74
  logDiagnostic?: (logFile: string, entry: DiagnosticEntry) => Promise<void>;
34
- fetch?: typeof fetch;
35
75
  loadManagedRuntime?: (env: EnvLike) => Promise<ManagedRuntimeIdentity | undefined>;
36
76
  isManagedPane?: (env: EnvLike) => Promise<boolean>;
77
+ makeLiveOutputPublisher?: (context: TurnContext & { turnId: string }) => LiveOutputPublisherLike;
78
+ connectPi?: typeof connectPi;
37
79
  }
38
80
 
39
81
  interface ActiveTurnState {
40
82
  context: TurnContext & { turnId: string };
41
83
  logFile: string;
42
84
  reporter: ReporterLike;
85
+ liveOutput: LiveOutputPublisherLike;
43
86
  output: string;
44
87
  ended: boolean;
45
88
  }
@@ -68,13 +111,12 @@ const NON_MESSAGE_ENTRY_KINDS = new Set([
68
111
  function topologyEntryKind(entry: Record<string, unknown>): PiTopologyEntryKind {
69
112
  if (entry.type !== "message") {
70
113
  return typeof entry.type === "string" && NON_MESSAGE_ENTRY_KINDS.has(entry.type)
71
- ? entry.type as PiTopologyEntryKind
114
+ ? (entry.type as PiTopologyEntryKind)
72
115
  : "other";
73
116
  }
74
117
  const message = entry.message;
75
- const role = message && typeof message === "object"
76
- ? (message as Record<string, unknown>).role
77
- : undefined;
118
+ const role =
119
+ message && typeof message === "object" ? (message as Record<string, unknown>).role : undefined;
78
120
  if (role === "user") return "user_message";
79
121
  if (role === "assistant") return "assistant_message";
80
122
  if (role === "toolResult") return "tool_result_message";
@@ -103,7 +145,10 @@ function topologyContextFromHookContext(ctx: unknown): PiTopologyContext | undef
103
145
  }
104
146
  }
105
147
 
106
- export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPiExtensionDependencies = {}): void {
148
+ export function createPontiaPiExtension(
149
+ pi: ExtensionAPI,
150
+ dependencies: PontiaPiExtensionDependencies = {},
151
+ ): void {
107
152
  const sourceEnv = dependencies.env ?? process.env;
108
153
  if (!pontiaHomeFromEnv(sourceEnv) || !hasTmuxPaneEnvironment(sourceEnv)) return;
109
154
  // Environment extensions may load PONTIA_HOME after extension registration.
@@ -111,12 +156,26 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
111
156
  const currentEnv = () => ({ ...sourceEnv, PONTIA_HOME: currentPontiaHome() });
112
157
  const currentHookLogFile = () => defaultHookLogFile(currentPontiaHome());
113
158
 
114
- const contextLoader = dependencies.loadContext ?? ((contextEnv, sessionContext) => loadTurnContext(contextEnv, { sessionContext }));
115
- const makeReporter = dependencies.makeReporter ?? ((logFile: string) => new EventReporter({ logFile }));
159
+ const rpcConnection: Pick<PiConnection, "request"> = {
160
+ request(method, params) {
161
+ if (!controlSocket) return Promise.reject(new Error("Pi connection is unavailable"));
162
+ return controlSocket.request(method, params);
163
+ },
164
+ };
165
+ const makeReporter =
166
+ dependencies.makeReporter ??
167
+ ((logFile: string) =>
168
+ new EventReporter({
169
+ logFile,
170
+ connection: rpcConnection,
171
+ }));
116
172
  const logDiagnostic = dependencies.logDiagnostic ?? appendDiagnostic;
117
- const fetchImpl = dependencies.fetch ?? fetch;
118
173
  const loadManagedRuntime = dependencies.loadManagedRuntime ?? loadPontiaManagedRuntimeIdentity;
119
174
  const isManagedPane = dependencies.isManagedPane ?? isPontiaManagedTmuxPane;
175
+ const makeLiveOutputPublisher =
176
+ dependencies.makeLiveOutputPublisher ??
177
+ ((context: TurnContext & { turnId: string }) =>
178
+ new LiveOutputPublisher(context, { connection: rpcConnection }));
120
179
 
121
180
  let activeTurn: ActiveTurnState | undefined;
122
181
  let readyReported = false;
@@ -126,6 +185,169 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
126
185
  let managedPaneConfirmed = false;
127
186
  let lastContextUsageJson: string | undefined;
128
187
  let pendingPrompt: string | undefined;
188
+ let controlSocket: PiConnection | undefined;
189
+ let controlGeneration = 0;
190
+ let piContext: ExtensionContext | undefined;
191
+ let changingModel = false;
192
+ const directInput = new AsyncLocalStorage<{
193
+ submission: ControlInput;
194
+ sessionContext: SessionContext;
195
+ consumed: boolean;
196
+ }>();
197
+ let queuedControlInput: ControlInput | undefined;
198
+
199
+ const modelId = (model: { provider: string; id: string }) => `${model.provider}/${model.id}`;
200
+
201
+ async function reportModel(): Promise<void> {
202
+ const context = boundSessionContext;
203
+ const model = piContext?.model;
204
+ if (!context || !model || !readyReported || reportingDisabled) return;
205
+ const result = await makeReporter(currentHookLogFile()).report(context, {
206
+ session_id: context.sessionId,
207
+ type: "session.model_updated",
208
+ data: { model: modelId(model), runtime_instance_id: context.runtimeInstanceId },
209
+ });
210
+ if (!reportAccepted(result)) throw new Error("Pi model observation was not accepted");
211
+ }
212
+
213
+ function sendControlInput(submission: ControlInput): void {
214
+ if (!boundSessionContext) throw new Error("Pi session is not bound");
215
+ try {
216
+ directInput.run({ submission, sessionContext: boundSessionContext, consumed: false }, () =>
217
+ pi.sendUserMessage(submission.input),
218
+ );
219
+ } catch (error) {
220
+ throw new RpcError(
221
+ -32007,
222
+ `Pi input delivery uncertain: ${error instanceof Error ? error.message : String(error)}`,
223
+ );
224
+ }
225
+ }
226
+
227
+ async function controlError(error: unknown): Promise<void> {
228
+ await logDiagnostic(currentHookLogFile(), {
229
+ level: "warn",
230
+ code: "pi_control_unavailable",
231
+ message: "Pi control channel failed",
232
+ details: error instanceof Error ? error.message : String(error),
233
+ });
234
+ }
235
+
236
+ async function closeControlSocket(): Promise<void> {
237
+ controlGeneration += 1;
238
+ queuedControlInput = undefined;
239
+ const socket = controlSocket;
240
+ controlSocket = undefined;
241
+ if (socket) await socket.close().catch(controlError);
242
+ }
243
+
244
+ async function registrationConnection(): Promise<PiConnection> {
245
+ if (controlSocket) return controlSocket;
246
+ const generation = controlGeneration;
247
+ const lifecycleContext = () => {
248
+ if (
249
+ generation !== controlGeneration ||
250
+ reportingDisabled ||
251
+ !boundSessionContext ||
252
+ !piContext ||
253
+ !readyReported
254
+ ) {
255
+ throw new Error("Pi lifecycle control session is no longer current");
256
+ }
257
+ return piContext;
258
+ };
259
+ const socket = await (dependencies.connectPi ?? connectPi)(
260
+ currentPontiaHome(),
261
+ (error) => {
262
+ void controlError(error).catch(() => {});
263
+ },
264
+ (submission) => {
265
+ if (generation !== controlGeneration || reportingDisabled || !boundSessionContext) {
266
+ throw new Error("Pi control session is no longer current");
267
+ }
268
+ if (queuedControlInput)
269
+ throw new RpcError(-32010, "Pi already has pending input; input was not submitted");
270
+ if (!piContext?.isIdle()) {
271
+ if (!activeTurn?.ended) throw new RpcError(-32010, "Pi is busy; input was not submitted");
272
+ queuedControlInput = submission;
273
+ return;
274
+ }
275
+ sendControlInput(submission);
276
+ },
277
+ {
278
+ listModels() {
279
+ if (
280
+ generation !== controlGeneration ||
281
+ reportingDisabled ||
282
+ !boundSessionContext ||
283
+ !piContext ||
284
+ !readyReported
285
+ ) {
286
+ throw new Error("Pi model control session is no longer current");
287
+ }
288
+ return piContext.modelRegistry.getAvailable().map((model) => ({
289
+ id: modelId(model),
290
+ name: model.name,
291
+ description: model.provider,
292
+ }));
293
+ },
294
+ async setModel(id) {
295
+ this.listModels();
296
+ if (changingModel) throw new Error("Pi already has a model change in progress");
297
+ const model = piContext!.modelRegistry
298
+ .getAvailable()
299
+ .find((model) => modelId(model) === id);
300
+ if (!model) throw new Error("The selected model is not available");
301
+ changingModel = true;
302
+ try {
303
+ if (!(await pi.setModel(model)))
304
+ throw new RpcError(-32006, "Pi could not authenticate the selected model");
305
+ this.listModels();
306
+ // Selecting the current model does not emit model_select; observe it again.
307
+ await reportModel();
308
+ } catch (error) {
309
+ if (error instanceof RpcError) throw error;
310
+ throw new RpcError(
311
+ -32007,
312
+ `Pi model change outcome is unknown: ${error instanceof Error ? error.message : String(error)}`,
313
+ );
314
+ } finally {
315
+ changingModel = false;
316
+ }
317
+ },
318
+ async onReconnect() {
319
+ if (generation !== controlGeneration)
320
+ throw new Error("Pi session changed during reconnect");
321
+ await reportModel();
322
+ },
323
+ },
324
+ (inboxMessageId) => {
325
+ if (generation !== controlGeneration || reportingDisabled || !boundSessionContext) {
326
+ throw new Error("Pi control session is no longer current");
327
+ }
328
+ // Command dispatch supplies the native context required by navigateTree.
329
+ pi.sendUserMessage(`/pontia-edit ${inboxMessageId}`, { expandPromptTemplates: true });
330
+ },
331
+ {
332
+ interrupt() {
333
+ lifecycleContext().abort();
334
+ },
335
+ shutdown() {
336
+ const ctx = lifecycleContext();
337
+ // Ordinary extension context: abort first, then exit after Pi settles.
338
+ // Unlike double Ctrl+C, this allows turn cleanup before session_shutdown.
339
+ ctx.abort();
340
+ ctx.shutdown();
341
+ },
342
+ },
343
+ );
344
+ if (generation !== controlGeneration) {
345
+ await socket.close();
346
+ throw new Error("Pi session changed during connection initialization");
347
+ }
348
+ controlSocket = socket;
349
+ return socket;
350
+ }
129
351
 
130
352
  async function confirmManagedPane(refresh = false): Promise<boolean> {
131
353
  if (managedPaneConfirmed && !refresh) return true;
@@ -137,13 +359,10 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
137
359
  if (boundSessionContext) return boundSessionContext;
138
360
  const runtimeIdentity = await loadManagedRuntime(currentEnv());
139
361
  if (!runtimeIdentity) return undefined;
140
- const connection = await resolvePontiaConnection({ pontiaHome: currentPontiaHome(), fetch: fetchImpl });
141
- if (!connection?.internalEventUrl) return undefined;
142
362
  return {
143
363
  sessionId: runtimeIdentity.sessionId,
144
364
  runtimeInstanceId: runtimeIdentity.runtimeInstanceId,
145
365
  clientType: "pi",
146
- internalEventUrl: connection.internalEventUrl,
147
366
  };
148
367
  }
149
368
 
@@ -172,41 +391,18 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
172
391
  return;
173
392
  }
174
393
  const loaded = { logFile };
175
- const connection = await resolvePontiaConnection({ pontiaHome: currentPontiaHome() });
176
- if (!connection?.externalApiToken) {
177
- await logDiagnostic(loaded.logFile, {
178
- level: "error",
179
- code: "branch_replay_stale_context",
180
- message: "pontia-edit requires an authenticated Pontia connection",
181
- });
182
- return;
183
- }
184
- const resolveUrl = commandContext.internalEventUrl.replace(
185
- /\/events\/?$/,
186
- "/inbox/branch-replay/resolve",
187
- );
188
394
 
189
395
  let replacementInput: string;
190
396
  let targetEntryId: string;
191
397
  try {
192
- const response = await fetchImpl(resolveUrl, {
193
- method: "POST",
194
- headers: {
195
- Authorization: `Bearer ${connection.externalApiToken}`,
196
- "Content-Type": "application/json",
197
- },
198
- body: JSON.stringify({
199
- inbox_message_id: inboxMessageId,
200
- session_id: commandContext.sessionId,
201
- runtime_instance_id: commandContext.runtimeInstanceId,
202
- client_type: "pi",
203
- }),
398
+ const connection = await registrationConnection();
399
+ const body = await connection.request("branch.resolve", {
400
+ inbox_message_id: inboxMessageId,
401
+ session_id: commandContext.sessionId,
402
+ runtime_instance_id: commandContext.runtimeInstanceId,
403
+ client_type: "pi",
204
404
  });
205
- const body = await parseJsonResponse(response);
206
- if (!response.ok) {
207
- throw new Error(`${response.status} ${response.statusText}`);
208
- }
209
- const replay = asRecord(asRecord(asRecord(body)?.data)?.branch_replay);
405
+ const replay = asRecord(asRecord(body)?.branch_replay);
210
406
  replacementInput = optionalString(replay?.replacement_input) ?? "";
211
407
  targetEntryId = optionalString(replay?.target_entry_id) ?? "";
212
408
  if (
@@ -253,7 +449,21 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
253
449
 
254
450
  try {
255
451
  ctx.ui.setEditorText("");
256
- await pi.sendUserMessage(replacementInput);
452
+ const current = await currentManagedSessionContext();
453
+ if (
454
+ current?.sessionId !== commandContext.sessionId ||
455
+ current?.runtimeInstanceId !== commandContext.runtimeInstanceId
456
+ ) {
457
+ throw new Error("branch replay Runtime is no longer current");
458
+ }
459
+ await directInput.run(
460
+ {
461
+ submission: { input: replacementInput, inboxMessageId },
462
+ sessionContext: current,
463
+ consumed: false,
464
+ },
465
+ () => pi.sendUserMessage(replacementInput),
466
+ );
257
467
  } catch (error) {
258
468
  await logDiagnostic(loaded.logFile, {
259
469
  level: "error",
@@ -267,11 +477,17 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
267
477
 
268
478
  async function scheduleMessageRefresh(reason: SessionMessageUpdatedReason): Promise<void> {
269
479
  if (!activeTurn || activeTurn.ended) return;
270
- await activeTurn.reporter.report(activeTurn.context, buildSessionMessageUpdatedEvent(activeTurn.context, reason));
480
+ await activeTurn.reporter.report(
481
+ activeTurn.context,
482
+ buildSessionMessageUpdatedEvent(activeTurn.context, reason),
483
+ );
271
484
  }
272
485
 
273
486
  async function reportFinalMessageRefresh(state: ActiveTurnState): Promise<void> {
274
- await state.reporter.report(state.context, buildSessionMessageUpdatedEvent(state.context, "final"));
487
+ await state.reporter.report(
488
+ state.context,
489
+ buildSessionMessageUpdatedEvent(state.context, "final"),
490
+ );
275
491
  }
276
492
 
277
493
  async function reportContextUsageFromHookEvent(event: unknown, ctx?: unknown): Promise<void> {
@@ -281,20 +497,25 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
281
497
  const usageJson = JSON.stringify(observation);
282
498
  if (usageJson === lastContextUsageJson) return;
283
499
  lastContextUsageJson = usageJson;
284
- await activeTurn.reporter.report(activeTurn.context, buildSessionContextUsageUpdatedEvent(activeTurn.context, observation.context_usage, observation.model));
500
+ await activeTurn.reporter.report(
501
+ activeTurn.context,
502
+ buildSessionContextUsageUpdatedEvent(activeTurn.context, observation.context_usage),
503
+ );
285
504
  }
286
505
 
287
506
  pi.on("before_agent_start", async (event) => {
288
507
  const eventRecord = event as unknown as Record<string, unknown>;
289
508
  pendingPrompt = optionalString(eventRecord.prompt);
290
- if (reportingDisabled || !await confirmManagedPane()) {
291
- return { systemPrompt: typeof eventRecord.systemPrompt === "string" ? eventRecord.systemPrompt : "" };
509
+ if (reportingDisabled || !(await confirmManagedPane())) {
510
+ return {
511
+ systemPrompt: typeof eventRecord.systemPrompt === "string" ? eventRecord.systemPrompt : "",
512
+ };
292
513
  }
293
- const currentSystemPrompt = typeof eventRecord.systemPrompt === "string" ? eventRecord.systemPrompt : "";
514
+ const currentSystemPrompt =
515
+ typeof eventRecord.systemPrompt === "string" ? eventRecord.systemPrompt : "";
294
516
  try {
295
517
  const profilePrompt = await loadProfileSystemPrompt(
296
- currentPontiaHome(),
297
- fetchImpl,
518
+ rpcConnection,
298
519
  boundSessionContext?.sessionId,
299
520
  );
300
521
  if (!profilePrompt) return { systemPrompt: currentSystemPrompt };
@@ -310,11 +531,21 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
310
531
  }
311
532
  });
312
533
 
534
+ pi.on("model_select", async (_event, ctx) => {
535
+ piContext = ctx;
536
+ await reportModel().catch(controlError);
537
+ });
538
+
313
539
  pi.on("session_start", async (event, ctx) => {
314
540
  if (reportingDisabled) return;
315
541
  const reason = (event as unknown as Record<string, unknown> | undefined)?.reason;
316
- if (readyReported && reason !== "fork" && reason !== "resume") return;
317
- if (reason !== "startup" && reason !== "new" && reason !== "resume" && reason !== "fork") return;
542
+ if (readyReported && reason !== "fork" && reason !== "resume" && reason !== "new") return;
543
+ if (reason !== "startup" && reason !== "new" && reason !== "resume" && reason !== "fork")
544
+ return;
545
+ await closeControlSocket();
546
+ const parentSessionId = boundSessionContext?.sessionId;
547
+ boundSessionContext = undefined;
548
+ readyReported = false;
318
549
  if (!isPersistentTuiContext(ctx)) {
319
550
  reportingDisabled = true;
320
551
  return;
@@ -325,26 +556,28 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
325
556
  deferredManualSessionDetails = sessionDetails;
326
557
  const logFile = currentHookLogFile();
327
558
  let context: SessionContext | undefined;
328
- const pontiaHome = currentPontiaHome();
329
559
  const env = currentEnv();
330
560
 
331
- const workspaceActive = await isActiveRegisteredWorkspace(pontiaHome, fetchImpl, sessionDetails.clientCwd);
332
- if (workspaceActive !== true) {
561
+ const workspaceActive = await isActiveRegisteredWorkspace(
562
+ await registrationConnection(),
563
+ sessionDetails.clientCwd,
564
+ );
565
+ if (!workspaceActive) {
566
+ await closeControlSocket();
333
567
  reportingDisabled = true;
334
568
  await logDiagnostic(logFile, {
335
569
  level: "info",
336
- code: workspaceActive === false ? "workspace_not_active" : "workspace_check_unavailable",
337
- message: workspaceActive === false
338
- ? "current pi workspace is not an active registered pontia workspace; pontia reporting disabled"
339
- : "could not verify active registered pontia workspace; pontia reporting disabled",
570
+ code: "workspace_not_active",
571
+ message:
572
+ "current pi workspace is not an active registered pontia workspace; pontia reporting disabled",
340
573
  details: { client_cwd: sessionDetails.clientCwd },
341
574
  });
342
575
  return;
343
576
  }
344
577
 
345
578
  if (reason === "fork") {
346
- const parentSessionId = boundSessionContext?.sessionId;
347
579
  if (!parentSessionId) {
580
+ await closeControlSocket();
348
581
  await logDiagnostic(logFile, {
349
582
  level: "error",
350
583
  code: "missing_fork_parent_session",
@@ -352,21 +585,29 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
352
585
  });
353
586
  return;
354
587
  }
355
- context = await bindSession(pontiaHome, env, fetchImpl, sessionDetails, { startKind: "fork", parentSessionId });
588
+ context = await bindSession(await registrationConnection(), env, sessionDetails, {
589
+ startKind: "fork",
590
+ parentSessionId,
591
+ });
356
592
  readyReported = false;
357
593
  } else {
358
- const existingSession = await loadExistingSessionContext(pontiaHome, fetchImpl, sessionDetails);
594
+ const existingSession = await loadExistingSessionContext(
595
+ await registrationConnection(),
596
+ sessionDetails,
597
+ );
359
598
  if (
360
- existingSession
361
- && ["idle", "busy", "interrupted"].includes(existingSession.sessionState)
599
+ existingSession &&
600
+ ["idle", "busy", "interrupted"].includes(existingSession.sessionState)
362
601
  ) {
602
+ await closeControlSocket();
363
603
  reportingDisabled = true;
364
604
  boundSessionContext = undefined;
365
605
  readyReported = false;
366
606
  await logDiagnostic(logFile, {
367
607
  level: "info",
368
608
  code: "duplicate_active_client_session",
369
- message: "native pi session is already bound to an active pontia session; duplicate TUI reporting disabled",
609
+ message:
610
+ "native pi session is already bound to an active pontia session; duplicate TUI reporting disabled",
370
611
  details: {
371
612
  client_session_key: sessionDetails.clientSessionKey,
372
613
  session_id: existingSession.sessionId,
@@ -375,22 +616,35 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
375
616
  });
376
617
  return;
377
618
  }
378
- if (!existingSession && !await confirmManagedPane()) {
619
+ if (!existingSession && !(await confirmManagedPane())) {
620
+ await closeControlSocket();
379
621
  boundSessionContext = undefined;
380
622
  readyReported = false;
381
623
  return;
382
624
  }
383
625
 
384
- context = await bindSession(pontiaHome, env, fetchImpl, sessionDetails, {
385
- runtimeInstanceId: existingSession?.runtimeInstanceId,
626
+ context = await bindSession(await registrationConnection(), env, sessionDetails, {
627
+ runtimeInstanceId:
628
+ existingSession?.sessionState === "starting"
629
+ ? existingSession.runtimeInstanceId
630
+ : undefined,
386
631
  });
387
632
  if (reason === "resume" || reason === "new") readyReported = false;
388
633
  }
389
- if (!context || !await confirmManagedPane(true)) return;
634
+ if (!context || !(await confirmManagedPane(true))) {
635
+ await closeControlSocket();
636
+ return;
637
+ }
390
638
 
391
639
  boundSessionContext = context;
392
- readyReported = reportAccepted(await makeReporter(logFile).report(context, buildSessionReadyEvent(context)));
640
+ piContext = ctx;
641
+ controlSocket!.registered(context);
642
+ readyReported = reportAccepted(
643
+ await makeReporter(logFile).report(context, buildSessionReadyEvent(context)),
644
+ );
645
+ await reportModel().catch(controlError);
393
646
  } catch (error) {
647
+ await closeControlSocket();
394
648
  const logFile = currentHookLogFile();
395
649
  await logDiagnostic(logFile, {
396
650
  level: "error",
@@ -402,14 +656,18 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
402
656
  });
403
657
 
404
658
  pi.on("session_shutdown", async (event) => {
405
- if (reportingDisabled || !await confirmManagedPane()) return;
406
- const reason = (event as unknown as Record<string, unknown> | undefined)?.reason;
407
- if (reason !== "quit" && reason !== "new" && reason !== "resume" && reason !== "fork") return;
408
-
659
+ piContext = undefined;
660
+ readyReported = false;
409
661
  try {
662
+ if (reportingDisabled || !(await confirmManagedPane())) return;
663
+ const reason = (event as unknown as Record<string, unknown> | undefined)?.reason;
664
+ if (reason !== "quit" && reason !== "new" && reason !== "resume" && reason !== "fork") return;
410
665
  const logFile = currentHookLogFile();
411
666
  if (!boundSessionContext) return;
412
- await makeReporter(logFile).report(boundSessionContext, buildSessionExitedEvent(boundSessionContext, reason));
667
+ await makeReporter(logFile).report(
668
+ boundSessionContext,
669
+ buildSessionExitedEvent(boundSessionContext, reason),
670
+ );
413
671
  } catch (error) {
414
672
  const logFile = currentHookLogFile();
415
673
  await logDiagnostic(logFile, {
@@ -418,6 +676,8 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
418
676
  message: "failed to report pontia exited signal",
419
677
  details: error instanceof Error ? error.message : String(error),
420
678
  });
679
+ } finally {
680
+ await closeControlSocket();
421
681
  }
422
682
  });
423
683
 
@@ -426,75 +686,81 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
426
686
  const previousLeafId = leafIdFromHookContext(ctx);
427
687
  if (reportingDisabled) return;
428
688
  try {
429
- const loaded = await contextLoader(currentEnv(), boundSessionContext);
430
- let turnContext: TurnContext | undefined;
431
- let logFile: string;
432
- if (loaded.ok) {
433
- turnContext = loaded.context;
434
- logFile = loaded.logFile;
435
- } else if (loaded.silent) {
436
- if (!loaded.logFile) {
689
+ const dispatch = directInput.getStore();
690
+ const submission =
691
+ dispatch && !dispatch.consumed && dispatch.sessionContext === boundSessionContext
692
+ ? dispatch.submission
693
+ : undefined;
694
+ if (dispatch) dispatch.consumed = true;
695
+ const logFile = currentHookLogFile();
696
+ if (!boundSessionContext) {
697
+ const hookSessionDetails = piSessionDetailsFromHookContext(ctx);
698
+ const sessionDetails = hookSessionDetails.clientSessionKey
699
+ ? hookSessionDetails
700
+ : deferredManualSessionDetails;
701
+ if (!sessionDetails) {
437
702
  activeTurn = undefined;
438
703
  pendingPrompt = undefined;
439
704
  return;
440
705
  }
441
- logFile = loaded.logFile;
442
- if (!boundSessionContext) {
443
- const hookSessionDetails = piSessionDetailsFromHookContext(ctx);
444
- const sessionDetails = hookSessionDetails.clientSessionKey ? hookSessionDetails : deferredManualSessionDetails;
445
- if (!sessionDetails) {
446
- activeTurn = undefined;
447
- pendingPrompt = undefined;
448
- return;
449
- }
450
- const pontiaHome = currentPontiaHome();
451
- const workspaceActive = await isActiveRegisteredWorkspace(pontiaHome, fetchImpl, sessionDetails.clientCwd);
452
- if (workspaceActive !== true) {
453
- reportingDisabled = true;
454
- await logDiagnostic(logFile, {
455
- level: "info",
456
- code: workspaceActive === false ? "workspace_not_active" : "workspace_check_unavailable",
457
- message: workspaceActive === false
458
- ? "current pi workspace is not an active registered pontia workspace; pontia reporting disabled"
459
- : "could not verify active registered pontia workspace; pontia reporting disabled",
460
- details: { client_cwd: sessionDetails.clientCwd },
461
- });
462
- activeTurn = undefined;
463
- pendingPrompt = undefined;
464
- return;
465
- }
466
- boundSessionContext = await bindSession(pontiaHome, currentEnv(), fetchImpl, sessionDetails);
467
- if (boundSessionContext && !readyReported) {
468
- readyReported = reportAccepted(await makeReporter(logFile).report(boundSessionContext, buildSessionReadyEvent(boundSessionContext)));
469
- }
470
- }
471
- if (boundSessionContext) {
472
- turnContext = {
473
- sessionId: boundSessionContext.sessionId,
474
- runtimeInstanceId: boundSessionContext.runtimeInstanceId,
475
- clientType: "pi",
476
- internalEventUrl: boundSessionContext.internalEventUrl,
477
- input: pendingPrompt,
478
- };
479
- } else {
706
+ const workspaceActive = await isActiveRegisteredWorkspace(
707
+ await registrationConnection(),
708
+ sessionDetails.clientCwd,
709
+ );
710
+ if (!workspaceActive) {
711
+ await closeControlSocket();
712
+ reportingDisabled = true;
713
+ await logDiagnostic(logFile, {
714
+ level: "info",
715
+ code: "workspace_not_active",
716
+ message:
717
+ "current pi workspace is not an active registered pontia workspace; pontia reporting disabled",
718
+ details: { client_cwd: sessionDetails.clientCwd },
719
+ });
480
720
  activeTurn = undefined;
481
721
  pendingPrompt = undefined;
482
722
  return;
483
723
  }
484
- } else {
724
+ boundSessionContext = await bindSession(
725
+ await registrationConnection(),
726
+ currentEnv(),
727
+ sessionDetails,
728
+ );
729
+ if (boundSessionContext && !readyReported) {
730
+ piContext = ctx;
731
+ controlSocket!.registered(boundSessionContext);
732
+ readyReported = reportAccepted(
733
+ await makeReporter(logFile).report(
734
+ boundSessionContext,
735
+ buildSessionReadyEvent(boundSessionContext),
736
+ ),
737
+ );
738
+ await reportModel().catch(controlError);
739
+ }
740
+ }
741
+ if (!boundSessionContext) {
485
742
  activeTurn = undefined;
486
- if (!loaded.silent && ctx?.hasUI) ctx.ui.notify(`pontia: ${loaded.reason}`, "warning");
743
+ pendingPrompt = undefined;
487
744
  return;
488
745
  }
746
+ const turnContext: TurnContext = {
747
+ sessionId: boundSessionContext.sessionId,
748
+ runtimeInstanceId: boundSessionContext.runtimeInstanceId,
749
+ clientType: "pi",
750
+ ...(submission ?? { input: pendingPrompt }),
751
+ };
489
752
 
490
753
  pendingPrompt = undefined;
491
- if (!await confirmManagedPane(true)) {
754
+ if (!(await confirmManagedPane(true))) {
492
755
  activeTurn = undefined;
493
756
  return;
494
757
  }
495
758
  const reporter = makeReporter(logFile);
496
759
  lastContextUsageJson = undefined;
497
- const started = await reporter.report(turnContext, buildTurnStartedEvent(turnContext, previousLeafId, topologyContext));
760
+ const started = await reporter.report(
761
+ turnContext,
762
+ buildTurnStartedEvent(turnContext, previousLeafId, topologyContext),
763
+ );
498
764
  const canonicalTurnId = typeof started === "boolean" ? turnContext.turnId : started.turnId;
499
765
  if (!reportAccepted(started) || !canonicalTurnId) {
500
766
  activeTurn = undefined;
@@ -505,14 +771,17 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
505
771
  });
506
772
  return;
507
773
  }
774
+ const activeContext = { ...turnContext, turnId: canonicalTurnId };
508
775
  activeTurn = {
509
- context: { ...turnContext, turnId: canonicalTurnId },
776
+ context: activeContext,
510
777
  logFile,
511
778
  reporter,
779
+ liveOutput: makeLiveOutputPublisher(activeContext),
512
780
  output: "",
513
781
  ended: false,
514
782
  };
515
783
  } catch (error) {
784
+ if (!boundSessionContext) await closeControlSocket();
516
785
  pendingPrompt = undefined;
517
786
  activeTurn = undefined;
518
787
  const logFile = currentHookLogFile();
@@ -529,12 +798,18 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
529
798
  if (!activeTurn || activeTurn.ended) return;
530
799
  await reportContextUsageFromHookEvent(event, ctx);
531
800
 
532
- const fullText = assistantTextFromMessage((event as unknown as Record<string, unknown> | undefined)?.message);
801
+ const delta = assistantDeltaFromEvent(event);
802
+ if (delta) activeTurn.liveOutput.appendText(delta);
803
+ const toolCall = completeToolCallFromMessageUpdate(event);
804
+ if (toolCall) activeTurn.liveOutput.appendToolCall(toolCall);
805
+
806
+ const fullText = assistantTextFromMessage(
807
+ (event as unknown as Record<string, unknown> | undefined)?.message,
808
+ );
533
809
  if (fullText) {
534
810
  activeTurn.output = fullText;
535
- } else {
536
- const delta = assistantDeltaFromEvent(event);
537
- if (delta) activeTurn.output += delta;
811
+ } else if (delta) {
812
+ activeTurn.output += delta;
538
813
  }
539
814
 
540
815
  if (isTranscriptBoundaryMessageUpdate(event)) await scheduleMessageRefresh("update");
@@ -551,44 +826,71 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
551
826
  pi.on("message_end", async (event, ctx) => {
552
827
  if (!activeTurn || activeTurn.ended) return;
553
828
  await reportContextUsageFromHookEvent(event, ctx);
554
- const fullText = assistantTextFromMessage((event as unknown as Record<string, unknown> | undefined)?.message);
829
+ const fullText = assistantTextFromMessage(
830
+ (event as unknown as Record<string, unknown> | undefined)?.message,
831
+ );
555
832
  if (fullText) activeTurn.output = fullText;
556
833
  await scheduleMessageRefresh("append");
557
834
  });
558
835
 
836
+ pi.on("agent_settled", (_event, ctx) => {
837
+ if (!queuedControlInput || !ctx.isIdle()) return;
838
+ const submission = queuedControlInput;
839
+ queuedControlInput = undefined;
840
+ sendControlInput(submission);
841
+ });
842
+
559
843
  pi.on("agent_end", async (event, ctx) => {
560
844
  if (!activeTurn || activeTurn.ended) return;
561
845
  await reportContextUsageFromHookEvent(event, ctx);
562
846
  activeTurn.ended = true;
563
847
 
564
848
  const state = activeTurn;
565
- const terminalLeafId = leafIdFromHookContext(ctx);
566
- if (agentEndWasInterrupted(event, ctx?.signal)) {
567
- await state.reporter.report(state.context, buildTurnInterruptedEvent(state.context, terminalLeafId));
568
- await reportFinalMessageRefresh(state);
569
- return;
570
- }
849
+ try {
850
+ const terminalLeafId = leafIdFromHookContext(ctx);
851
+ if (agentEndWasInterrupted(event, ctx?.signal)) {
852
+ await state.reporter.report(
853
+ state.context,
854
+ buildTurnInterruptedEvent(state.context, terminalLeafId),
855
+ );
856
+ await reportFinalMessageRefresh(state);
857
+ return;
858
+ }
571
859
 
572
- const failureMessage = errorMessageFromAgentEnd(event);
573
- if (failureMessage) {
574
- await state.reporter.report(state.context, buildTurnFailedEvent(state.context, failureMessage, terminalLeafId));
575
- await reportFinalMessageRefresh(state);
576
- return;
577
- }
860
+ const failureMessage = errorMessageFromAgentEnd(event);
861
+ if (failureMessage) {
862
+ await state.reporter.report(
863
+ state.context,
864
+ buildTurnFailedEvent(state.context, failureMessage, terminalLeafId),
865
+ );
866
+ await reportFinalMessageRefresh(state);
867
+ return;
868
+ }
578
869
 
579
- if (!state.output) {
580
- const finalText = lastAssistantTextFromMessages((event as unknown as Record<string, unknown> | undefined)?.messages);
581
- if (finalText) state.output = finalText;
582
- }
870
+ if (!state.output) {
871
+ const finalText = lastAssistantTextFromMessages(
872
+ (event as unknown as Record<string, unknown> | undefined)?.messages,
873
+ );
874
+ if (finalText) state.output = finalText;
875
+ }
583
876
 
584
- const output = state.output.trim();
585
- if (output.length > 0) {
586
- const outputResult = await state.reporter.report(state.context, buildTurnOutputEvent(state.context, output));
587
- if (!reportAccepted(outputResult)) return;
588
- }
877
+ const output = state.output.trim();
878
+ if (output.length > 0) {
879
+ const outputResult = await state.reporter.report(
880
+ state.context,
881
+ buildTurnOutputEvent(state.context, output),
882
+ );
883
+ if (!reportAccepted(outputResult)) return;
884
+ }
589
885
 
590
- await state.reporter.report(state.context, buildTurnCompletedEvent(state.context, terminalLeafId));
591
- await reportFinalMessageRefresh(state);
886
+ await state.reporter.report(
887
+ state.context,
888
+ buildTurnCompletedEvent(state.context, terminalLeafId),
889
+ );
890
+ await reportFinalMessageRefresh(state);
891
+ } finally {
892
+ await state.liveOutput.close();
893
+ }
592
894
  });
593
895
  }
594
896