@pontia/pi-client-plugin 0.1.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,39 +1,88 @@
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 {
20
62
  return typeof result === "boolean" ? result : result.accepted;
21
63
  }
22
64
 
65
+ function isPersistentTuiContext(ctx: unknown): boolean {
66
+ if (!ctx || typeof ctx !== "object") return false;
67
+ if ((ctx as Record<string, unknown>).mode !== "tui") return false;
68
+ return piSessionDetailsFromHookContext(ctx).clientSessionFile !== undefined;
69
+ }
70
+
23
71
  export interface PontiaPiExtensionDependencies {
24
72
  env?: EnvLike;
25
- loadContext?: (env: EnvLike, sessionContext?: SessionContext) => Promise<LoadTurnContextResult>;
26
73
  makeReporter?: (logFile: string) => ReporterLike;
27
74
  logDiagnostic?: (logFile: string, entry: DiagnosticEntry) => Promise<void>;
28
- fetch?: typeof fetch;
29
75
  loadManagedRuntime?: (env: EnvLike) => Promise<ManagedRuntimeIdentity | undefined>;
30
76
  isManagedPane?: (env: EnvLike) => Promise<boolean>;
77
+ makeLiveOutputPublisher?: (context: TurnContext & { turnId: string }) => LiveOutputPublisherLike;
78
+ connectPi?: typeof connectPi;
31
79
  }
32
80
 
33
81
  interface ActiveTurnState {
34
82
  context: TurnContext & { turnId: string };
35
83
  logFile: string;
36
84
  reporter: ReporterLike;
85
+ liveOutput: LiveOutputPublisherLike;
37
86
  output: string;
38
87
  ended: boolean;
39
88
  }
@@ -62,13 +111,12 @@ const NON_MESSAGE_ENTRY_KINDS = new Set([
62
111
  function topologyEntryKind(entry: Record<string, unknown>): PiTopologyEntryKind {
63
112
  if (entry.type !== "message") {
64
113
  return typeof entry.type === "string" && NON_MESSAGE_ENTRY_KINDS.has(entry.type)
65
- ? entry.type as PiTopologyEntryKind
114
+ ? (entry.type as PiTopologyEntryKind)
66
115
  : "other";
67
116
  }
68
117
  const message = entry.message;
69
- const role = message && typeof message === "object"
70
- ? (message as Record<string, unknown>).role
71
- : undefined;
118
+ const role =
119
+ message && typeof message === "object" ? (message as Record<string, unknown>).role : undefined;
72
120
  if (role === "user") return "user_message";
73
121
  if (role === "assistant") return "assistant_message";
74
122
  if (role === "toolResult") return "tool_result_message";
@@ -97,7 +145,10 @@ function topologyContextFromHookContext(ctx: unknown): PiTopologyContext | undef
97
145
  }
98
146
  }
99
147
 
100
- export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPiExtensionDependencies = {}): void {
148
+ export function createPontiaPiExtension(
149
+ pi: ExtensionAPI,
150
+ dependencies: PontiaPiExtensionDependencies = {},
151
+ ): void {
101
152
  const sourceEnv = dependencies.env ?? process.env;
102
153
  if (!pontiaHomeFromEnv(sourceEnv) || !hasTmuxPaneEnvironment(sourceEnv)) return;
103
154
  // Environment extensions may load PONTIA_HOME after extension registration.
@@ -105,12 +156,26 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
105
156
  const currentEnv = () => ({ ...sourceEnv, PONTIA_HOME: currentPontiaHome() });
106
157
  const currentHookLogFile = () => defaultHookLogFile(currentPontiaHome());
107
158
 
108
- const contextLoader = dependencies.loadContext ?? ((contextEnv, sessionContext) => loadTurnContext(contextEnv, { sessionContext }));
109
- 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
+ }));
110
172
  const logDiagnostic = dependencies.logDiagnostic ?? appendDiagnostic;
111
- const fetchImpl = dependencies.fetch ?? fetch;
112
173
  const loadManagedRuntime = dependencies.loadManagedRuntime ?? loadPontiaManagedRuntimeIdentity;
113
174
  const isManagedPane = dependencies.isManagedPane ?? isPontiaManagedTmuxPane;
175
+ const makeLiveOutputPublisher =
176
+ dependencies.makeLiveOutputPublisher ??
177
+ ((context: TurnContext & { turnId: string }) =>
178
+ new LiveOutputPublisher(context, { connection: rpcConnection }));
114
179
 
115
180
  let activeTurn: ActiveTurnState | undefined;
116
181
  let readyReported = false;
@@ -120,6 +185,169 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
120
185
  let managedPaneConfirmed = false;
121
186
  let lastContextUsageJson: string | undefined;
122
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
+ }
123
351
 
124
352
  async function confirmManagedPane(refresh = false): Promise<boolean> {
125
353
  if (managedPaneConfirmed && !refresh) return true;
@@ -131,19 +359,17 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
131
359
  if (boundSessionContext) return boundSessionContext;
132
360
  const runtimeIdentity = await loadManagedRuntime(currentEnv());
133
361
  if (!runtimeIdentity) return undefined;
134
- const connection = await resolvePontiaConnection({ pontiaHome: currentPontiaHome(), fetch: fetchImpl });
135
- if (!connection?.internalEventUrl) return undefined;
136
362
  return {
137
363
  sessionId: runtimeIdentity.sessionId,
138
364
  runtimeInstanceId: runtimeIdentity.runtimeInstanceId,
139
365
  clientType: "pi",
140
- internalEventUrl: connection.internalEventUrl,
141
366
  };
142
367
  }
143
368
 
144
369
  pi.registerCommand("pontia-edit", {
145
370
  description: "Replay a Pontia Inbox message from a historical Pi entry",
146
371
  handler: async (args, ctx) => {
372
+ if (!isPersistentTuiContext(ctx)) return;
147
373
  const inboxMessageId = args.trim();
148
374
  const logFile = currentHookLogFile();
149
375
  if (!/^msg_[^\s]+$/.test(inboxMessageId)) {
@@ -165,41 +391,18 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
165
391
  return;
166
392
  }
167
393
  const loaded = { logFile };
168
- const connection = await resolvePontiaConnection({ pontiaHome: currentPontiaHome() });
169
- if (!connection?.externalApiToken) {
170
- await logDiagnostic(loaded.logFile, {
171
- level: "error",
172
- code: "branch_replay_stale_context",
173
- message: "pontia-edit requires an authenticated Pontia connection",
174
- });
175
- return;
176
- }
177
- const resolveUrl = commandContext.internalEventUrl.replace(
178
- /\/events\/?$/,
179
- "/inbox/branch-replay/resolve",
180
- );
181
394
 
182
395
  let replacementInput: string;
183
396
  let targetEntryId: string;
184
397
  try {
185
- const response = await fetchImpl(resolveUrl, {
186
- method: "POST",
187
- headers: {
188
- Authorization: `Bearer ${connection.externalApiToken}`,
189
- "Content-Type": "application/json",
190
- },
191
- body: JSON.stringify({
192
- inbox_message_id: inboxMessageId,
193
- session_id: commandContext.sessionId,
194
- runtime_instance_id: commandContext.runtimeInstanceId,
195
- client_type: "pi",
196
- }),
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",
197
404
  });
198
- const body = await parseJsonResponse(response);
199
- if (!response.ok) {
200
- throw new Error(`${response.status} ${response.statusText}`);
201
- }
202
- const replay = asRecord(asRecord(asRecord(body)?.data)?.branch_replay);
405
+ const replay = asRecord(asRecord(body)?.branch_replay);
203
406
  replacementInput = optionalString(replay?.replacement_input) ?? "";
204
407
  targetEntryId = optionalString(replay?.target_entry_id) ?? "";
205
408
  if (
@@ -246,7 +449,21 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
246
449
 
247
450
  try {
248
451
  ctx.ui.setEditorText("");
249
- 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
+ );
250
467
  } catch (error) {
251
468
  await logDiagnostic(loaded.logFile, {
252
469
  level: "error",
@@ -260,11 +477,17 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
260
477
 
261
478
  async function scheduleMessageRefresh(reason: SessionMessageUpdatedReason): Promise<void> {
262
479
  if (!activeTurn || activeTurn.ended) return;
263
- await activeTurn.reporter.report(activeTurn.context, buildSessionMessageUpdatedEvent(activeTurn.context, reason));
480
+ await activeTurn.reporter.report(
481
+ activeTurn.context,
482
+ buildSessionMessageUpdatedEvent(activeTurn.context, reason),
483
+ );
264
484
  }
265
485
 
266
486
  async function reportFinalMessageRefresh(state: ActiveTurnState): Promise<void> {
267
- await state.reporter.report(state.context, buildSessionMessageUpdatedEvent(state.context, "final"));
487
+ await state.reporter.report(
488
+ state.context,
489
+ buildSessionMessageUpdatedEvent(state.context, "final"),
490
+ );
268
491
  }
269
492
 
270
493
  async function reportContextUsageFromHookEvent(event: unknown, ctx?: unknown): Promise<void> {
@@ -274,20 +497,25 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
274
497
  const usageJson = JSON.stringify(observation);
275
498
  if (usageJson === lastContextUsageJson) return;
276
499
  lastContextUsageJson = usageJson;
277
- 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
+ );
278
504
  }
279
505
 
280
506
  pi.on("before_agent_start", async (event) => {
281
507
  const eventRecord = event as unknown as Record<string, unknown>;
282
508
  pendingPrompt = optionalString(eventRecord.prompt);
283
- if (reportingDisabled || !await confirmManagedPane()) {
284
- return { systemPrompt: typeof eventRecord.systemPrompt === "string" ? eventRecord.systemPrompt : "" };
509
+ if (reportingDisabled || !(await confirmManagedPane())) {
510
+ return {
511
+ systemPrompt: typeof eventRecord.systemPrompt === "string" ? eventRecord.systemPrompt : "",
512
+ };
285
513
  }
286
- const currentSystemPrompt = typeof eventRecord.systemPrompt === "string" ? eventRecord.systemPrompt : "";
514
+ const currentSystemPrompt =
515
+ typeof eventRecord.systemPrompt === "string" ? eventRecord.systemPrompt : "";
287
516
  try {
288
517
  const profilePrompt = await loadProfileSystemPrompt(
289
- currentPontiaHome(),
290
- fetchImpl,
518
+ rpcConnection,
291
519
  boundSessionContext?.sessionId,
292
520
  );
293
521
  if (!profilePrompt) return { systemPrompt: currentSystemPrompt };
@@ -303,37 +531,53 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
303
531
  }
304
532
  });
305
533
 
534
+ pi.on("model_select", async (_event, ctx) => {
535
+ piContext = ctx;
536
+ await reportModel().catch(controlError);
537
+ });
538
+
306
539
  pi.on("session_start", async (event, ctx) => {
307
540
  if (reportingDisabled) return;
308
541
  const reason = (event as unknown as Record<string, unknown> | undefined)?.reason;
309
- if (readyReported && reason !== "fork" && reason !== "resume") return;
310
- 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;
549
+ if (!isPersistentTuiContext(ctx)) {
550
+ reportingDisabled = true;
551
+ return;
552
+ }
311
553
 
312
554
  try {
313
555
  const sessionDetails = piSessionDetailsFromHookContext(ctx);
314
556
  deferredManualSessionDetails = sessionDetails;
315
557
  const logFile = currentHookLogFile();
316
558
  let context: SessionContext | undefined;
317
- const pontiaHome = currentPontiaHome();
318
559
  const env = currentEnv();
319
560
 
320
- const workspaceActive = await isActiveRegisteredWorkspace(pontiaHome, fetchImpl, sessionDetails.clientCwd);
321
- if (workspaceActive !== true) {
561
+ const workspaceActive = await isActiveRegisteredWorkspace(
562
+ await registrationConnection(),
563
+ sessionDetails.clientCwd,
564
+ );
565
+ if (!workspaceActive) {
566
+ await closeControlSocket();
322
567
  reportingDisabled = true;
323
568
  await logDiagnostic(logFile, {
324
569
  level: "info",
325
- code: workspaceActive === false ? "workspace_not_active" : "workspace_check_unavailable",
326
- message: workspaceActive === false
327
- ? "current pi workspace is not an active registered pontia workspace; pontia reporting disabled"
328
- : "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",
329
573
  details: { client_cwd: sessionDetails.clientCwd },
330
574
  });
331
575
  return;
332
576
  }
333
577
 
334
578
  if (reason === "fork") {
335
- const parentSessionId = boundSessionContext?.sessionId;
336
579
  if (!parentSessionId) {
580
+ await closeControlSocket();
337
581
  await logDiagnostic(logFile, {
338
582
  level: "error",
339
583
  code: "missing_fork_parent_session",
@@ -341,21 +585,29 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
341
585
  });
342
586
  return;
343
587
  }
344
- context = await bindSession(pontiaHome, env, fetchImpl, sessionDetails, { startKind: "fork", parentSessionId });
588
+ context = await bindSession(await registrationConnection(), env, sessionDetails, {
589
+ startKind: "fork",
590
+ parentSessionId,
591
+ });
345
592
  readyReported = false;
346
593
  } else {
347
- const existingSession = await loadExistingSessionContext(pontiaHome, fetchImpl, sessionDetails);
594
+ const existingSession = await loadExistingSessionContext(
595
+ await registrationConnection(),
596
+ sessionDetails,
597
+ );
348
598
  if (
349
- existingSession
350
- && ["idle", "busy", "interrupted"].includes(existingSession.sessionState)
599
+ existingSession &&
600
+ ["idle", "busy", "interrupted"].includes(existingSession.sessionState)
351
601
  ) {
602
+ await closeControlSocket();
352
603
  reportingDisabled = true;
353
604
  boundSessionContext = undefined;
354
605
  readyReported = false;
355
606
  await logDiagnostic(logFile, {
356
607
  level: "info",
357
608
  code: "duplicate_active_client_session",
358
- 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",
359
611
  details: {
360
612
  client_session_key: sessionDetails.clientSessionKey,
361
613
  session_id: existingSession.sessionId,
@@ -364,22 +616,35 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
364
616
  });
365
617
  return;
366
618
  }
367
- if (!existingSession && !await confirmManagedPane()) {
619
+ if (!existingSession && !(await confirmManagedPane())) {
620
+ await closeControlSocket();
368
621
  boundSessionContext = undefined;
369
622
  readyReported = false;
370
623
  return;
371
624
  }
372
625
 
373
- context = await bindSession(pontiaHome, env, fetchImpl, sessionDetails, {
374
- runtimeInstanceId: existingSession?.runtimeInstanceId,
626
+ context = await bindSession(await registrationConnection(), env, sessionDetails, {
627
+ runtimeInstanceId:
628
+ existingSession?.sessionState === "starting"
629
+ ? existingSession.runtimeInstanceId
630
+ : undefined,
375
631
  });
376
632
  if (reason === "resume" || reason === "new") readyReported = false;
377
633
  }
378
- if (!context || !await confirmManagedPane(true)) return;
634
+ if (!context || !(await confirmManagedPane(true))) {
635
+ await closeControlSocket();
636
+ return;
637
+ }
379
638
 
380
639
  boundSessionContext = context;
381
- 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);
382
646
  } catch (error) {
647
+ await closeControlSocket();
383
648
  const logFile = currentHookLogFile();
384
649
  await logDiagnostic(logFile, {
385
650
  level: "error",
@@ -391,14 +656,18 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
391
656
  });
392
657
 
393
658
  pi.on("session_shutdown", async (event) => {
394
- if (reportingDisabled || !await confirmManagedPane()) return;
395
- const reason = (event as unknown as Record<string, unknown> | undefined)?.reason;
396
- if (reason !== "quit" && reason !== "new" && reason !== "resume" && reason !== "fork") return;
397
-
659
+ piContext = undefined;
660
+ readyReported = false;
398
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;
399
665
  const logFile = currentHookLogFile();
400
666
  if (!boundSessionContext) return;
401
- await makeReporter(logFile).report(boundSessionContext, buildSessionExitedEvent(boundSessionContext, reason));
667
+ await makeReporter(logFile).report(
668
+ boundSessionContext,
669
+ buildSessionExitedEvent(boundSessionContext, reason),
670
+ );
402
671
  } catch (error) {
403
672
  const logFile = currentHookLogFile();
404
673
  await logDiagnostic(logFile, {
@@ -407,6 +676,8 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
407
676
  message: "failed to report pontia exited signal",
408
677
  details: error instanceof Error ? error.message : String(error),
409
678
  });
679
+ } finally {
680
+ await closeControlSocket();
410
681
  }
411
682
  });
412
683
 
@@ -415,75 +686,81 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
415
686
  const previousLeafId = leafIdFromHookContext(ctx);
416
687
  if (reportingDisabled) return;
417
688
  try {
418
- const loaded = await contextLoader(currentEnv(), boundSessionContext);
419
- let turnContext: TurnContext | undefined;
420
- let logFile: string;
421
- if (loaded.ok) {
422
- turnContext = loaded.context;
423
- logFile = loaded.logFile;
424
- } else if (loaded.silent) {
425
- 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) {
426
702
  activeTurn = undefined;
427
703
  pendingPrompt = undefined;
428
704
  return;
429
705
  }
430
- logFile = loaded.logFile;
431
- if (!boundSessionContext) {
432
- const hookSessionDetails = piSessionDetailsFromHookContext(ctx);
433
- const sessionDetails = hookSessionDetails.clientSessionKey ? hookSessionDetails : deferredManualSessionDetails;
434
- if (!sessionDetails) {
435
- activeTurn = undefined;
436
- pendingPrompt = undefined;
437
- return;
438
- }
439
- const pontiaHome = currentPontiaHome();
440
- const workspaceActive = await isActiveRegisteredWorkspace(pontiaHome, fetchImpl, sessionDetails.clientCwd);
441
- if (workspaceActive !== true) {
442
- reportingDisabled = true;
443
- await logDiagnostic(logFile, {
444
- level: "info",
445
- code: workspaceActive === false ? "workspace_not_active" : "workspace_check_unavailable",
446
- message: workspaceActive === false
447
- ? "current pi workspace is not an active registered pontia workspace; pontia reporting disabled"
448
- : "could not verify active registered pontia workspace; pontia reporting disabled",
449
- details: { client_cwd: sessionDetails.clientCwd },
450
- });
451
- activeTurn = undefined;
452
- pendingPrompt = undefined;
453
- return;
454
- }
455
- boundSessionContext = await bindSession(pontiaHome, currentEnv(), fetchImpl, sessionDetails);
456
- if (boundSessionContext && !readyReported) {
457
- readyReported = reportAccepted(await makeReporter(logFile).report(boundSessionContext, buildSessionReadyEvent(boundSessionContext)));
458
- }
459
- }
460
- if (boundSessionContext) {
461
- turnContext = {
462
- sessionId: boundSessionContext.sessionId,
463
- runtimeInstanceId: boundSessionContext.runtimeInstanceId,
464
- clientType: "pi",
465
- internalEventUrl: boundSessionContext.internalEventUrl,
466
- input: pendingPrompt,
467
- };
468
- } 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
+ });
469
720
  activeTurn = undefined;
470
721
  pendingPrompt = undefined;
471
722
  return;
472
723
  }
473
- } 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) {
474
742
  activeTurn = undefined;
475
- if (!loaded.silent && ctx?.hasUI) ctx.ui.notify(`pontia: ${loaded.reason}`, "warning");
743
+ pendingPrompt = undefined;
476
744
  return;
477
745
  }
746
+ const turnContext: TurnContext = {
747
+ sessionId: boundSessionContext.sessionId,
748
+ runtimeInstanceId: boundSessionContext.runtimeInstanceId,
749
+ clientType: "pi",
750
+ ...(submission ?? { input: pendingPrompt }),
751
+ };
478
752
 
479
753
  pendingPrompt = undefined;
480
- if (!await confirmManagedPane(true)) {
754
+ if (!(await confirmManagedPane(true))) {
481
755
  activeTurn = undefined;
482
756
  return;
483
757
  }
484
758
  const reporter = makeReporter(logFile);
485
759
  lastContextUsageJson = undefined;
486
- const started = await reporter.report(turnContext, buildTurnStartedEvent(turnContext, previousLeafId, topologyContext));
760
+ const started = await reporter.report(
761
+ turnContext,
762
+ buildTurnStartedEvent(turnContext, previousLeafId, topologyContext),
763
+ );
487
764
  const canonicalTurnId = typeof started === "boolean" ? turnContext.turnId : started.turnId;
488
765
  if (!reportAccepted(started) || !canonicalTurnId) {
489
766
  activeTurn = undefined;
@@ -494,14 +771,17 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
494
771
  });
495
772
  return;
496
773
  }
774
+ const activeContext = { ...turnContext, turnId: canonicalTurnId };
497
775
  activeTurn = {
498
- context: { ...turnContext, turnId: canonicalTurnId },
776
+ context: activeContext,
499
777
  logFile,
500
778
  reporter,
779
+ liveOutput: makeLiveOutputPublisher(activeContext),
501
780
  output: "",
502
781
  ended: false,
503
782
  };
504
783
  } catch (error) {
784
+ if (!boundSessionContext) await closeControlSocket();
505
785
  pendingPrompt = undefined;
506
786
  activeTurn = undefined;
507
787
  const logFile = currentHookLogFile();
@@ -518,12 +798,18 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
518
798
  if (!activeTurn || activeTurn.ended) return;
519
799
  await reportContextUsageFromHookEvent(event, ctx);
520
800
 
521
- 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
+ );
522
809
  if (fullText) {
523
810
  activeTurn.output = fullText;
524
- } else {
525
- const delta = assistantDeltaFromEvent(event);
526
- if (delta) activeTurn.output += delta;
811
+ } else if (delta) {
812
+ activeTurn.output += delta;
527
813
  }
528
814
 
529
815
  if (isTranscriptBoundaryMessageUpdate(event)) await scheduleMessageRefresh("update");
@@ -540,44 +826,71 @@ export function createPontiaPiExtension(pi: ExtensionAPI, dependencies: PontiaPi
540
826
  pi.on("message_end", async (event, ctx) => {
541
827
  if (!activeTurn || activeTurn.ended) return;
542
828
  await reportContextUsageFromHookEvent(event, ctx);
543
- 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
+ );
544
832
  if (fullText) activeTurn.output = fullText;
545
833
  await scheduleMessageRefresh("append");
546
834
  });
547
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
+
548
843
  pi.on("agent_end", async (event, ctx) => {
549
844
  if (!activeTurn || activeTurn.ended) return;
550
845
  await reportContextUsageFromHookEvent(event, ctx);
551
846
  activeTurn.ended = true;
552
847
 
553
848
  const state = activeTurn;
554
- const terminalLeafId = leafIdFromHookContext(ctx);
555
- if (agentEndWasInterrupted(event)) {
556
- await state.reporter.report(state.context, buildTurnInterruptedEvent(state.context, terminalLeafId));
557
- await reportFinalMessageRefresh(state);
558
- return;
559
- }
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
+ }
560
859
 
561
- const failureMessage = errorMessageFromAgentEnd(event);
562
- if (failureMessage) {
563
- await state.reporter.report(state.context, buildTurnFailedEvent(state.context, failureMessage, terminalLeafId));
564
- await reportFinalMessageRefresh(state);
565
- return;
566
- }
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
+ }
567
869
 
568
- if (!state.output) {
569
- const finalText = lastAssistantTextFromMessages((event as unknown as Record<string, unknown> | undefined)?.messages);
570
- if (finalText) state.output = finalText;
571
- }
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
+ }
572
876
 
573
- const output = state.output.trim();
574
- if (output.length > 0) {
575
- const outputResult = await state.reporter.report(state.context, buildTurnOutputEvent(state.context, output));
576
- if (!reportAccepted(outputResult)) return;
577
- }
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
+ }
578
885
 
579
- await state.reporter.report(state.context, buildTurnCompletedEvent(state.context, terminalLeafId));
580
- 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
+ }
581
894
  });
582
895
  }
583
896