threadroom-pi 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -0
  3. package/extensions/README.md +15 -0
  4. package/extensions/index.ts +280 -0
  5. package/extensions/native/index.ts +614 -0
  6. package/extensions/native/presentation.ts +30 -0
  7. package/extensions/native/receipt.ts +25 -0
  8. package/extensions/native/ui.ts +167 -0
  9. package/extensions/presentation/renderers.ts +185 -0
  10. package/extensions/questions/README.md +41 -0
  11. package/extensions/questions/compose.ts +82 -0
  12. package/extensions/questions/external-editor.ts +24 -0
  13. package/extensions/questions/host.ts +415 -0
  14. package/extensions/questions/index.ts +6 -0
  15. package/extensions/questions/model.ts +175 -0
  16. package/extensions/questions/stream.ts +299 -0
  17. package/extensions/questions/text.ts +10 -0
  18. package/extensions/questions/tool.ts +309 -0
  19. package/extensions/questions/types.ts +40 -0
  20. package/extensions/questions/view.ts +221 -0
  21. package/node_modules/threadroom-service/README.md +73 -0
  22. package/node_modules/threadroom-service/bin/threadroom-service.js +9 -0
  23. package/node_modules/threadroom-service/dist/public/app.js +349 -0
  24. package/node_modules/threadroom-service/dist/public/assets/mist-bloom.svg +17 -0
  25. package/node_modules/threadroom-service/dist/public/assets/mist-drift.svg +16 -0
  26. package/node_modules/threadroom-service/dist/public/assets/mist-prowler.svg +15 -0
  27. package/node_modules/threadroom-service/dist/public/client.js +30 -0
  28. package/node_modules/threadroom-service/dist/public/index.html +54 -0
  29. package/node_modules/threadroom-service/dist/public/presentations.js +194 -0
  30. package/node_modules/threadroom-service/dist/public/routes.js +15 -0
  31. package/node_modules/threadroom-service/dist/public/styles.css +263 -0
  32. package/node_modules/threadroom-service/dist/src/live.js +170 -0
  33. package/node_modules/threadroom-service/dist/src/main.js +33 -0
  34. package/node_modules/threadroom-service/dist/src/presentations.js +116 -0
  35. package/node_modules/threadroom-service/dist/src/server.js +143 -0
  36. package/node_modules/threadroom-service/dist/src/site.js +53 -0
  37. package/node_modules/threadroom-service/dist/src/store.js +459 -0
  38. package/node_modules/threadroom-service/dist/src/ui-main.js +14 -0
  39. package/node_modules/threadroom-service/lib/cli.js +188 -0
  40. package/node_modules/threadroom-service/lib/ensure.js +157 -0
  41. package/node_modules/threadroom-service/lib/paths.js +19 -0
  42. package/node_modules/threadroom-service/package.json +19 -0
  43. package/package.json +50 -0
  44. package/scripts/stage-service.js +32 -0
  45. package/scripts/verify-packed.js +85 -0
  46. package/scripts/verify-release.js +79 -0
  47. package/src/client.js +135 -0
  48. package/src/config.js +57 -0
  49. package/src/http-transport.js +44 -0
  50. package/src/participation.js +211 -0
  51. package/src/service-runtime.js +43 -0
@@ -0,0 +1,614 @@
1
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
+ import { Type } from 'typebox';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { AskPanel, pendingCard, plain, type Feedback } from './ui.ts';
5
+ import type { NativeQuestionPresentation, NativeQuestionSource, NativeSavedAnswer } from './presentation.ts';
6
+ import { createReceiptJournal } from './receipt.ts';
7
+ import { renderNativeQuestion, renderNativeAnswer, renderNativeFeedback, renderAsyncAskCall, renderAsyncAskResult } from '../questions/stream.ts';
8
+ import type { QuestionAnswer, QuestionResult } from '../questions/types.ts';
9
+
10
+ const QUESTION = 'threadroom.native.question.v1';
11
+ const ANSWER = 'threadroom.native.answer.v1';
12
+ const FEEDBACK = 'threadroom.native.feedback.v1';
13
+
14
+ // Reload replaces extension closures but preserves Pi's agent queues. Keep only
15
+ // process-local submission identities across that boundary, never question data
16
+ // or durable receipt claims. A fresh process/SessionManager can recover safely.
17
+ const INFLIGHT = Symbol.for('threadroom.native.inflight.v1');
18
+ const inflight: WeakMap<object, Map<string, Set<string>>> =
19
+ (globalThis as any)[INFLIGHT] ??= new WeakMap();
20
+ function submissions(manager: object, sessionId: string) {
21
+ let sessions = inflight.get(manager);
22
+ if (!sessions) { sessions = new Map(); inflight.set(manager, sessions); }
23
+ let ids = sessions.get(sessionId);
24
+ if (!ids) { ids = new Set(); sessions.set(sessionId, ids); }
25
+ return ids;
26
+ }
27
+
28
+ // SDK append can mutate its branch before a failed disk write. These IDs are
29
+ // uncertainty exclusions, never saved data or receipts. Reload cannot make the
30
+ // same manager's failed append authoritative.
31
+ const STORAGE = Symbol.for('threadroom.native.storage-unconfirmed.v1');
32
+ const uncertain: WeakMap<object, Map<string, Set<string>>> = (globalThis as any)[STORAGE] ??= new WeakMap();
33
+ function unconfirmed(ctx: ExtensionContext) {
34
+ const manager = ctx.sessionManager, sessionId = manager.getSessionId();
35
+ let sessions = uncertain.get(manager); if (!sessions) { sessions = new Map(); uncertain.set(manager, sessions); }
36
+ let ids = sessions.get(sessionId); if (!ids) { ids = new Set(); sessions.set(sessionId, ids); }
37
+ return ids;
38
+ }
39
+ function storageFailure(cause?: unknown, anchor?: string | null) {
40
+ return Object.assign(new Error(`Private storage is unconfirmed; no new private feedback will be sent. Draft retained. Recover the original SDK journal before continuing; /reload is not storage recovery. Preserve/copy retained drafts before any process replacement. Do not quit/resume as a feedback retry: feedback may have been consumed without a durable receipt.${anchor ? ` Last prior branch entry: ${anchor}.` : ''}${cause ? ` Cause: ${plain(cause)}` : ''}`), { code: 'storage_unconfirmed' });
41
+ }
42
+
43
+ // An overlay can retain an unmounted widget as preFocus. Share only current
44
+ // UI ownership across closure reload, never questions or durable receipts.
45
+ const FOCUS = Symbol.for('pi.private-question.focus.v1');
46
+ const focusOwners: WeakMap<object, { handoff(data: string, editor: any): void }> =
47
+ (globalThis as any)[FOCUS] ??= new WeakMap();
48
+
49
+ function mountedEditor(tui: any, preferred: any, fallback?: any) {
50
+ const mounted = new Set<any>(), queue = [...(tui.children || [])];
51
+ while (queue.length) {
52
+ const item = queue.pop(); if (!item || mounted.has(item)) continue;
53
+ mounted.add(item); if (Array.isArray(item.children)) queue.push(...item.children);
54
+ }
55
+ const editor = (item: any) => mounted.has(item) && typeof item?.getText === 'function' && typeof item?.setText === 'function';
56
+ return editor(preferred) ? preferred : editor(fallback) ? fallback : [...mounted].find(editor);
57
+ }
58
+
59
+ type Prompt = { question: string; header?: string; context?: string; options?: { label: string; description?: string; preview?: string }[]; multiSelect?: boolean };
60
+ export type NativeQuestionRequest = Readonly<{ question: string; header?: string; context?: string; options?: readonly (string | Readonly<{ label: string; description?: string; preview?: string }>)[]; multiSelect?: boolean }>;
61
+ type Question = { sessionId: string; id: string; toolCallId: string; prompt: Prompt };
62
+ type Answer = { sessionId: string; answerId: string; questionId: string; prompt: Prompt;
63
+ answer: { text: string; custom?: string; optionIndex?: number; selection?: { label: string; description?: string; preview?: string };
64
+ optionIndices?: number[]; selections?: { label: string; description?: string; preview?: string }[] } };
65
+ export type NativeQuestionWaitResult = Readonly<{
66
+ sessionId: string;
67
+ questionId: string;
68
+ answerId?: string;
69
+ status: 'answered' | 'cancelled' | 'already_queued' | 'already_received' | 'already_claimed';
70
+ note?: string;
71
+ /** Internal activation/handoff controls; never serialized into the tool result. */
72
+ acceptWait?: () => void;
73
+ acceptClaim?: () => void;
74
+ releaseClaim?: () => void;
75
+ result: QuestionResult;
76
+ }>;
77
+ export type NativeQuestionWaiter = (questionId: string, context: ExtensionContext, signal: AbortSignal) => Promise<NativeQuestionWaitResult>;
78
+
79
+ /** Native/private asks have no service dependency. The standalone producer keeps its
80
+ * legacy name; composed question surfaces can expose the same capability through
81
+ * their single public tool instead. */
82
+ export function registerNativeAsks(pi: ExtensionAPI, options: {
83
+ presentation?: NativeQuestionPresentation;
84
+ waitToolName?: string;
85
+ registerStandaloneTool?: boolean;
86
+ } = {}) {
87
+ let context: ExtensionContext | undefined;
88
+ let epoch = 0;
89
+ let retired = false;
90
+ let replacing = false;
91
+ let changingTree = false;
92
+ let compacting = false;
93
+ let running = false;
94
+ let timer: ReturnType<typeof setTimeout> | undefined;
95
+ let reopenWhenIdle = false;
96
+ let open: { epoch: number; close?: () => void; panel?: AskPanel; yield?: () => void; focus?: () => void } | undefined;
97
+ const receiptJournal = createReceiptJournal();
98
+ let source: NativeQuestionSource | undefined;
99
+ let projectionError: string | undefined, displayError: string | undefined;
100
+ type Wait = { context: ExtensionContext; activation: number; acceptWait: () => void; signal: AbortSignal; abort: () => void; resolve: (value: NativeQuestionWaitResult) => void; reject: (error: unknown) => void };
101
+ const waits = new Map<string, Wait>();
102
+ const blockingClaims = new Set<string>();
103
+ type Handoff = { context: ExtensionContext; activation: number; answerId: string; live: boolean };
104
+ const pendingHandoffs = new Set<Handoff>();
105
+ function presentationAvailable(ctx: ExtensionContext) {
106
+ if (!options.presentation?.canPresent) return true;
107
+ try { return options.presentation.canPresent(ctx); } catch { return false; }
108
+ }
109
+ function presentationFailed(ctx: ExtensionContext, error: unknown, phase: 'projection' | 'display') {
110
+ const message = plain(error); if (phase === 'projection') projectionError = message; else displayError = message;
111
+ try { ctx.ui.notify(`Private question presentation failed: ${message}. Saved questions remain pending; /asks retries.`, 'error'); } catch {}
112
+ }
113
+ function append(ctx: ExtensionContext, type: string, data: any) {
114
+ const blocked = unconfirmed(ctx); if (blocked.size) throw storageFailure();
115
+ const prior = new Set(ctx.sessionManager.getBranch().map((entry) => entry.id)), anchor = ctx.sessionManager.getLeafId?.();
116
+ try { pi.appendEntry(type, data); }
117
+ catch (error) {
118
+ for (const entry of ctx.sessionManager.getBranch()) if (!prior.has(entry.id)) blocked.add(entry.id);
119
+ if (blocked.size) throw storageFailure(error, anchor);
120
+ throw error; // A preappend refusal has not poisoned the SDK branch.
121
+ }
122
+ }
123
+ function waitAnswer(answer: Answer): QuestionAnswer {
124
+ if (answer.answer.optionIndices) return Object.freeze({ questionIndex: 0, question: answer.prompt.question,
125
+ selected: Object.freeze((answer.answer.selections || []).map((selection) => selection.label)),
126
+ optionIndices: Object.freeze([...answer.answer.optionIndices]),
127
+ previews: Object.freeze((answer.answer.selections || []).map((selection) => selection.preview ?? null)),
128
+ ...(answer.answer.custom ? { answer: answer.answer.custom } : {}), wasCustom: !!answer.answer.custom });
129
+ return Object.freeze({ questionIndex: 0, question: answer.prompt.question, answer: answer.answer.text,
130
+ ...(answer.answer.optionIndex !== undefined ? { optionIndex: answer.answer.optionIndex } : {}),
131
+ ...(answer.answer.selection?.preview !== undefined ? { preview: answer.answer.selection.preview } : {}),
132
+ wasCustom: answer.answer.optionIndex === undefined });
133
+ }
134
+ function cancelWait(ctx: ExtensionContext, mine: number, questionId: string) {
135
+ if (!active(ctx, mine)) return;
136
+ const wait = waits.get(questionId); if (!wait) return;
137
+ waits.delete(questionId); wait.signal.removeEventListener('abort', wait.abort);
138
+ wait.resolve({ sessionId: ctx.sessionManager.getSessionId(), questionId, status: 'cancelled', acceptWait: wait.acceptWait,
139
+ result: Object.freeze({ answers: Object.freeze([]), cancelled: true }) });
140
+ ambient(ctx);
141
+ }
142
+ function claimAnswer(ctx: ExtensionContext, mine: number, answerId: string) {
143
+ const handoff: Handoff = { context: ctx, activation: mine, answerId, live: true };
144
+ pendingHandoffs.add(handoff); blockingClaims.add(answerId);
145
+ const releaseClaim = () => {
146
+ if (!handoff.live) return; handoff.live = false; pendingHandoffs.delete(handoff);
147
+ if (blockingClaims.delete(answerId) && active(handoff.context, handoff.activation)) { ambient(handoff.context); flush(handoff.context); }
148
+ };
149
+ const acceptClaim = () => {
150
+ if (!handoff.live || !pendingHandoffs.has(handoff) || !active(handoff.context, handoff.activation)) {
151
+ releaseClaim();
152
+ throw Object.assign(new Error('Private question answer handoff detached during session transition.'), { code: 'presentation_detached' });
153
+ }
154
+ // No await follows acceptance before the tool result is returned. Removing
155
+ // the capability here makes that synchronous return the delivery winner.
156
+ pendingHandoffs.delete(handoff);
157
+ };
158
+ return { acceptClaim, releaseClaim };
159
+ }
160
+ function settleWait(answer: Answer) {
161
+ const wait = waits.get(answer.questionId); if (!wait) return;
162
+ waits.delete(answer.questionId); wait.signal.removeEventListener('abort', wait.abort);
163
+ const claim = claimAnswer(wait.context, wait.activation, answer.answerId);
164
+ wait.resolve({ sessionId: answer.sessionId, questionId: answer.questionId, answerId: answer.answerId, status: 'answered', acceptWait: wait.acceptWait, ...claim,
165
+ result: Object.freeze({ answers: Object.freeze([waitAnswer(answer)]), cancelled: false }) });
166
+ }
167
+ function persistReply(ctx: ExtensionContext, mine: number, reply: { questionId: string; text: string; optionIndex?: number; optionIndices?: readonly number[] }): NativeSavedAnswer {
168
+ if (!active(ctx, mine)) throw new Error('Original private question activation is detached.');
169
+ const state = project(ctx), question = state.questions.get(reply.questionId);
170
+ if (!question || state.answers.has(reply.questionId)) throw new Error('Original question is not pending on this branch.');
171
+ const selection = reply.optionIndex === undefined ? undefined : question.prompt.options?.[reply.optionIndex];
172
+ if (reply.optionIndex !== undefined && (!Number.isInteger(reply.optionIndex) || !selection)) throw new Error('Invalid original option index.');
173
+ const optionIndices = reply.optionIndices ? [...reply.optionIndices] : undefined;
174
+ const selections = optionIndices?.map((index) => question.prompt.options?.[index]);
175
+ if (optionIndices && (new Set(optionIndices).size !== optionIndices.length || selections?.some((item) => !item))) {
176
+ throw new Error('Invalid original option indices.');
177
+ }
178
+ const custom = plain(reply.text);
179
+ const labels = (selections?.filter((item): item is NonNullable<typeof item> => !!item) || []).map((item) => item.label);
180
+ const text = [labels.join(', '), custom].filter(Boolean).join('; '); if (!text) throw new Error('Reply must not be empty.');
181
+ const answer: Answer = { sessionId: state.sessionId, answerId: `answer-${randomUUID()}`, questionId: question.id, prompt: question.prompt,
182
+ answer: { text, ...(selection ? { optionIndex: reply.optionIndex, selection } : {}),
183
+ ...(optionIndices ? { optionIndices, selections: selections as NonNullable<typeof selections> } : {}),
184
+ ...(optionIndices && custom ? { custom } : {}) } };
185
+ append(ctx, ANSWER, answer);
186
+ if (project(ctx).answers.get(question.id)?.answerId !== answer.answerId) throw new Error('Host did not save the answer entry.');
187
+ // Reserve the exact saved answer before projection removes its tab or flush
188
+ // can enqueue ordinary async feedback for the same answer.
189
+ settleWait(answer);
190
+ return { sessionId: state.sessionId, questionId: question.id, answerId: answer.answerId };
191
+ }
192
+ let hostPrompt = false;
193
+ let submitted = new Set<string>();
194
+
195
+ function project(ctx: ExtensionContext) {
196
+ const sessionId = ctx.sessionManager.getSessionId();
197
+ const questions = new Map<string, Question>();
198
+ const answers = new Map<string, Answer>();
199
+ const received = new Set<string>();
200
+ const blocked = unconfirmed(ctx), diskReceipts = receiptJournal(ctx.sessionManager);
201
+ for (const entry of ctx.sessionManager.getBranch() as any[]) {
202
+ if (blocked.has(entry.id)) continue;
203
+ if (entry.type === 'custom' && entry.data?.sessionId === sessionId) {
204
+ if (entry.customType === QUESTION) questions.set(entry.data.id, entry.data);
205
+ if (entry.customType === ANSWER) answers.set(entry.data.questionId, entry.data);
206
+ }
207
+ if (entry.type === 'custom_message' && entry.customType === FEEDBACK && entry.details?.sessionId === sessionId) {
208
+ if (diskReceipts && !diskReceipts.has(entry.id)) { blocked.add(entry.id); continue; }
209
+ received.add(entry.details.answerId);
210
+ }
211
+ if (entry.type === 'message' && entry.message?.role === 'toolResult' && entry.message.toolName === 'ask_user_question' &&
212
+ entry.message.isError !== true && entry.message.details?.sessionId === sessionId) {
213
+ if (diskReceipts && !diskReceipts.has(entry.id)) { blocked.add(entry.id); continue; }
214
+ for (const id of entry.message.details?.receivedNativeAnswerIds || []) if (typeof id === 'string') received.add(id);
215
+ }
216
+ }
217
+ // An answer is meaningful only with its original question on this branch.
218
+ for (const id of answers.keys()) if (!questions.has(id)) answers.delete(id);
219
+ return { sessionId, questions, answers, received, storageUnconfirmed: blocked.size > 0,
220
+ pending: [...questions.values()].filter((question) => !answers.has(question.id)) };
221
+ }
222
+ function sameSession(ctx: ExtensionContext) {
223
+ return context?.sessionManager === ctx.sessionManager;
224
+ }
225
+ function active(ctx: ExtensionContext, mine: number) {
226
+ return mine === epoch && sameSession(ctx) && !replacing && !changingTree && !compacting;
227
+ }
228
+ function ambient(ctx: ExtensionContext) {
229
+ if (ctx.mode !== 'tui') return;
230
+ const state = project(ctx);
231
+ for (const id of state.received) { submitted.delete(id); blockingClaims.delete(id); }
232
+ const waiting = [...state.answers.values()].filter((answer) => !state.received.has(answer.answerId)).length;
233
+ try {
234
+ ctx.ui.setStatus('native-asks', state.storageUnconfirmed ? 'Private storage unconfirmed · recovery needed' : state.pending.length || waiting
235
+ ? `Asks: ${state.pending.length} pending${waiting ? ` · ${waiting} saved feedback` : ''} · /asks` : undefined);
236
+ if (options.presentation) {
237
+ if (!presentationAvailable(ctx)) {
238
+ const oldSource = source; source = undefined; oldSource?.dispose();
239
+ projectionError = 'This Pi host does not expose the UI capability required to present private questions.';
240
+ return;
241
+ }
242
+ if (active(ctx, epoch)) {
243
+ if (!source) {
244
+ const mine = epoch;
245
+ source = options.presentation.connect({ context: ctx, sessionId: state.sessionId, activation: mine, commit(reply) {
246
+ const saved = persistReply(ctx, mine, reply);
247
+ try { ambient(ctx); flush(ctx); } catch (error) { try { ctx.ui.notify(`Feedback saved (${saved.answerId}), but continuation failed: ${plain(error)}. No receipt claimed.`, 'error'); } catch {} }
248
+ return saved;
249
+ }, cancelWait(questionId) { cancelWait(ctx, mine, questionId); } });
250
+ }
251
+ source.replace(state.pending, { requiredQuestionIds: [...waits.keys()], answeredQuestionIds: [...state.answers.keys()] }); projectionError = undefined;
252
+ if (!state.pending.length) displayError = undefined;
253
+ }
254
+ return;
255
+ }
256
+ if (open && !state.pending.length) {
257
+ const old = open; open = undefined; old.yield?.(); old.close?.();
258
+ } else open?.panel?.update(state.pending);
259
+ if (!open) ctx.ui.setWidget('native-asks', state.pending.length && active(ctx, epoch)
260
+ ? (tui, theme) => pendingCard(state.pending, tui, theme) : undefined);
261
+ projectionError = undefined;
262
+ } catch (error) { presentationFailed(ctx, error, 'projection'); }
263
+ }
264
+ function flush(ctx: ExtensionContext) {
265
+ if (!active(ctx, epoch) || ctx.mode !== 'tui') return;
266
+ // isIdle includes tree summarization/compaction, not only agent streaming.
267
+ if (!running && !ctx.isIdle()) { resumeWhenIdle(ctx, epoch); return; }
268
+ const state = project(ctx);
269
+ if (state.storageUnconfirmed) { ambient(ctx); return; }
270
+ for (const answer of state.answers.values()) {
271
+ if (state.received.has(answer.answerId) || submitted.has(answer.answerId) || blockingClaims.has(answer.answerId)) continue;
272
+ submitted.add(answer.answerId); // In-flight only: never a persistence receipt.
273
+ try {
274
+ pi.sendMessage({ customType: FEEDBACK, display: true, details: answer,
275
+ content: `Saved private human feedback for native ask (answer identity ${answer.answerId}):\n${JSON.stringify(answer)}` },
276
+ { deliverAs: 'steer', triggerTurn: true });
277
+ } catch (error) {
278
+ submitted.delete(answer.answerId);
279
+ ctx.ui.notify(`Feedback saved, but delivery failed: ${plain(error)}`, 'error');
280
+ }
281
+ }
282
+ ambient(ctx);
283
+ }
284
+ function invalidate() {
285
+ ++epoch; projectionError = displayError = undefined;
286
+ for (const [id, wait] of waits) {
287
+ waits.delete(id); wait.signal.removeEventListener('abort', wait.abort);
288
+ wait.reject(Object.assign(new Error('Private question wait detached during session transition.'), { code: 'presentation_detached' }));
289
+ }
290
+ for (const handoff of pendingHandoffs) handoff.live = false;
291
+ pendingHandoffs.clear(); blockingClaims.clear();
292
+ const oldSource = source; source = undefined;
293
+ try { oldSource?.dispose(); } catch (error) { if (context) presentationFailed(context, error, 'projection'); }
294
+ clearTimeout(timer); timer = undefined; reopenWhenIdle = false;
295
+ const old = open; open = undefined; old?.yield?.(); old?.close?.();
296
+ if (!options.presentation && context?.mode === 'tui') context.ui.setWidget('native-asks', undefined);
297
+ }
298
+ function bind(ctx: ExtensionContext) {
299
+ invalidate();
300
+ context = ctx; replacing = changingTree = compacting = false; running = false;
301
+ submitted = submissions(ctx.sessionManager, ctx.sessionManager.getSessionId());
302
+ ambient(ctx); flush(ctx); void present(ctx);
303
+ }
304
+ function assertAdmission(ctx: ExtensionContext) {
305
+ if (retired) throw Object.assign(new Error('Private question producer belongs to a retired session.'), { code: 'presentation_detached' });
306
+ if (!context) bind(ctx);
307
+ if (!active(ctx, epoch)) throw Object.assign(new Error('Private question session is changing; retry only after the active session lifecycle resumes.'), { code: 'presentation_detached' });
308
+ }
309
+ function captureAdmission(ctx: ExtensionContext) {
310
+ assertAdmission(ctx); const mine = epoch;
311
+ return () => {
312
+ if (!active(ctx, mine)) throw Object.assign(new Error('Private question completion detached during session transition.'), { code: 'presentation_detached' });
313
+ };
314
+ }
315
+ const waitForQuestion: NativeQuestionWaiter = async (questionId, ctx, signal) => {
316
+ const id = questionId.trim();
317
+ if (!id) throw Object.assign(new Error('A private question ID is required.'), { code: 'invalid_question_id' });
318
+ signal.throwIfAborted();
319
+ if (ctx.mode !== 'tui' || !options.presentation || !presentationAvailable(ctx)) {
320
+ throw Object.assign(new Error('Waiting on a private nonblocking question needs its interactive TUI presentation.'), { code: 'unsupported_host' });
321
+ }
322
+ const acceptWait = captureAdmission(ctx);
323
+ const state = project(ctx), answered = state.answers.get(id);
324
+ if (state.storageUnconfirmed) throw storageFailure();
325
+ if (answered) {
326
+ const result = Object.freeze({ answers: Object.freeze([]), cancelled: false });
327
+ if (state.received.has(answered.answerId)) return { sessionId: answered.sessionId, questionId: id, acceptWait,
328
+ answerId: answered.answerId, status: 'already_received', note: 'The answer was already delivered in this session branch.', result };
329
+ if (submitted.has(answered.answerId)) return { sessionId: answered.sessionId, questionId: id, acceptWait,
330
+ answerId: answered.answerId, status: 'already_queued', note: 'The saved answer is already queued through the original nonblocking feedback path; no second delivery was created.', result };
331
+ if (blockingClaims.has(answered.answerId)) return { sessionId: answered.sessionId, questionId: id, acceptWait,
332
+ answerId: answered.answerId, status: 'already_claimed', note: 'Another blocking result already owns delivery of this saved answer.', result };
333
+ const claim = claimAnswer(ctx, epoch, answered.answerId);
334
+ return { sessionId: answered.sessionId, questionId: id, answerId: answered.answerId, status: 'answered', acceptWait, ...claim,
335
+ result: Object.freeze({ answers: Object.freeze([waitAnswer(answered)]), cancelled: false }) };
336
+ }
337
+ if (!state.questions.has(id)) throw Object.assign(new Error('That private question does not belong to this session branch.'), { code: 'unknown_question' });
338
+ if (waits.has(id)) throw Object.assign(new Error('This session is already waiting on that private question.'), { code: 'already_waiting' });
339
+ let resolve!: (value: NativeQuestionWaitResult) => void, reject!: (error: unknown) => void;
340
+ const outcome = new Promise<NativeQuestionWaitResult>((done, fail) => { resolve = done; reject = fail; });
341
+ const abort = () => {
342
+ const wait = waits.get(id); if (!wait || wait.abort !== abort) return;
343
+ waits.delete(id); signal.removeEventListener('abort', abort);
344
+ reject(signal.reason ?? Object.assign(new Error('Private question wait aborted.'), { name: 'AbortError' }));
345
+ if (active(ctx, epoch)) ambient(ctx);
346
+ };
347
+ waits.set(id, { context: ctx, activation: epoch, acceptWait, signal, abort, resolve, reject });
348
+ signal.addEventListener('abort', abort, { once: true });
349
+ ambient(ctx);
350
+ if (!source || projectionError) {
351
+ waits.delete(id); signal.removeEventListener('abort', abort); ambient(ctx);
352
+ throw Object.assign(new Error(`Private question could not become required${projectionError ? `: ${projectionError}` : '.'}`), { code: 'presentation_failed' });
353
+ }
354
+ return outcome;
355
+ };
356
+ // Ordinary agent/compaction busy state can end without another extension
357
+ // event. This timer retries only work already admitted by an active epoch; it
358
+ // is never evidence that a session transition completed.
359
+ function resumeWhenIdle(ctx: ExtensionContext, mine: number, reopen = false) {
360
+ reopenWhenIdle ||= reopen;
361
+ if (timer) return;
362
+ timer = setTimeout(() => {
363
+ timer = undefined;
364
+ if (mine !== epoch || !sameSession(ctx)) return;
365
+ if (!ctx.isIdle()) { resumeWhenIdle(ctx, mine); return; }
366
+ const show = reopenWhenIdle; reopenWhenIdle = false;
367
+ ambient(ctx); flush(ctx); if (show) void present(ctx);
368
+ }, 100);
369
+ timer.unref?.();
370
+ }
371
+ function boundary(ctx: ExtensionContext, kind: 'replace' | 'tree' | 'compact') {
372
+ invalidate();
373
+ if (kind === 'replace') replacing = true;
374
+ if (kind === 'tree') changingTree = true;
375
+ if (kind === 'compact') compacting = true;
376
+ try { ctx.ui.setStatus('native-asks', kind === 'compact'
377
+ ? 'Private asks paused for compaction'
378
+ : 'Private asks paused for session transition · reload after a cancelled transition'); } catch {}
379
+ }
380
+ pi.on('session_start', (_event, ctx) => { retired = false; bind(ctx); });
381
+ pi.on('session_shutdown', () => {
382
+ retired = true; invalidate();
383
+ context?.ui.setStatus('native-asks', undefined); context = undefined;
384
+ });
385
+ pi.on('session_before_switch', (_event, ctx) => { boundary(ctx, 'replace'); });
386
+ pi.on('session_before_fork', (_event, ctx) => { boundary(ctx, 'replace'); });
387
+ pi.on('session_before_tree', (_event, ctx) => { boundary(ctx, 'tree'); });
388
+ pi.on('session_tree', (_event, ctx) => {
389
+ changingTree = false; submitted = submissions(ctx.sessionManager, ctx.sessionManager.getSessionId());
390
+ if (active(ctx, epoch)) { ambient(ctx); flush(ctx); void present(ctx); }
391
+ });
392
+ pi.on('session_before_compact', (_event, ctx) => { boundary(ctx, 'compact'); });
393
+ for (const event of ['session_compact', 'session_compact_failed'] as const) {
394
+ pi.on(event, (_event, ctx) => {
395
+ clearTimeout(timer); timer = undefined; compacting = false;
396
+ if (active(ctx, epoch)) { ambient(ctx); flush(ctx); void present(ctx); }
397
+ });
398
+ }
399
+ // Our inline widget does not start a blocking UI span. The host's outer
400
+ // prompt events therefore describe other prompts, including overlapping ones.
401
+ pi.on('ui_prompt_start', () => { hostPrompt = true; open?.yield?.(); });
402
+ pi.on('ui_prompt_end', (_event, ctx) => {
403
+ hostPrompt = false;
404
+ if (sameSession(ctx) && active(ctx, epoch)) open?.focus?.();
405
+ });
406
+ pi.on('agent_start', () => { running = true; });
407
+ function releaseBlockingClaims(ctx: ExtensionContext) {
408
+ for (const handoff of pendingHandoffs) handoff.live = false;
409
+ pendingHandoffs.clear(); blockingClaims.clear();
410
+ if (active(ctx, epoch)) { ambient(ctx); flush(ctx); }
411
+ }
412
+ pi.on('turn_end', (_event, ctx) => { if (sameSession(ctx)) releaseBlockingClaims(ctx); });
413
+ pi.on('agent_settled', (_event, ctx) => {
414
+ running = false;
415
+ if (sameSession(ctx)) releaseBlockingClaims(ctx);
416
+ });
417
+
418
+ pi.registerEntryRenderer<Question>(QUESTION, (entry, options, theme) => renderNativeQuestion(entry.data, options, theme));
419
+ pi.registerEntryRenderer<Answer>(ANSWER, (entry, options, theme) => renderNativeAnswer(entry.data, options, theme));
420
+ pi.registerMessageRenderer<Answer>(FEEDBACK, (message, options, theme) => renderNativeFeedback(message.details, options, theme));
421
+
422
+ function result(value: any) { return { content: [{ type: 'text' as const, text: JSON.stringify(value) }], details: value }; }
423
+ async function askQuestion(toolCallId: string, params: NativeQuestionRequest, signal: AbortSignal | undefined, ctx: ExtensionContext) {
424
+ if (ctx.mode !== 'tui') return result({ status: 'unsupported_host', host: ctx.mode,
425
+ reason: 'Nonblocking private asks require interactive Pi TUI; no question was saved.' });
426
+ if (signal?.aborted) return result({ status: 'aborted', saved: false });
427
+ if (!presentationAvailable(ctx)) return result({ status: 'unsupported_host', host: ctx.mode, saved: false,
428
+ reason: 'This Pi host does not expose the private question presentation capability; no question was saved.' });
429
+ if (retired) return result({ status: 'session_changing', saved: false });
430
+ if (!context) bind(ctx);
431
+ if (!active(ctx, epoch)) return result({ status: 'session_changing', saved: false });
432
+ if (!params.question.trim()) return result({ status: 'invalid_question', saved: false });
433
+ const state = project(ctx);
434
+ const id = `ask-${createHash('sha256').update(`${state.sessionId}\0${toolCallId}`).digest('hex').slice(0, 24)}`;
435
+ const prompt: Prompt = { question: params.question,
436
+ ...(params.header !== undefined ? { header: params.header } : {}),
437
+ ...(params.context !== undefined ? { context: params.context } : {}),
438
+ ...(params.options !== undefined ? { options: params.options.map((option) =>
439
+ typeof option === 'string' ? { label: option } : { label: option.label,
440
+ ...(option.description !== undefined ? { description: option.description } : {}),
441
+ ...(option.preview !== undefined ? { preview: option.preview } : {}) }) } : {}),
442
+ ...(params.multiSelect !== undefined ? { multiSelect: params.multiSelect } : {}) };
443
+ const old = state.questions.get(id);
444
+ if (old && JSON.stringify(old.prompt) !== JSON.stringify(prompt)) return result({ status: 'identity_conflict', id, saved: false });
445
+ try {
446
+ if (state.storageUnconfirmed) throw storageFailure();
447
+ if (!old) append(ctx, QUESTION, { sessionId: state.sessionId, id, toolCallId, prompt });
448
+ const saved = project(ctx);
449
+ if (!saved.questions.has(id)) throw new Error('Host did not save the question entry.');
450
+ } catch (error) { return result({ status: (error as any)?.code === 'storage_unconfirmed' ? 'storage_unconfirmed' : 'save_failed', id, saved: false, error: plain(error) }); }
451
+ ambient(ctx); if (!old && !options.presentation) present(ctx, id);
452
+ const saved = project(ctx);
453
+ const status = saved.answers.has(id) ? 'answered' : 'pending';
454
+ return result({ id, sessionId: state.sessionId, status,
455
+ ...(status === 'pending' && options.waitToolName ? { waitWith: { tool: options.waitToolName, questions: [{ questionId: id }], blocking: true } } : {}),
456
+ ...((projectionError || displayError) ? { presentationError: projectionError || displayError } : {}),
457
+ pending: saved.pending.map((question) => ({ id: question.id, question: question.prompt.question.slice(0, 160) })) });
458
+ }
459
+ if (options.registerStandaloneTool !== false) pi.registerTool({
460
+ name: 'ask_user_question_async', label: 'Ask privately (async)',
461
+ description: 'Ask one private native Pi question, then continue working. The question opens automatically in Pi’s input area while this tool returns a stable pending identity immediately. The person can select a suggestion or write freely; /asks reopens paused questions. Saved feedback steers this session or wakes it when idle. Interactive TUI only; questions remain local to this session/branch, not shared Threadroom.',
462
+ parameters: Type.Object({
463
+ question: Type.String({ minLength: 1, maxLength: 12000, description: 'The question, in ordinary text. No header required.' }),
464
+ context: Type.Optional(Type.String({ maxLength: 30000, description: 'Optional context for the person.' })),
465
+ options: Type.Optional(Type.Array(Type.Union([Type.String({ minLength: 1, maxLength: 2000 }),
466
+ Type.Object({ label: Type.String({ minLength: 1, maxLength: 2000 }),
467
+ preview: Type.Optional(Type.String({ maxLength: 12000, description: 'Optional plain-text preview.' })) })]),
468
+ { maxItems: 20, description: 'Optional suggestions; the person can edit them or answer freely.' })),
469
+ }),
470
+ renderShell: 'self',
471
+ renderCall: renderAsyncAskCall,
472
+ renderResult: renderAsyncAskResult,
473
+ execute(toolCallId, params, signal, _update, ctx) { return askQuestion(toolCallId, params, signal, ctx); },
474
+ });
475
+
476
+
477
+ function present(ctx: ExtensionContext, initialId?: string, focus = false) {
478
+ if (ctx.mode !== 'tui' || !active(ctx, epoch) || open) return;
479
+ if (!running && !ctx.isIdle()) { resumeWhenIdle(ctx, epoch, true); return; }
480
+ const state = project(ctx);
481
+ if (!state.pending.length) return;
482
+ if (options.presentation) {
483
+ ambient(ctx); try { source?.reveal(initialId, { focus }); if (source) displayError = undefined; } catch (error) { presentationFailed(ctx, error, 'display'); }
484
+ return;
485
+ }
486
+ const mine = epoch;
487
+ const opening: NonNullable<typeof open> = { epoch: mine };
488
+ open = opening;
489
+ try {
490
+ ctx.ui.setWidget('native-asks', (tui, theme) => {
491
+ if (typeof tui.getFocusedComponent !== 'function' || typeof tui.setFocus !== 'function')
492
+ throw new Error('This Pi TUI does not support owned inline-question focus.');
493
+ let previous: any;
494
+ const finish = (feedback: Feedback | undefined) => {
495
+ if (open !== opening) return;
496
+ if (!feedback) { opening.yield?.(); open = undefined; }
497
+ if (!active(ctx, mine)) return;
498
+ try {
499
+ if (feedback) {
500
+ const current = project(ctx);
501
+ const question = current.questions.get(feedback.id);
502
+ if (question && !current.answers.has(feedback.id) && feedback.text.trim()) {
503
+ const selection = feedback.optionIndex === undefined ? undefined : question.prompt.options?.[feedback.optionIndex];
504
+ const answer: Answer = { sessionId: current.sessionId, answerId: `answer-${randomUUID()}`,
505
+ questionId: question.id, prompt: question.prompt,
506
+ answer: { text: feedback.text, ...(selection ? { optionIndex: feedback.optionIndex, selection } : {}) } };
507
+ append(ctx, ANSWER, answer); // Save prompt association + structured answer BEFORE wake.
508
+ if (project(ctx).answers.get(question.id)?.answerId !== answer.answerId) throw new Error('Host did not save the answer entry.');
509
+ }
510
+ } else ctx.ui.notify('Paused without answering. Question stays visible and pending; unsaved draft discarded. /asks reopens it.', 'info');
511
+ } catch (error) {
512
+ ctx.ui.notify(`Private question could not complete: ${plain(error)}. Check /asks; no decline was recorded.`, 'error');
513
+ }
514
+ ambient(ctx); flush(ctx);
515
+ // The same panel projects the remaining questions, preserving their
516
+ // unsaved drafts. Only explicit pause or a host boundary discards them.
517
+ };
518
+ const panel = new AskPanel(state.pending, tui, theme, finish, initialId);
519
+ panel.suspend(hostPrompt);
520
+ opening.panel = panel;
521
+ const owner = { handoff(data: string, editor: any) {
522
+ if (!active(ctx, mine) || open !== opening) return;
523
+ const target = mountedEditor(tui, previous, editor);
524
+ tui.setFocus(target || null); opening.focus?.();
525
+ if (tui.getFocusedComponent() === panel) panel.handleInput(data);
526
+ else target?.handleInput?.(data);
527
+ } };
528
+ focusOwners.set(tui, owner);
529
+ panel.onRetire = () => { if (focusOwners.get(tui) === owner) focusOwners.delete(tui); };
530
+ panel.retiredInput = (data) => {
531
+ if (tui.getFocusedComponent() !== panel) return;
532
+ const successor = focusOwners.get(tui);
533
+ if (successor && successor !== owner) successor.handoff(data, previous);
534
+ else { const target = mountedEditor(tui, previous); tui.setFocus(target || null); target?.handleInput?.(data); }
535
+ tui.requestRender();
536
+ };
537
+ opening.close = () => panel.close();
538
+ opening.yield = () => {
539
+ // Never restore over another prompt that has already taken focus.
540
+ panel.suspend(true);
541
+ if (tui.getFocusedComponent() === panel) tui.setFocus(previous);
542
+ };
543
+ opening.focus = () => {
544
+ if (!active(ctx, mine) || open !== opening) return;
545
+ const current: any = tui.getFocusedComponent();
546
+ // Built-in selectors do not emit ui_prompt spans. Only the public
547
+ // editor boundary is eligible to lend focus; never borrow a selector.
548
+ const editor = typeof current?.getText === 'function' && typeof current?.setText === 'function';
549
+ const available = !hostPrompt && (current === panel || editor);
550
+ panel.suspend(!available);
551
+ if (available && current !== panel) { previous = current; tui.setFocus(panel); }
552
+ };
553
+ // Host selectors request a render on open/close. Reconcile focus at that
554
+ // boundary too, without a timer, a displaced UI promise or private APIs.
555
+ panel.beforeRender = () => opening.focus?.();
556
+ // setWidget mounts synchronously after this factory returns. Claim focus
557
+ // afterwards, without retaining a custom-UI promise or replacing an editor.
558
+ queueMicrotask(() => opening.focus?.());
559
+ return panel;
560
+ }, { placement: 'aboveEditor' });
561
+ ambient(ctx); displayError = undefined;
562
+ } catch (error) {
563
+ if (open === opening) { opening.yield?.(); open = undefined; }
564
+ ambient(ctx); presentationFailed(ctx, error, 'display');
565
+ }
566
+ }
567
+
568
+ pi.registerCommand('asks', {
569
+ description: 'Reopen this session’s pending private questions.',
570
+ async handler(args, ctx) {
571
+ if (ctx.mode !== 'tui') { ctx.ui.notify('Private asks require interactive Pi TUI.', 'warning'); return; }
572
+ if (retired) { ctx.ui.notify('Private asks belong to a retired session; retry after the active session starts.', 'info'); return; }
573
+ if (!context) bind(ctx);
574
+ if (!active(ctx, epoch)) {
575
+ ctx.ui.notify(compacting
576
+ ? 'Private asks are paused for compaction; retry after the host reports completion.'
577
+ : 'Private asks remain gated because Pi does not report a cancelled or failed session transition. After the transition command returns, run /reload and then /asks.', 'info');
578
+ return;
579
+ }
580
+ const state = project(ctx);
581
+ if (state.storageUnconfirmed) {
582
+ ambient(ctx); ctx.ui.notify(plain(storageFailure()), 'error');
583
+ if (!open && state.pending.length) {
584
+ const id = args.trim() || undefined;
585
+ if (!id || state.pending.some((question) => question.id === id)) present(ctx, id, true);
586
+ }
587
+ return;
588
+ }
589
+ if (open) { ctx.ui.notify('A private question is already open.', 'info'); return; }
590
+ if (!state.pending.length) {
591
+ ambient(ctx);
592
+ // The shared presenter may still own a blocking-only group. /asks is
593
+ // explicit focus intent even though that group has no native ask row.
594
+ if (options.presentation) {
595
+ try { if (source?.reveal(undefined, { focus: true })) { displayError = undefined; return; } }
596
+ catch (error) { presentationFailed(ctx, error, 'display'); return; }
597
+ }
598
+ const waiting = [...state.answers.values()].filter((answer) => !state.received.has(answer.answerId));
599
+ ctx.ui.notify(waiting.length
600
+ ? `No pending private questions. ${waiting.length} saved feedback item(s) await an actual host receipt:\n` +
601
+ waiting.map((answer) => `${plain(answer.prompt.question)}\n${plain(answer.answer.text)}\nAnswer: ${answer.answerId}`).join('\n\n') +
602
+ '\n\nDelivery may still be queued or consumed without a durable receipt. Preserve/copy retained drafts and the original SDK journal. Do not submit again or quit/resume as a feedback retry; /reload is not a delivery retry.'
603
+ : 'No pending private questions.', 'info');
604
+ return;
605
+ }
606
+ const initialId = args.trim() || undefined;
607
+ if (initialId && !state.pending.some((question) => question.id === initialId)) {
608
+ ctx.ui.notify('That private question is not pending on this session branch.', 'warning'); return;
609
+ }
610
+ present(ctx, initialId, true);
611
+ },
612
+ });
613
+ return { waitForQuestion, captureAdmission, askQuestion };
614
+ }
@@ -0,0 +1,30 @@
1
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
2
+
3
+ export type NativeQuestion = Readonly<{ sessionId: string; id: string; toolCallId: string; prompt: Readonly<{ question: string; header?: string; context?: string; options?: readonly Readonly<{ label: string; description?: string; preview?: string }>[]; multiSelect?: boolean }> }>;
4
+ export type NativeSavedAnswer = Readonly<{ sessionId: string; questionId: string; answerId: string }>;
5
+ export interface NativeQuestionSource {
6
+ /** Authoritative branch projection. Requiredness is a live wait lease, while
7
+ * answered IDs distinguish successful persistence from source detachment. */
8
+ replace(questions: readonly NativeQuestion[], state?: Readonly<{
9
+ requiredQuestionIds?: readonly string[];
10
+ answeredQuestionIds?: readonly string[];
11
+ }>): void;
12
+ /** Select/display a pending question. Only an explicit person action grants
13
+ * focus intent; bind/navigation/recovery use passive reveal. */
14
+ reveal(questionId?: string, options?: Readonly<{ focus?: boolean }>): boolean | void;
15
+ dispose(): void;
16
+ }
17
+ export interface NativeQuestionPresentation {
18
+ /** Optional pre-persistence admission for the presentation's public host
19
+ * capabilities. A false result means no new question may be saved. */
20
+ canPresent?(context: ExtensionContext): boolean;
21
+ connect(binding: {
22
+ context: ExtensionContext;
23
+ sessionId: string;
24
+ activation: number;
25
+ /** Validates original activation/branch and acknowledges persistence only. */
26
+ commit(reply: { questionId: string; text: string; optionIndex?: number; optionIndices?: readonly number[] }): NativeSavedAnswer;
27
+ /** Releases only the live required wait; the saved async question remains. */
28
+ cancelWait(questionId: string): void;
29
+ }): NativeQuestionSource;
30
+ }