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,299 @@
1
+ import type { Theme, ToolRenderContext, ToolRenderResultOptions } from '@earendil-works/pi-coding-agent';
2
+ import { Text, truncateToWidth, type Component } from '@earendil-works/pi-tui';
3
+ import { readable } from './text.ts';
4
+
5
+ /** Transcript presentation only. Raw records confer no storage/delivery authority.
6
+ * Authored fields are sanitized before trusted theme styling; inputs are never changed. */
7
+ export type StreamOptions = { readonly expanded: boolean };
8
+ type Result = { readonly details?: unknown; readonly content?: readonly unknown[] };
9
+ type RecordValue = Record<string, unknown>;
10
+ type Role = 'question' | 'reply' | 'context';
11
+ type Row = { text: string; color?: 'text' | 'dim' | 'warning' | 'error'; compact?: boolean };
12
+
13
+ function record(value: unknown): RecordValue {
14
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as RecordValue : {};
15
+ }
16
+ function list(value: unknown): unknown[] { return Array.isArray(value) ? value : []; }
17
+ function field(value: unknown): string { return typeof value === 'string' ? readable(value) : ''; }
18
+ function line(value: unknown): string { return field(value).replace(/\s+/gu, ' ').trim(); }
19
+ function title(prompt: unknown): string {
20
+ const data = record(prompt);
21
+ return line(data.header) || field(data.question).split('\n').find(part => part.trim())?.trim() || '';
22
+ }
23
+ function authoredTitle(prompt: unknown): string { return line(record(prompt).header); }
24
+ function preview(value: unknown, maxLines = 6, maxCharacters = 2000): string {
25
+ const text = field(value), lines = text.split('\n').map(part => part.trimEnd());
26
+ let shown = lines.slice(0, maxLines).join('\n'), clipped = lines.length > maxLines;
27
+ if (shown.length > maxCharacters) { shown = shown.slice(0, maxCharacters); clipped = true; }
28
+ return shown + (clipped ? '…' : '');
29
+ }
30
+ function compactPromptRows(prompts: unknown[]): Row[] {
31
+ if (prompts.length === 1) return preview(record(prompts[0]).question) ? [{ text: preview(record(prompts[0]).question) }] : [];
32
+ return prompts.flatMap((value, index) => {
33
+ const prompt = record(value), label = authoredTitle(prompt) || `Question ${index + 1}`;
34
+ return [{ text: label, color: 'dim' as const }, { text: preview(prompt.question) }];
35
+ });
36
+ }
37
+ // Escaped literal IDs retain actual identity even if an older host supplies controls.
38
+ function identity(value: unknown): string {
39
+ return typeof value === 'string' && value.length ? JSON.stringify(value).slice(1, -1)
40
+ .replace(/[\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`) : '';
41
+ }
42
+ function reference(kind: string, value: unknown): string {
43
+ const id = identity(value); return id ? `${kind}:${id}` : '';
44
+ }
45
+ function callReference(context?: ToolRenderContext): string { return reference('call', context?.toolCallId); }
46
+ function integer(value: unknown): value is number { return Number.isInteger(value) && (value as number) >= 0; }
47
+ function literal(label: string, value: unknown): Row[] {
48
+ return typeof value === 'string' ? [{ text: `${label}:`, color: 'dim' }, { text: field(value) }] : [];
49
+ }
50
+ function promptRows(value: unknown): Row[] {
51
+ const prompt = record(value), rows = [...literal('Authored header', prompt.header), ...literal('Question', prompt.question), ...literal('Context', prompt.context)];
52
+ if (typeof prompt.multiSelect === 'boolean') rows.push({ text: `Multiple selections: ${prompt.multiSelect ? 'allowed' : 'no'}`, color: 'dim' });
53
+ for (const [index, value] of list(prompt.options).entries()) {
54
+ const option = typeof value === 'string' ? { label: value } : record(value);
55
+ rows.push(...literal(`Suggestion ${index + 1}`, option.label), ...literal('Description', option.description), ...literal('Preview', option.preview));
56
+ }
57
+ return rows;
58
+ }
59
+ function compactReply(value: unknown): string {
60
+ const answer = record(value), selected = list(answer.selected).map(line).filter(Boolean).join(', ');
61
+ const custom = field(answer.answer) || field(answer.text), parts = [selected, custom].filter(Boolean);
62
+ return preview(parts.join(selected && custom ? '\n' : ''));
63
+ }
64
+ function nativeReplyRows(value: unknown): Row[] {
65
+ const answer = record(value), rows = literal('Reply', answer.text);
66
+ if (integer(answer.optionIndex)) rows.push({ text: `Original suggestion: ${answer.optionIndex + 1}`, color: 'dim' });
67
+ const selection = record(answer.selection);
68
+ rows.push(...literal('Selected label', selection.label), ...literal('Selected preview', selection.preview));
69
+ const indices = list(answer.optionIndices), selections = list(answer.selections);
70
+ for (const [position, value] of selections.entries()) {
71
+ const selected = record(value), index = indices[position];
72
+ rows.push(...literal(integer(index) ? `Selected suggestion ${index + 1}` : 'Selected label', selected.label),
73
+ ...literal('Selected preview', selected.preview));
74
+ }
75
+ rows.push(...literal('Additional reply', answer.custom));
76
+ return rows;
77
+ }
78
+ function contentText(result: Result): string {
79
+ return list(result.content).map(part => field(record(part).text)).filter(Boolean).join('\n');
80
+ }
81
+
82
+ /** No implicit padding or colored tool-success shell. The host owns placement. */
83
+ function card(role: Role, heading: string, authoredTitle: string, metadata: string[], rows: Row[], theme: Theme): Component {
84
+ const color = role === 'question' ? 'borderAccent' : role === 'reply' ? 'customMessageLabel' : 'dim';
85
+ return {
86
+ render(width: number) {
87
+ if (width <= 0) return [];
88
+ const header = theme.fg(color, heading) + (authoredTitle ? ` · ${theme.fg('text', authoredTitle)}` : '');
89
+ const output = [truncateToWidth(header, width, '…')];
90
+ const meta = metadata.filter(Boolean).join(' · ');
91
+ if (meta) output.push(truncateToWidth(theme.fg('dim', `│ ${meta}`), width, '…'));
92
+ for (const row of rows) {
93
+ // SDK rows never contain line breaks. A one-column viewport cannot fit
94
+ // a wide glyph, so expanded originals use reversible Unicode escapes.
95
+ const literal = row.compact ? line(row.text) : width === 1
96
+ ? row.text.replace(/[^\x00-\x7f]/gu, char => `\\u{${char.codePointAt(0)!.toString(16)}}`) : row.text;
97
+ const text = theme.fg(row.color || 'text', literal);
98
+ output.push(...(row.compact ? [truncateToWidth(text, width, '…')] : new Text(text, 0, 0).render(width).map(part => truncateToWidth(part, width, ''))));
99
+ }
100
+ return output;
101
+ },
102
+ invalidate() {},
103
+ };
104
+ }
105
+ function nativeMetadata(data: RecordValue, question: boolean): string[] {
106
+ return ['PRIVATE', 'ASYNC', reference('q', question ? data.id : data.questionId)];
107
+ }
108
+
109
+ /** Original native source record. A caption is not proof of a successful append. */
110
+ export function renderNativeQuestion(data: unknown, options: StreamOptions, theme: Theme): Component {
111
+ const source = record(data), prompt = record(source.prompt), expanded = options?.expanded;
112
+ const rows = expanded ? [
113
+ ...literal('Question identity', identity(source.id) || undefined),
114
+ ...literal('Source call', identity(source.toolCallId) || undefined),
115
+ ...promptRows(prompt),
116
+ ] : [...compactPromptRows([prompt]), ...([{ text: [list(prompt.options).length ? `${list(prompt.options).length} suggestions` : '',
117
+ typeof prompt.context === 'string' ? 'Context available' : ''].filter(Boolean).join(' · '), color: 'dim', compact: true }]
118
+ .filter(row => row.text) as Row[])];
119
+ return card('question', 'Question', authoredTitle(prompt), expanded ? nativeMetadata(source, true) : [], rows, theme);
120
+ }
121
+
122
+ /** Human reply record; does not claim persistence, consumption, or completion. */
123
+ export function renderNativeAnswer(data: unknown, options: StreamOptions, theme: Theme): Component {
124
+ const source = record(data), answer = record(source.answer);
125
+ const rows = options?.expanded ? [
126
+ ...literal('Question identity', identity(source.questionId) || undefined),
127
+ ...literal('Answer identity', identity(source.answerId) || undefined),
128
+ ...promptRows(source.prompt), ...nativeReplyRows(answer),
129
+ ] : [{ text: compactReply(answer) }].filter(row => row.text);
130
+ return card('reply', 'Your reply', title(source.prompt), options?.expanded ? nativeMetadata(source, false) : [], rows, theme);
131
+ }
132
+
133
+ /** Context echo is intentionally compact, not another primary reply body.
134
+ * An SDK context row alone cannot prove the model read or received it. */
135
+ export function renderNativeFeedback(details: unknown, options: StreamOptions, theme: Theme): Component {
136
+ const source = record(details);
137
+ const rows = options?.expanded ? [
138
+ ...literal('Question identity', identity(source.questionId) || undefined),
139
+ ...literal('Answer identity', identity(source.answerId) || undefined),
140
+ ...promptRows(source.prompt), ...nativeReplyRows(source.answer),
141
+ ] : [];
142
+ return card('context', 'Reply context', '', options?.expanded ? nativeMetadata(source, false) : [], rows, theme);
143
+ }
144
+
145
+ /** Public SDK call context supplies a call reference; absent older-host context
146
+ * never causes a reference inferred from authored question text. */
147
+ export function renderAsyncAskCall(args: unknown, theme: Theme, context?: ToolRenderContext): Component {
148
+ const prompt = record(args), expanded = !!context?.expanded;
149
+ return card('question', 'Question request', authoredTitle(prompt), expanded ? ['PRIVATE', 'ASYNC', callReference(context)] : [],
150
+ expanded ? promptRows(prompt) : compactPromptRows([prompt]), theme);
151
+ }
152
+
153
+ const failedRequests: Record<string, string> = {
154
+ unsupported_host: 'Not presented · unsupported host',
155
+ aborted: 'Request aborted',
156
+ session_changing: 'Request detached · session changing',
157
+ invalid_question: 'Question request invalid',
158
+ identity_conflict: 'Question identity conflict',
159
+ save_failed: 'Question storage failed',
160
+ storage_unconfirmed: 'Storage unconfirmed',
161
+ };
162
+ export function renderAsyncAskResult(result: Result, options: ToolRenderResultOptions, theme: Theme, context?: ToolRenderContext): Component {
163
+ const details = record(result.details), status = field(details.status), partial = options?.isPartial || context?.isPartial;
164
+ const children = list(details.questions).map(record);
165
+ const failed = Object.hasOwn(failedRequests, status) ? failedRequests[status] : undefined;
166
+ const error = context?.isError === true, expanded = !!options?.expanded;
167
+ const metadata = expanded ? ['PRIVATE', 'ASYNC', reference('q', details.id) || callReference(context)] : [];
168
+ const heading = error ? 'Question request failed' : partial ? 'Question request update'
169
+ : status === 'pending' ? 'Waiting for reply' : status === 'answered' ? 'Reply already saved' : 'Question request result';
170
+ const rows: Row[] = [], content = contentText(result);
171
+ if (failed) rows.push({ text: failed, color: status === 'storage_unconfirmed' ? 'warning' : 'error', compact: true });
172
+ for (const [index, child] of children.entries()) {
173
+ const childStatus = field(child.status), childFailure = failedRequests[childStatus];
174
+ const childIdentity = expanded && identity(child.id) ? ` · q:${identity(child.id)}` : '';
175
+ rows.push({ text: `Question ${index + 1}${childIdentity} · ${childFailure || childStatus || 'unknown result'}`,
176
+ color: childFailure ? childStatus === 'storage_unconfirmed' ? 'warning' : 'error' : 'dim', compact: true });
177
+ if (childFailure || options?.expanded) rows.push(...literal('Reason', child.reason), ...literal('Storage diagnostic', child.error),
178
+ ...literal('Presentation diagnostic', child.presentationError));
179
+ }
180
+ if (error || !Object.keys(details).length) {
181
+ const diagnostic = content || field(details.error) || field(details.reason);
182
+ if (diagnostic) rows.push({ text: diagnostic, color: error ? 'error' : 'text', compact: !options?.expanded });
183
+ }
184
+ if (expanded) {
185
+ rows.push(...literal('Question identity', identity(details.id) || undefined), ...literal('Source call', identity(context?.toolCallId) || undefined));
186
+ if (status) rows.push({ text: `Status reported by request: ${status}`, color: 'dim' });
187
+ rows.push(...literal('Reason', details.reason), ...literal('Storage diagnostic', details.error), ...literal('Presentation diagnostic', details.presentationError));
188
+ const args = record(context?.args), authored = list(args.questions);
189
+ if (authored.length) for (const [index, prompt] of authored.entries()) {
190
+ rows.push({ text: `Original question ${index + 1}`, color: 'dim' }, ...promptRows(prompt));
191
+ }
192
+ else if (context?.args) rows.push(...promptRows(context.args));
193
+ }
194
+ return card('context', heading, '', metadata, rows, theme);
195
+ }
196
+
197
+ export function renderBlockingAskCall(args: unknown, theme: Theme, context?: ToolRenderContext): Component {
198
+ const input = record(args), requests = list(input.questions), first = record(requests[0]);
199
+ const nestedQuestionId = requests.length === 1 ? identity(first.questionId) : '';
200
+ const questionId = nestedQuestionId || (!requests.length ? identity(input.questionId) : ''), questions = questionId ? [] : requests;
201
+ const expanded = !!context?.expanded;
202
+ const rows: Row[] = questionId
203
+ ? expanded ? literal('Existing question identity', questionId) : []
204
+ : expanded ? questions.flatMap((question, index) => [
205
+ { text: `Original question ${index + 1}`, color: 'dim' as const }, ...promptRows(question),
206
+ ]) : compactPromptRows(questions);
207
+ return card('question', questionId ? 'Wait for existing question' : 'Question request',
208
+ questionId ? '' : questions.length === 1 ? authoredTitle(first) : `${questions.length} questions`,
209
+ expanded ? ['PRIVATE', 'BLOCKING', questionId ? reference('q', questionId) : callReference(context)] : [], rows, theme);
210
+ }
211
+ function blockingAnswerRows(value: unknown, specs: unknown[], expanded: boolean, ref: string): Row[] {
212
+ const answer = record(value), index = integer(answer.questionIndex) ? answer.questionIndex : undefined;
213
+ const heading = index === undefined ? 'Question reply' : `Original question ${index + 1}`;
214
+ if (!expanded) {
215
+ const rows: Row[] = [];
216
+ if (specs.length > 1) {
217
+ const spec = index === undefined ? undefined : specs[index];
218
+ rows.push({ text: authoredTitle(spec) || (index === undefined ? 'Reply' : `Question ${index + 1}`), color: 'dim' });
219
+ }
220
+ const reply = compactReply(answer);
221
+ if (reply) rows.push({ text: reply });
222
+ if (typeof answer.notes === 'string') rows.push({ text: 'Notes available', color: 'dim', compact: true });
223
+ return rows;
224
+ }
225
+ const rows: Row[] = [{ text: `${heading}${ref ? ` · ${ref}${index === undefined ? '' : `/${index + 1}`}` : ''}`, color: 'dim' }];
226
+ if (!specs.length) rows.push(...literal('Question', answer.question));
227
+ rows.push(...literal('Reply', answer.answer));
228
+ if (integer(answer.optionIndex)) rows.push({ text: `Original suggestion: ${answer.optionIndex + 1}`, color: 'dim' });
229
+ const indices = list(answer.optionIndices);
230
+ for (const [position, selected] of list(answer.selected).entries()) {
231
+ const option = indices[position], label = integer(option) ? `Selected suggestion ${option + 1}` : 'Selected label';
232
+ rows.push(...literal(label, selected));
233
+ }
234
+ rows.push(...literal('Selected preview', answer.preview));
235
+ for (const [position, preview] of list(answer.previews).entries()) {
236
+ rows.push(...literal(`Selected preview ${position + 1}`, preview === null ? '(none authored)' : preview));
237
+ }
238
+ rows.push(...literal('Notes', answer.notes));
239
+ return rows;
240
+ }
241
+
242
+ /** Partial replies retain authored indices; abort/errors never become a human cancel. */
243
+ export function renderBlockingAskResult(result: Result, options: ToolRenderResultOptions, theme: Theme, context?: ToolRenderContext): Component {
244
+ const details = record(result.details), input = record(context?.args), requests = list(input.questions), first = record(requests[0]);
245
+ const nestedQuestionId = requests.length === 1 ? identity(first.questionId) : '';
246
+ const referencedQuestionId = nestedQuestionId || (!requests.length ? identity(input.questionId) : '');
247
+ const specs = referencedQuestionId ? [] : requests, answers = list(details.answers);
248
+ const questionId = identity(details.questionId) || referencedQuestionId, existing = !!questionId;
249
+ const partial = options?.isPartial || context?.isPartial, error = context?.isError, expanded = !!options?.expanded;
250
+ const recognizedReply = typeof details.cancelled === 'boolean' || Array.isArray(details.answers);
251
+ const heading = error ? 'Question request failed' : partial ? 'Reply update'
252
+ : existing && details.cancelled === true ? 'Question wait cancelled'
253
+ : existing && answers.length ? 'Your reply' : existing ? 'Question wait result'
254
+ : details.cancelled === true ? 'Questionnaire cancelled'
255
+ : recognizedReply ? answers.length === 1 ? 'Your reply' : 'Your replies' : 'Question request result';
256
+ const metadata = expanded
257
+ ? ['PRIVATE', 'BLOCKING', existing ? reference('q', details.questionId || referencedQuestionId) : callReference(context) || reference('group', details.groupId)]
258
+ : [];
259
+ const rows: Row[] = [];
260
+ if (existing) {
261
+ if (details.cancelled === true) rows.push({ text: 'Wait cancelled · original nonblocking question remains pending', color: 'dim', compact: true });
262
+ else if (typeof details.waitNote === 'string') rows.push({ text: field(details.waitNote), color: 'dim', compact: true });
263
+ } else if (typeof details.cancelled === 'boolean' && (details.cancelled || specs.length !== 1 || answers.length !== 1)) {
264
+ rows.push({ text: `${answers.length} ${answers.length === 1 ? 'reply' : 'replies'}${specs.length ? ` / ${specs.length} original questions` : ''}${details.cancelled ? ' · partial replies retained' : ''}`, color: 'dim', compact: true });
265
+ if (specs.length && answers.length < specs.length) rows.push({ text: 'Some original questions unanswered', color: 'dim', compact: true });
266
+ }
267
+ if (expanded) {
268
+ rows.push(...literal(existing ? 'Existing question identity' : 'Group identity', existing ? questionId : identity(details.groupId) || undefined));
269
+ if (existing && typeof details.waitStatus === 'string') rows.push({ text: `Wait status: ${field(details.waitStatus)}`, color: 'dim' });
270
+ for (const [index, spec] of specs.entries()) rows.push({ text: `Original question ${index + 1}`, color: 'dim' }, ...promptRows(spec));
271
+ }
272
+ for (const answer of answers) rows.push(...blockingAnswerRows(answer, specs, expanded, metadata[2] || ''));
273
+ if (!answers.length && typeof details.cancelled !== 'boolean') {
274
+ const text = contentText(result);
275
+ if (text) rows.push({ text, color: error ? 'error' : 'text', compact: !expanded });
276
+ }
277
+ const resultTitle = !existing && specs.length === 1 ? authoredTitle(specs[0]) : '';
278
+ return card(error ? 'context' : 'reply', heading, resultTitle, metadata, rows, theme);
279
+ }
280
+
281
+ /** One public question tool has two execution modes. The authored schema stays
282
+ * familiar; only an explicit blocking=false changes the execution contract. */
283
+ export function renderPrivateAskCall(args: unknown, theme: Theme, context?: ToolRenderContext): Component {
284
+ const input = record(args);
285
+ if (input.blocking !== false) return renderBlockingAskCall(args, theme, context);
286
+ const questions = list(input.questions), first = record(questions[0]);
287
+ if (questions.length === 1) return renderAsyncAskCall(first, theme, context);
288
+ const expanded = !!context?.expanded;
289
+ return card('question', 'Question request', `${questions.length} questions`, expanded ? ['PRIVATE', 'ASYNC', callReference(context)] : [],
290
+ expanded ? questions.flatMap((question, index) => [
291
+ { text: `Original question ${index + 1}`, color: 'dim' as const }, ...promptRows(question),
292
+ ]) : compactPromptRows(questions), theme);
293
+ }
294
+
295
+ export function renderPrivateAskResult(result: Result, options: ToolRenderResultOptions, theme: Theme, context?: ToolRenderContext): Component {
296
+ return record(context?.args).blocking === false
297
+ ? renderAsyncAskResult(result, options, theme, context)
298
+ : renderBlockingAskResult(result, options, theme, context);
299
+ }
@@ -0,0 +1,10 @@
1
+ import { stripVTControlCharacters } from 'node:util';
2
+
3
+ /** Render/paste safety, never a rewrite of the original stored question. */
4
+ export function readable(value: unknown): string {
5
+ return stripVTControlCharacters(String(value)).replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu, ' ');
6
+ }
7
+ export function replyText(value: string): string { return readable(value); }
8
+ export function pastedReplyText(value: string): string {
9
+ return readable(value.replace(/\r\n/gu, '\n').replace(/\r/gu, '\n').replace(/\t/gu, ' '));
10
+ }
@@ -0,0 +1,309 @@
1
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
+ import { truncateHead } from '@earendil-works/pi-coding-agent';
3
+ import { Type } from 'typebox';
4
+ import type { QuestionAnswer, QuestionGroup, QuestionResult } from './types.ts';
5
+ import { renderPrivateAskCall, renderPrivateAskResult } from './stream.ts';
6
+
7
+ /** TUI presentation owns interaction; rejection means no human cancellation result.
8
+ * Composed hosts carry their originating native activation through final return. */
9
+ export type QuestionPresentation = QuestionResult | Readonly<{ result: QuestionResult; accept: () => void }>;
10
+ export type QuestionPresenter = (
11
+ group: QuestionGroup, ctx: ExtensionContext, signal?: AbortSignal,
12
+ ) => Promise<QuestionPresentation>;
13
+
14
+ // New questions and references share one required collection so an omitted
15
+ // alternative never needs a fabricated placeholder in a generated tool call.
16
+ const authoredQuestion = Type.Object({
17
+ question: Type.String({ minLength: 1, description: 'What the person is being asked.' }),
18
+ header: Type.Optional(Type.String({ maxLength: 16, description: 'Short tab label.' })),
19
+ context: Type.Optional(Type.String({ description: 'Supporting detail shown directly beneath the question, such as a command, path, or comparison background.' })),
20
+ options: Type.Array(Type.Object({
21
+ label: Type.String({ minLength: 1, maxLength: 60 }),
22
+ description: Type.Optional(Type.String({ description: 'A short explanation shown when this suggestion is selected.' })),
23
+ preview: Type.Optional(Type.String({ description: 'Additional content shown when this suggestion is selected.' })),
24
+ }, { additionalProperties: false }), { minItems: 2, maxItems: 4 }),
25
+ multiSelect: Type.Optional(Type.Boolean()),
26
+ }, { additionalProperties: false });
27
+ const pendingQuestion = Type.Object({
28
+ questionId: Type.String({ minLength: 1,
29
+ description: 'Stable pending ID returned by an earlier nonblocking call. Reuses that question instead of asking it again.' }),
30
+ }, { additionalProperties: false });
31
+ const parameters = Type.Object({
32
+ questions: Type.Union([
33
+ Type.Array(authoredQuestion, { minItems: 1, maxItems: 4 }),
34
+ Type.Array(pendingQuestion, { minItems: 1, maxItems: 1 }),
35
+ ], { description: 'New questions, or one pending question reference when returning to an earlier nonblocking question.' }),
36
+ blocking: Type.Optional(Type.Boolean({ default: true,
37
+ description: 'Wait for an answer before continuing. Defaults to true; set false to leave new questions pending while continuing.' })),
38
+ }, { additionalProperties: false,
39
+ description: 'Provide 1–4 new questions, or a single pending question reference. blocking defaults to true.' });
40
+
41
+ function failure(code: string, message: string): Error {
42
+ return Object.assign(new Error(message), { code });
43
+ }
44
+
45
+ function resultText(result: QuestionResult): string {
46
+ const notice = '\n[Result text truncated; full answers are retained in tool result details.]';
47
+ const complete = JSON.stringify(result, null, 2);
48
+ const first = truncateHead(complete, { maxBytes: 50 * 1024 - Buffer.byteLength(notice), maxLines: 1998 });
49
+ if (!first.truncated) return complete;
50
+ // A large preview (or a single huge escaped line) must not erase the decision.
51
+ const short = (text: string | undefined) => text === undefined ? undefined : text.length > 512 ? `${text.slice(0, 512)}…` : text;
52
+ const summary = JSON.stringify({ cancelled: result.cancelled, answers: result.answers.map((answer) => ({
53
+ questionIndex: answer.questionIndex, question: short(answer.question),
54
+ optionIndex: answer.optionIndex, optionIndices: answer.optionIndices,
55
+ selected: answer.selected?.map(short), answer: short(answer.answer), notes: short(answer.notes), wasCustom: answer.wasCustom,
56
+ })) }, null, 2);
57
+ const prefix = `Answer summary (long text shortened; previews omitted):\n${summary}\n\nResult detail:\n`;
58
+ const remaining = truncateHead(complete, {
59
+ maxBytes: 50 * 1024 - Buffer.byteLength(prefix) - Buffer.byteLength(notice),
60
+ maxLines: 2000 - prefix.split('\n').length - notice.split('\n').length,
61
+ });
62
+ return prefix + remaining.content + notice;
63
+ }
64
+
65
+ /** Stop waiting even if a presenter ignores cancellation; always observe its late rejection. */
66
+ function abortable<T>(work: () => Promise<T>, signal: AbortSignal): Promise<T> {
67
+ signal.throwIfAborted();
68
+ return new Promise((resolve, reject) => {
69
+ const aborted = () => reject(signal.reason);
70
+ signal.addEventListener('abort', aborted, { once: true });
71
+ Promise.resolve().then(() => {
72
+ signal.throwIfAborted();
73
+ return work();
74
+ }).then(resolve, reject).finally(() => signal.removeEventListener('abort', aborted));
75
+ });
76
+ }
77
+
78
+ /** SDK dialog fallback: numbered entries preserve identity even for duplicate labels. */
79
+ async function dialogs(group: QuestionGroup, ctx: ExtensionContext, signal: AbortSignal): Promise<QuestionResult> {
80
+ const answers: QuestionAnswer[] = [];
81
+ const select = (title: string, choices: string[]) => abortable(() => ctx.ui.select(title, choices, { signal }), signal);
82
+ const input = (title: string) => abortable(() => ctx.ui.input(title, undefined, { signal }), signal);
83
+ for (const [questionIndex, spec] of group.questions.entries()) {
84
+ const options = spec.options ?? [];
85
+ const checked = new Set<number>();
86
+ let custom = '', notes = '';
87
+ const title = [spec.header, spec.question, spec.context].filter(Boolean).join('\n');
88
+ const currentAnswer = (): QuestionAnswer | undefined => {
89
+ if (!checked.size && !custom) return undefined;
90
+ const indices = [...checked].sort((a, b) => a - b);
91
+ const base = { questionIndex, question: spec.question, ...(notes ? { notes } : {}) };
92
+ if (spec.multiSelect) {
93
+ return { ...base, selected: indices.map((index) => options[index].label), optionIndices: indices,
94
+ previews: indices.map((index) => options[index].preview ?? null),
95
+ ...(custom ? { answer: custom, wasCustom: true } : { wasCustom: false }) };
96
+ }
97
+ if (custom) return { ...base, answer: custom, wasCustom: true };
98
+ const optionIndex = indices[0], option = options[optionIndex];
99
+ return { ...base, answer: option.label, optionIndex, wasCustom: false,
100
+ ...(option.preview !== undefined ? { preview: option.preview } : {}) };
101
+ };
102
+ const cancelled = (): QuestionResult => {
103
+ const current = currentAnswer();
104
+ return { answers: current ? [...answers, current] : answers, cancelled: true };
105
+ };
106
+ while (true) {
107
+ const choices = options.map((option, index) => [
108
+ `${checked.has(index) ? '[x]' : '[ ]'} ${index + 1}. ${option.label}`,
109
+ option.description, option.preview,
110
+ ].filter((part) => part !== undefined).join('\n'));
111
+ const controls = ['Reply: Write or clear custom answer', 'Notes: Write or clear notes',
112
+ 'Next: Keep this answer', 'Skip: Leave this question unanswered', 'Cancel: Cancel this questionnaire'];
113
+ const menu = [...choices, ...controls];
114
+ const choice = await select(`${title}${custom ? `\nCustom answer: ${custom}` : ''}${notes ? `\nNotes: ${notes}` : ''}`, menu);
115
+ signal.throwIfAborted();
116
+ if (choice === undefined) return cancelled();
117
+ const action = menu.indexOf(choice);
118
+ if (action < 0) throw failure('invalid_dialog_response', 'Question dialog returned an unknown choice.');
119
+ if (action < options.length) {
120
+ if (spec.multiSelect) {
121
+ if (checked.has(action)) checked.delete(action); else checked.add(action);
122
+ } else {
123
+ checked.clear(); checked.add(action); custom = '';
124
+ }
125
+ continue;
126
+ }
127
+ switch (action - options.length) {
128
+ case 0: {
129
+ const value = await input(`${title}\nCustom answer (empty clears it)`);
130
+ signal.throwIfAborted();
131
+ if (value !== undefined) custom = value.trim();
132
+ continue;
133
+ }
134
+ case 1: {
135
+ const value = await input(`${title}\nNotes (empty clears them)`);
136
+ signal.throwIfAborted();
137
+ if (value !== undefined) notes = value.trim();
138
+ continue;
139
+ }
140
+ case 2: {
141
+ const answer = currentAnswer();
142
+ if (!answer) continue;
143
+ answers.push(answer);
144
+ break;
145
+ }
146
+ case 3: break;
147
+ case 4: return cancelled();
148
+ }
149
+ break;
150
+ }
151
+ }
152
+ const submit = 'Submit: Return these answers', cancel = 'Cancel: Cancel this questionnaire';
153
+ const decision = await select(`Review: ${answers.length}/${group.questions.length} questions answered\n${JSON.stringify(answers, null, 2)}`, [submit, cancel]);
154
+ signal.throwIfAborted();
155
+ if (decision !== undefined && decision !== submit && decision !== cancel) {
156
+ throw failure('invalid_dialog_response', 'Question review returned an unknown choice.');
157
+ }
158
+ return { answers, cancelled: decision !== submit };
159
+ }
160
+
161
+ export type NonblockingQuestionProducer = (
162
+ toolCallId: string,
163
+ question: Readonly<{ question: string; header?: string; context?: string; options?: readonly Readonly<{ label: string; description?: string; preview?: string }>[]; multiSelect?: boolean }>,
164
+ ctx: ExtensionContext,
165
+ signal?: AbortSignal,
166
+ ) => Promise<Readonly<{ content: readonly unknown[]; details: Record<string, unknown> }>>;
167
+
168
+ export type ExistingQuestionWait = (questionId: string, ctx: ExtensionContext, signal: AbortSignal) => Promise<Readonly<{
169
+ sessionId: string;
170
+ questionId: string;
171
+ answerId?: string;
172
+ status: 'answered' | 'cancelled' | 'already_queued' | 'already_received' | 'already_claimed';
173
+ note?: string;
174
+ acceptWait?: () => void;
175
+ acceptClaim?: () => void;
176
+ releaseClaim?: () => void;
177
+ result: QuestionResult;
178
+ }>>;
179
+
180
+ /** Registers the private question tool; no network, service, or global settings changes. */
181
+ export function registerBlockingQuestions(pi: ExtensionAPI, present: QuestionPresenter, waitExisting?: ExistingQuestionWait,
182
+ captureAdmission?: (ctx: ExtensionContext) => () => void, askNonblocking?: NonblockingQuestionProducer): void {
183
+ const active = new Set<AbortController>();
184
+ let retired = false, replacing = false, changingTree = false, compacting = false;
185
+ const transitioning = () => replacing || changingTree || compacting;
186
+ function detachActive(message: string) {
187
+ for (const controller of active) controller.abort(failure('presentation_detached', message));
188
+ active.clear();
189
+ }
190
+ pi.on('session_shutdown', () => {
191
+ retired = true; replacing = true;
192
+ detachActive('Question presentation detached during session shutdown.');
193
+ });
194
+ for (const event of ['session_before_switch', 'session_before_fork'] as const) pi.on(event, () => {
195
+ replacing = true; detachActive('Question presentation detached during session transition.');
196
+ });
197
+ pi.on('session_before_tree', () => {
198
+ changingTree = true; detachActive('Question presentation detached during session transition.');
199
+ });
200
+ pi.on('session_before_compact', () => {
201
+ compacting = true; detachActive('Question presentation detached during session transition.');
202
+ });
203
+ // Only the matching positive lifecycle event reopens its gate. Stock Pi
204
+ // exposes no cancelled/failed replacement or tree event, so those attempts
205
+ // remain fail-closed until their positive event or a lifecycle rebind.
206
+ pi.on('session_start', () => { retired = false; replacing = changingTree = compacting = false; });
207
+ pi.on('session_tree', () => { changingTree = false; });
208
+ pi.on('session_compact', () => { compacting = false; });
209
+ pi.on('session_compact_failed', () => { compacting = false; });
210
+ pi.registerTool({
211
+ name: 'ask_user_question',
212
+ label: 'Ask Questions',
213
+ description: 'A private questionnaire for decisions and feedback. It ordinarily waits for the person’s answer. A nonblocking question stays open while useful work continues and can become required later without being asked twice. The person sees the question, its context, suggestions, and selected previews together. Supports 1–4 questions, 2–4 suggestions, custom replies, multiple selection, and partial submission. Result text is limited to 2000 lines/50KB; full answers remain in details.',
214
+ promptSnippet: 'Ask private questions; ordinarily waits, or can leave them pending while useful work continues',
215
+ promptGuidelines: ['Blocking is the ordinary question-and-answer experience. Nonblocking questions fit moments when useful work can continue while the person answers.'],
216
+ parameters,
217
+ renderShell: 'self',
218
+ renderCall: renderPrivateAskCall,
219
+ renderResult: renderPrivateAskResult,
220
+ async execute(toolCallId, params, signal, _onUpdate, ctx) {
221
+ if (retired) throw failure('presentation_detached', 'Question producer belongs to a retired session.');
222
+ if (transitioning()) throw failure('presentation_detached', 'Private question admission is closed during an unresolved session transition.');
223
+ const controller = new AbortController();
224
+ const signals = [controller.signal, signal, ctx.signal].filter((value): value is AbortSignal => value !== undefined);
225
+ const lifetime = AbortSignal.any(signals);
226
+ lifetime.throwIfAborted();
227
+ if (!ctx.hasUI || ctx.mode === 'print' || ctx.mode === 'json') {
228
+ throw failure('unsupported_host', 'ask_user_question needs an interactive TUI or SDK dialog host; no question was presented and no human declined.');
229
+ }
230
+ const requests = Array.isArray(params.questions) ? params.questions : [];
231
+ const references = requests.filter((request): request is { questionId: string } => 'questionId' in request);
232
+ const reference = references[0], authored = reference ? undefined : requests;
233
+ const blocking = params.blocking !== false;
234
+ if (!requests.length || references.length > 1 || (reference && requests.length !== 1)) {
235
+ throw failure('invalid_arguments', 'Provide 1–4 new questions, or one existing pending question reference.');
236
+ }
237
+ if (reference && !blocking) {
238
+ throw failure('invalid_arguments', 'A pending question reference is already nonblocking; wait for it with blocking=true.');
239
+ }
240
+ if (!blocking && !askNonblocking) throw failure('unsupported_host', 'This question producer does not support nonblocking questions.');
241
+ active.add(controller);
242
+ try {
243
+ if (!blocking) {
244
+ const results = [];
245
+ for (const [index, spec] of authored!.entries()) {
246
+ results.push(await askNonblocking!(`${toolCallId}:${index}`, {
247
+ question: spec.question,
248
+ ...(spec.header !== undefined ? { header: spec.header } : {}),
249
+ ...(spec.context !== undefined ? { context: spec.context } : {}),
250
+ options: spec.options.map((option) => ({ label: option.label,
251
+ ...(option.description !== undefined ? { description: option.description } : {}),
252
+ ...(option.preview !== undefined ? { preview: option.preview } : {}) })),
253
+ ...(spec.multiSelect !== undefined ? { multiSelect: spec.multiSelect } : {}),
254
+ }, ctx, lifetime));
255
+ lifetime.throwIfAborted();
256
+ }
257
+ if (results.length === 1) return results[0];
258
+ const questions = results.map((item) => item.details);
259
+ const accepted = questions.every((item) => item.status === 'pending' || item.status === 'answered');
260
+ const value = { status: !accepted ? 'partial' : questions.every((item) => item.status === 'answered') ? 'answered' : 'pending', questions };
261
+ return { content: [{ type: 'text', text: JSON.stringify(value) }], details: value };
262
+ }
263
+ if (reference) {
264
+ if (ctx.mode !== 'tui' || !waitExisting) throw failure('unsupported_host', 'Waiting on an existing private nonblocking question needs the interactive TUI host that owns it.');
265
+ let waited: Awaited<ReturnType<ExistingQuestionWait>> | undefined;
266
+ try {
267
+ // The native wait already owns lifetime cancellation. Keeping its
268
+ // resolved value visible here lets a final abort release a claim
269
+ // before any answer-bearing tool result exists.
270
+ waited = await waitExisting(reference.questionId, ctx, lifetime);
271
+ lifetime.throwIfAborted();
272
+ // Every native outcome is activation-bound; answer-bearing outcomes
273
+ // additionally arbitrate the one delivery winner.
274
+ waited.acceptWait?.();
275
+ waited.acceptClaim?.();
276
+ const text = waited.note
277
+ ? JSON.stringify({ status: waited.status, questionId: waited.questionId, note: waited.note })
278
+ : waited.status === 'cancelled'
279
+ ? JSON.stringify({ status: waited.status, questionId: waited.questionId, ...waited.result,
280
+ note: 'Blocking wait cancelled. The original nonblocking question remains pending.' })
281
+ : resultText(waited.result);
282
+ return { content: [{ type: 'text', text }], details: {
283
+ groupId: `native:${waited.questionId}`, sessionId: waited.sessionId, questionId: waited.questionId,
284
+ waitStatus: waited.status, ...(waited.note ? { waitNote: waited.note } : {}),
285
+ ...(waited.status === 'answered' && waited.answerId ? { receivedNativeAnswerIds: [waited.answerId] } : {}), ...waited.result,
286
+ } };
287
+ } catch (error) { waited?.releaseClaim?.(); throw error; }
288
+ }
289
+ // TUI composition returns its fence with the presenter outcome; SDK
290
+ // dialogs need it captured here before their first awaited interaction.
291
+ const acceptCompletion = ctx.mode === 'tui' ? undefined : captureAdmission?.(ctx);
292
+ const group: QuestionGroup = Object.freeze({
293
+ id: `blocking:${toolCallId}`, mode: 'blocking',
294
+ questions: Object.freeze(authored!.map((spec, index) => Object.freeze({
295
+ ...spec, id: `question:${index}`,
296
+ options: Object.freeze(spec.options.map((option) => Object.freeze({ ...option }))),
297
+ }))),
298
+ });
299
+ const presented = await abortable(() => ctx.mode === 'tui' ? present(group, ctx, lifetime) : dialogs(group, ctx, lifetime), lifetime);
300
+ lifetime.throwIfAborted();
301
+ const result = 'result' in presented ? presented.result : presented;
302
+ if ('result' in presented) presented.accept(); else acceptCompletion?.();
303
+ return { content: [{ type: 'text', text: resultText(result) }], details: { groupId: group.id, ...result } };
304
+ } finally {
305
+ active.delete(controller);
306
+ }
307
+ },
308
+ });
309
+ }