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,25 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+
3
+ /** Public SDK session-file protocol, not manager internals. Memory-only hosts
4
+ * have no disk assertion. A persisted host's branch row alone cannot prove a
5
+ * receipt: SDK can expose it before a failed write. Cache identities only. */
6
+ export function createReceiptJournal() {
7
+ let cached: { file: string; stamp: string; ids: Set<string> } | undefined;
8
+ return (manager: { getSessionFile?(): string | undefined }): ReadonlySet<string> | undefined => {
9
+ const file = manager.getSessionFile?.(); if (!file) return undefined;
10
+ try {
11
+ const stat = statSync(file), stamp = `${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
12
+ if (cached?.file === file && cached.stamp === stamp) return cached.ids;
13
+ const ids = new Set<string>();
14
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
15
+ try {
16
+ const entry = JSON.parse(line);
17
+ const nativeFeedback = entry.type === 'custom_message';
18
+ const blockingResult = entry.type === 'message' && entry.message?.role === 'toolResult' && entry.message.toolName === 'ask_user_question';
19
+ if ((nativeFeedback || blockingResult) && typeof entry.id === 'string') ids.add(entry.id);
20
+ } catch {} // A torn line is not a receipt.
21
+ }
22
+ cached = { file, stamp, ids }; return ids;
23
+ } catch { cached = undefined; return new Set<string>(); }
24
+ };
25
+ }
@@ -0,0 +1,167 @@
1
+ import { Input, matchesKey, wrapTextWithAnsi, truncateToWidth, visibleWidth, Text } from '@earendil-works/pi-tui';
2
+ import { stripVTControlCharacters } from 'node:util';
3
+
4
+ // Stored text is data, never terminal instructions (including OSC links and bidi).
5
+ export function plain(value: unknown): string {
6
+ return stripVTControlCharacters(String(value ?? ''))
7
+ .replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu, ' ');
8
+ }
9
+ function singleLine(value: unknown) { return plain(value).replace(/\n/g, ' '); }
10
+ export function card(text: string) { return new Text(plain(text), 0, 0); }
11
+ export type Feedback = { id: string; text: string; optionIndex?: number };
12
+
13
+ /** Lives in Pi's normal input area while the agent continues its turn. */
14
+ export class AskPanel {
15
+ private input!: Input;
16
+ private question: any;
17
+ private option = 0;
18
+ private writing = false;
19
+ private scroll = 0;
20
+ private pageSize = 1;
21
+ private closed = false;
22
+ private suspended = false;
23
+ beforeRender?: () => void;
24
+ onRetire?: () => void;
25
+ retiredInput?: (data: string) => void;
26
+ private drafts = new Map<string, { input: Input; writing: boolean; option: number; scroll: number }>();
27
+ private _focused = false;
28
+ get focused() { return this._focused; }
29
+ set focused(value: boolean) { this._focused = value; this.input.focused = value && this.writing; }
30
+
31
+ constructor(private items: any[], private tui: any, private theme: any,
32
+ private done: (value: Feedback | undefined) => void, initialId?: string) {
33
+ this.show(items.find((item) => item.id === initialId) || items[0]);
34
+ }
35
+ suspend(value: boolean) { if (value !== this.suspended) { this.suspended = value; this.tui.requestRender(); } }
36
+ private submit(value: Feedback) { if (!this.closed) this.done(value); }
37
+ close(value?: Feedback) { if (!this.closed) { this.closed = true; this.onRetire?.(); this.done(value); } }
38
+ private show(item: any) {
39
+ if (this.input) this.input.focused = false;
40
+ this.question = item;
41
+ const draft = this.drafts.get(item?.id);
42
+ this.input = draft?.input ?? new Input({ prompt: '> ', placeholder: 'Your response' });
43
+ this.input.onSubmit = (text) => {
44
+ if (this.question && text.trim()) this.submit({ id: this.question.id, text });
45
+ };
46
+ this.option = draft?.option ?? 0; this.scroll = draft?.scroll ?? 0;
47
+ this.writing = draft?.writing ?? !item?.prompt.options?.length;
48
+ this.focused = this._focused;
49
+ }
50
+ update(items: any[]) {
51
+ this.items = items;
52
+ for (const id of this.drafts.keys()) if (!items.some((item) => item.id === id)) this.drafts.delete(id);
53
+ // Another async ask joins the panel without replacing the current answer draft.
54
+ if (!items.some((item) => item.id === this.question?.id)) this.show(items[0]);
55
+ if (!items.length) this.close();
56
+ this.tui.requestRender();
57
+ }
58
+ private next(direction = 1) {
59
+ this.drafts.set(this.question.id, { input: this.input, writing: this.writing, option: this.option, scroll: this.scroll });
60
+ const index = this.items.findIndex((item) => item.id === this.question.id);
61
+ this.show(this.items[(index + direction + this.items.length) % this.items.length]);
62
+ }
63
+ private write(value = '') {
64
+ this.writing = true;
65
+ // Browsing choices is not permission to replace an existing written draft.
66
+ if (!this.input.getValue()) this.input.setValue(value);
67
+ this.focused = this._focused;
68
+ }
69
+ handleInput(data: string) {
70
+ if (this.closed) { this.retiredInput?.(data); return; }
71
+ if (!this.question) return;
72
+ const options = this.question.prompt.options || [];
73
+ if (matchesKey(data, 'escape')) { this.close(); return; }
74
+ if (matchesKey(data, 'shift+tab') && this.items.length > 1) this.next();
75
+ else if (matchesKey(data, 'pageUp')) this.scroll = Math.max(0, this.scroll - this.pageSize);
76
+ else if (matchesKey(data, 'pageDown')) this.scroll += this.pageSize;
77
+ else if (!this.writing && (matchesKey(data, 'left') || matchesKey(data, 'right'))) {
78
+ const direction = matchesKey(data, 'left') ? -1 : 1;
79
+ this.next(direction);
80
+ } else if (!this.writing && (matchesKey(data, 'up') || matchesKey(data, 'down'))) {
81
+ const direction = matchesKey(data, 'up') ? -1 : 1;
82
+ this.option = (this.option + direction + options.length + 1) % (options.length + 1);
83
+ this.scroll = 0;
84
+ } else if (!this.writing && matchesKey(data, 'enter')) {
85
+ if (this.option === options.length) this.write();
86
+ else this.submit({ id: this.question.id, text: singleLine(options[this.option].label), optionIndex: this.option });
87
+ } else if (matchesKey(data, 'tab') && options.length) {
88
+ if (this.writing) { this.writing = false; this.focused = this._focused; }
89
+ else this.write(this.option < options.length ? singleLine(options[this.option].label) : '');
90
+ } else {
91
+ // Let Pi Input decode ordinary text, bracketed paste and Kitty printable
92
+ // keys. Escape-prefixed input is not necessarily a navigation key.
93
+ this.input.handleInput(data);
94
+ const value = this.input.getValue(), safe = singleLine(value);
95
+ if (safe !== value) this.input.setValue(safe);
96
+ const writing = options.length ? safe.length > 0 : true;
97
+ if (writing !== this.writing) { this.writing = writing; this.focused = this._focused; }
98
+ }
99
+ this.tui.requestRender();
100
+ }
101
+ render(width: number): string[] {
102
+ this.beforeRender?.();
103
+ width = Math.max(1, width);
104
+ const line = (text: string) => truncateToWidth(singleLine(text), width);
105
+ const border = this.theme.fg('accent', '─'.repeat(width));
106
+ if (!this.question) return [];
107
+ const { prompt } = this.question;
108
+ if (this.suspended) return [this.theme.fg('accent', line('Private question · waiting for the current Pi prompt')),
109
+ ...wrapTextWithAnsi(plain(prompt.question), width).slice(0, 3)];
110
+ const options = prompt.options || [];
111
+ const title = `Private question${this.items.length > 1 ? ` ${this.items.findIndex((item) => item.id === this.question.id) + 1}/${this.items.length} · Shift+Tab questions` : ''} · reply here`;
112
+ const selected = !this.writing && options[this.option];
113
+ const details = [prompt.header, prompt.question, prompt.context,
114
+ selected && visibleWidth(singleLine(`› ${this.option + 1}. ${selected.label}`)) > width ? `Selected: ${selected.label}` : '',
115
+ selected?.description ? `Meaning: ${selected.description}` : '',
116
+ selected?.preview ? `Preview: ${selected.preview}` : ''].filter(Boolean).join('\n\n');
117
+ const body = wrapTextWithAnsi(plain(details), width);
118
+ const input = this.writing ? this.input.render(width) : [];
119
+ // Budget the complete widget, leaving space for Pi's editor, status/footer
120
+ // and chat. Every detail remains reachable, even in a short terminal.
121
+ const budget = Math.max(4, Math.min(20, (this.tui.terminal?.rows || 30) - 12));
122
+ const hint = this.writing
123
+ ? 'Enter save reply' + (options.length ? ' · Tab choices' : '') + ' · Esc pause'
124
+ : '↑/↓ choose · Enter save choice · Tab edit · Or type a reply · Esc pause';
125
+ const activeOption = this.writing ? options.length : this.option;
126
+ const choiceLine = (i: number) => this.theme.fg(i === activeOption ? 'accent' : 'text',
127
+ line(`${i === activeOption ? '›' : ' '} ${i + 1}. ${i === options.length ? 'Type something.' : options[i].label}`));
128
+ if (budget <= 6) {
129
+ this.pageSize = 1;
130
+ this.scroll = Math.min(this.scroll, Math.max(0, body.length - 1));
131
+ const count = options.length ? Math.min(options.length + 1, Math.max(1, budget - 3 - input.length)) : 0;
132
+ const start = Math.max(0, Math.min(activeOption - Math.floor(count / 2), options.length + 1 - count));
133
+ return [this.theme.fg('accent', line(title)), body[this.scroll],
134
+ ...Array.from({ length: count }, (_, i) => choiceLine(start + i)), ...input,
135
+ this.theme.fg('dim', line('PgUp/PgDn details · ' + hint))].slice(0, budget);
136
+ }
137
+ const room = budget - 4 - input.length;
138
+ const count = options.length ? Math.min(6, options.length + 1, Math.max(1, Math.floor(room / 2))) : 0;
139
+ const height = Math.max(1, room - count - 1); // reserve the scroll hint too
140
+ this.pageSize = height;
141
+ this.scroll = Math.min(this.scroll, Math.max(0, body.length - height));
142
+ const rows = [border, this.theme.fg('accent', line(title)), ...body.slice(this.scroll, this.scroll + height)];
143
+ if (body.length > height) rows.push(this.theme.fg('dim', line(`PgUp/PgDn · details ${this.scroll + 1}–${Math.min(body.length, this.scroll + height)}/${body.length}`)));
144
+ if (count) {
145
+ const start = Math.max(0, Math.min(activeOption - Math.floor(count / 2), options.length + 1 - count));
146
+ for (let i = start; i < start + count; i++) rows.push(choiceLine(i));
147
+ }
148
+ rows.push(...input, this.theme.fg('dim', line(hint)), border);
149
+ return rows;
150
+ }
151
+ invalidate() { this.input.invalidate(); }
152
+ dispose() { this.closed = true; this.onRetire?.(); }
153
+ }
154
+
155
+ /** Escape restores the chat editor, but does not make the pending ask disappear. */
156
+ export function pendingCard(items: any[], tui: any, theme: any) {
157
+ return {
158
+ invalidate() {},
159
+ render(width: number) {
160
+ width = Math.max(1, width);
161
+ const rows = [theme.fg('accent', truncateToWidth(`Private questions paused · ${items.length} pending · /asks to reply`, width))];
162
+ const budget = Math.max(2, Math.min(6, Math.floor((tui.terminal?.rows || 30) / 4)));
163
+ for (const item of items) rows.push(...wrapTextWithAnsi(plain(item.prompt.question), width));
164
+ return rows.slice(0, budget);
165
+ },
166
+ };
167
+ }
@@ -0,0 +1,185 @@
1
+ import { Text, hyperlink, getCapabilities } from '@earendil-works/pi-tui';
2
+ import type { Theme } from '@earendil-works/pi-coding-agent';
3
+
4
+ // Saved discussions are text, not terminal instructions or Markdown. Keep the
5
+ // original records in the tool/message payload; only this view is abbreviated.
6
+ function literal(value: unknown, multiline = false): string {
7
+ const text = typeof value === 'string' ? value : value === undefined ? '' : JSON.stringify(value);
8
+ return (text || '').replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/g, (char) => {
9
+ if (char === '\n') return multiline ? '\n' : ' ';
10
+ if (char === '\t') return ' ';
11
+ return `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`;
12
+ });
13
+ }
14
+ function preview(value: unknown, expanded: boolean, multiline = false): string {
15
+ const text = literal(value, multiline);
16
+ const limit = expanded ? 4000 : 240;
17
+ return text.length > limit ? `${text.slice(0, limit)}… [more at the discussion URL]` : text;
18
+ }
19
+ function link(value: unknown): string {
20
+ if (typeof value !== 'string' || /[\s\u0000-\u001f\u007f-\u009f]/u.test(value)) return '';
21
+ try {
22
+ const url = new URL(value);
23
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return '';
24
+ // Only our validated HTTP(S) address becomes a terminal hyperlink. Saved
25
+ // labels and body text never supply escape sequences or hidden targets.
26
+ return getCapabilities().hyperlinks ? hyperlink(url.href, url.href) : url.href;
27
+ } catch { return ''; }
28
+ }
29
+ function block(theme: Theme, heading: string, lines: string[], pad = 0) {
30
+ return new Text([theme.fg('toolTitle', theme.bold(heading)), ...lines.filter(Boolean)].join('\n'), pad, 0);
31
+ }
32
+ function author(node: any, expanded: boolean) {
33
+ if (!node?.author) return '';
34
+ // A name, role, session ID, or arbitrary label is provenance, not approval.
35
+ const label = typeof node.author === 'object' ? node.author.name ?? node.author.id ?? node.author : node.author;
36
+ return `Author label: ${preview(expanded ? node.author : label, expanded)} (caller-provided; not authenticated approval)`;
37
+ }
38
+ function record(node: any, expanded: boolean, url?: unknown): string[] {
39
+ if (!node) return [];
40
+ return [
41
+ preview(node.title || 'Untitled discussion', expanded),
42
+ link(url || node.url),
43
+ expanded ? `Node: ${literal(node.id)}${node.status ? ` · Status: ${literal(node.status)}` : ''}` : node.status ? `Status: ${literal(node.status)}` : '',
44
+ node.body ? preview(node.body, expanded, expanded) : '',
45
+ node.presentation ? `Presentation: ${literal(node.presentation.kind)}${expanded && node.presentation.revision ? ` · Revision: ${literal(node.presentation.revision)}` : ''} (open the discussion)` : '',
46
+ node.presentation?.fallback ? preview(node.presentation.fallback, expanded, expanded) : '',
47
+ expanded ? author(node, true) : '',
48
+ ];
49
+ }
50
+ function response(node: any, expanded: boolean): string[] {
51
+ if (!node) return [];
52
+ return [
53
+ `Reply intent: ${literal(node.response?.kind || 'contribution')}`,
54
+ node.body ? preview(node.body, expanded, expanded) : preview(node.title, expanded),
55
+ node.response?.selections !== undefined ? `Selections: ${preview(node.response.selections, expanded)}` : '',
56
+ author(node, expanded),
57
+ link(node.url),
58
+ expanded ? `Reply node: ${literal(node.id)}` : '',
59
+ expanded && node.response?.targetId ? `In reply to: ${literal(node.response.targetId)}` : '',
60
+ expanded && node.response?.presentationRevision ? `Responded to revision: ${literal(node.response.presentationRevision)}` : '',
61
+ node.presentation ? `Reply presentation: ${literal(node.presentation.kind)} (open the reply)` : '',
62
+ node.presentation?.fallback ? preview(node.presentation.fallback, expanded, expanded) : '',
63
+ ];
64
+ }
65
+ const actions: Record<string, string> = {
66
+ read: 'Read discussion', browse: 'Discussion outline', publish: 'Contribution saved',
67
+ reply: 'Reply saved', watch: 'Watching discussion', unwatch: 'Stopped watching in this session', wait: 'Wait finished',
68
+ };
69
+ const outcomes: Record<string, string> = {
70
+ timeout: 'No reply before the wait ended; the discussion remains saved and watched.',
71
+ cancelled: 'Wait cancelled; shared history is unchanged.',
72
+ unavailable: 'Could not establish the wait outcome; shared history is unchanged.',
73
+ answer: 'Saved reply received · intent: answer', clarification: 'Saved reply received · intent: clarification',
74
+ defer: 'Saved reply received · intent: defer', reject: 'Saved reply received · intent: reject',
75
+ };
76
+
77
+ export function renderAskCall(args: any, theme: Theme, context?: any) {
78
+ return call(args, theme, !!context?.expanded, true);
79
+ }
80
+ export function renderDiscussionCall(args: any, theme: Theme, context?: any) {
81
+ return call(args, theme, !!context?.expanded, false);
82
+ }
83
+ function call(args: any, theme: Theme, expanded: boolean, ask: boolean) {
84
+ const input = ask ? args : args.input || {};
85
+ const presentation = input.presentation;
86
+ const authored = input.html !== undefined || presentation?.html !== undefined || presentation?.kind === 'html-v1';
87
+ const scope = input.path || input.project || input.parentId;
88
+ return block(theme, ask ? 'Ask in Threadroom' : `Threadroom · ${literal(args.action || 'discussion')}`, [
89
+ preview(input.question || input.title || args.id || args.query || '', expanded),
90
+ scope ? `Within: ${preview(scope, expanded)}` : '',
91
+ authored ? 'Authored interaction · source stays out of the terminal' : '',
92
+ (input.fallback || presentation?.fallback) ? preview(input.fallback || presentation.fallback, expanded, expanded) : '',
93
+ expanded && input.body ? preview(input.body, true, true) : '',
94
+ args.waitMs !== undefined ? `Wait: up to ${literal(args.waitMs)} ms; ending the wait does not withdraw saved work` : '',
95
+ ]);
96
+ }
97
+
98
+ export function renderToolResult(result: any, options: any, theme: Theme, context?: any) {
99
+ const expanded = !!options.expanded;
100
+ if (options.isPartial) return block(theme, 'Threadroom', ['Working…']);
101
+ const value = result.details;
102
+ if (!value || typeof value !== 'object') {
103
+ const text = (result.content || []).filter((part: any) => part.type === 'text').map((part: any) => part.text).join('\n');
104
+ return block(theme, 'Threadroom', [preview(text || 'No result available.', expanded, expanded)]);
105
+ }
106
+ const action = context?.args?.action || 'publish';
107
+ const lines: string[] = [];
108
+ let heading = actions[action] || 'Threadroom';
109
+ if (value.error && !value.outcome) {
110
+ heading = 'Threadroom · not confirmed';
111
+ lines.push(preview(value.error, expanded, expanded), literal(value.publication),
112
+ value.retryKey ? `Retry key: ${literal(value.retryKey)}` : '', ...endpointLines(context?.endpoints));
113
+ } else if (Array.isArray(value.nodes)) {
114
+ heading = 'Threadroom · discussion outline';
115
+ const shown = expanded ? value.nodes : value.nodes.slice(0, 5);
116
+ for (const node of shown) lines.push(...record(node, false), expanded ? `Node: ${literal(node.id)}` : '');
117
+ const omitted = (value.omitted || 0) + value.nodes.length - shown.length;
118
+ if (omitted) lines.push(`${omitted} more discussions${expanded ? '; narrow the browse query to see them' : '; expand for more'}.`);
119
+ if (!value.nodes.length) lines.push('No matching discussions.');
120
+ } else {
121
+ if (value.outcome) {
122
+ heading = 'Threadroom · wait finished';
123
+ lines.push(outcomes[value.outcome] || `Wait outcome: ${literal(value.outcome)}`);
124
+ if (value.error) lines.push(preview(value.error, expanded), ...endpointLines(context?.endpoints));
125
+ }
126
+ if (value.deduplicated) lines.push('Already saved; this retry did not publish a duplicate.');
127
+ if (value.node?.response && action === 'reply') {
128
+ if (value.target) lines.push(`In discussion: ${preview(value.target.title, expanded)}`, link(value.target.url));
129
+ lines.push(...response(value.node, expanded));
130
+ } else {
131
+ lines.push(...record(value.node, expanded, value.url));
132
+ if (!value.node) lines.push(value.id ? `Node: ${literal(value.id)}` : '', link(value.url));
133
+ if (value.response) lines.push(...response(value.response, expanded));
134
+ else if (value.node?.response) lines.push(...response(value.node, expanded));
135
+ if (expanded && value.ancestors?.length) lines.push(`Within: ${value.ancestors.map((node: any) => literal(node.title)).join(' / ')}`);
136
+ const otherChildren = value.children?.filter((child: any) => child.id !== value.response?.id) || [];
137
+ if (otherChildren.length) {
138
+ const children = expanded ? otherChildren : otherChildren.slice(-2);
139
+ lines.push('Saved contributions:');
140
+ for (const child of children) lines.push(...(child.response ? response(child, expanded) : record(child, expanded)));
141
+ const omitted = (value.omittedChildren || 0) + otherChildren.length - children.length;
142
+ if (omitted) lines.push(`${omitted} other contributions; open the discussion for full history.`);
143
+ }
144
+ }
145
+ if (value.note) lines.push(preview(value.note, expanded));
146
+ }
147
+ if (expanded) {
148
+ if (value.receivedResponseIds?.length) lines.push(`Receipt IDs: ${literal(value.receivedResponseIds)}`);
149
+ if (value.retryKey && !value.error) lines.push(`Retry key: ${literal(value.retryKey)}`);
150
+ if (value.contextSnapshot) lines.push(`Snapshot: ${literal(value.contextSnapshot)}`);
151
+ lines.push(...participationLines(value.participation));
152
+ }
153
+ return block(theme, heading, lines);
154
+ }
155
+
156
+ export function renderFeedback(message: any, options: any, theme: Theme) {
157
+ const value = message.details;
158
+ const expanded = !!options.expanded;
159
+ if (!value?.response?.node) return block(theme, 'Saved Threadroom feedback', [
160
+ preview(typeof message.content === 'string' ? message.content : 'Saved feedback; no readable record available.', expanded, expanded),
161
+ ], options.outputPad ?? 0);
162
+ const lines = [
163
+ `Discussion: ${preview(value.target?.node?.title || 'Untitled discussion', expanded)}`,
164
+ link(value.target?.url || value.target?.node?.url),
165
+ ...response(value.response.node, expanded),
166
+ ];
167
+ if (expanded) lines.push(`Receipt IDs: ${literal(value.receivedResponseIds)}`,
168
+ `Delivery event: ${literal(value.delivery?.eventId)} · sequence: ${literal(value.delivery?.sequence)}`);
169
+ return block(theme, 'Saved Threadroom feedback', lines, options.outputPad ?? 0);
170
+ }
171
+
172
+ function participationLines(state: any): string[] {
173
+ if (!state) return [];
174
+ return [`Connection: ${literal(state.connection)}`, `Watching: ${literal(state.watching || [])}`,
175
+ state.otherWatches ? `${literal(state.otherWatches)} other watches` : '',
176
+ state.unconfirmedDeliveries ? `${literal(state.unconfirmedDeliveries)} deliveries awaiting transcript receipt` : ''];
177
+ }
178
+ function endpointLines(endpoints: any): string[] {
179
+ if (!endpoints) return [];
180
+ // Notifications are plain text too, not an ANSI or Markdown surface.
181
+ return [`API: ${literal(endpoints.apiUrl)}`, `Website: ${literal(endpoints.uiUrl)}`];
182
+ }
183
+ export function participationNotice(state: any, endpoints?: any): string {
184
+ return ['Threadroom', ...endpointLines(endpoints), ...participationLines(state)].filter(Boolean).join('\n');
185
+ }
@@ -0,0 +1,41 @@
1
+ # Private questions in Pi
2
+
3
+ A question can need an answer now, or leave the AI free to keep working. This owned SDK-based surface keeps both in the same flat input-area tabs while preserving those execution contracts. Async arrivals stay passive. A blocking question selects and focuses the shared surface; while any blocker remains, Chat cannot reclaim input, but async tabs remain available alongside the required questions. Suggestions stay visible during writing, and clearing the reply restores choice selection.
4
+
5
+ This is a fresh implementation. The existing questionnaire informed the experience, but is neither a dependency nor implementation source. Copied experiments under `.pi/` are historical evidence, not this module.
6
+
7
+ ## Explicit composition
8
+
9
+ `registerPrivateQuestions(pi)` registers one `ask_user_question` producer once. Blocking is the default; `blocking: false` uses the same authored shape without waiting. It does not install resources or connect to Threadroom. The package MAIN uses this composition; loading it alongside another producer with the same name would be duplicate wiring. Preparing that entry point does not reload or change the resources of a running Pi session.
10
+
11
+ Ordinary prompts and saved answers remain in the original Pi session/branch. Threadroom's optional shared discussion tools are a separate lane. A nonblocking result includes stable question IDs; passing one back as the sole `{ questionId }` entry in `questions` leases requiredness to the same pending cell instead of creating or publishing another question. The native source still owns saving. Escape releases the wait while preserving the async question and draft.
12
+
13
+ In the conversation stream, cyan question cards and violet reply cards use authored titles and identity-based references to connect each exchange. Literal role labels remain readable without color. Operational echoes stay compact; expanding reveals full literal text and structured reply details. “Private” describes the Pi-local audience, not encryption, retention or delivery confirmation; warning/error colors describe actual reported failures rather than the asking or answering role.
14
+
15
+ The public `index.ts` also exposes the model, view, inline host, blocking producer and domain types for independent consumers. The host accepts question groups: blocking callers await their own outcome, while an async source owns saving through its commit callback. Completion of that callback means persistence, not AI consumption. Question/option indices retain authored identity even when labels duplicate or tabs reorder.
16
+
17
+ ## Interaction and ownership
18
+
19
+ The question pane has a solid border while it owns input focus and a dotted border while an async-only pane is inactive; a required blocker uses a solid warning border and explicit marker. Its header contains question/review tabs. Tab moves forward through required and async tabs and wraps, so a person can answer ambient questions without leaving the blocking surface. Up/Down retain their choice/text behavior.
20
+
21
+ Normal Pi needs no SDK patch. Async arrivals appear passively above the editor. Shift+Tab or `/asks` explicitly enters an async-only pane, retaining the exact previous input only as a loan origin—not proof that it is Chat. A blocker takes default selection and claims Chat focus even on stock Pi. Until all blockers resolve or cancel, Shift+Tab and collapse are contained in the shared question surface; pausing an async tab returns to a required tab. Successfully answering the last blocker keeps the pane on its prior async tab, or another remaining async tab, and resumes it if needed; Chat does not reappear between those questions. Cancellation or detachment restores the exact input the blocker claimed, while a blocker that began in an already-owned pane falls back to another visible unpaused async tab when available. Losing or replacing an origin never authorizes a guessed successor, but actually reclaiming an authoritative replacement or explicit `/asks` entry establishes a fresh loan. No editor factory, draft setter, class check or text probe discovers ownership.
22
+
23
+ Chat keeps native Tab completion for an async-only pane. While async questions are open, Shift+Tab is a global pane toggle; known SDK dialog spans suppress it, and public core-editor identity prevents a blocker from stealing an unannounced foreign prompt. On stock hosts, the exact input captured by an established modal loan provides the same protection; only the initial unannounced input boundary is ambiguous, and explicit `/asks` can deliberately rebind a lost loan. `focusToggleKey` configures or disables the async toggle; plain Tab cannot be used. `/asks` also works without the public raw terminal-input hook. Missing return capabilities omit unsupported hints. With no questions, native keys remain unchanged. Ctrl+] is the separate optional async collapse/reentry shortcut; the former Ctrl+Tab routes and Chat/Questions heading are retired.
24
+
25
+ Optional public `ui.getCoreEditor()` identity distinguishes Chat from unannounced foreign prompts and recognizes the current core editor for a new focus loan. A blocker preserves its exact return until that origin is lost and an authoritative replacement is actually reclaimed. Async-only focus remains explicit. SDK dialog notifications coordinate ordinary prompts but do not identify arbitrary focused components.
26
+
27
+ Partial blocking submission retains live checks and notes; cancel affects only that group. Notes use Alt+N where supported, not printable `n`. Our native async source does not offer notes because its persisted answer contract is reply text and optional chosen-option identity. A promoted native question keeps those original limits rather than acquiring questionnaire notes, multi-select, or narrower authoring bounds.
28
+
29
+ Escape pauses an async question without declining it; when a blocker is pending, focus returns to a required tab instead of Chat. `/asks` selects pending questions, while automatic async bind/navigation/recovery reveal remains passive. All new private admission and unsettled result handoffs close at the first switch/fork/tree/compaction boundary. Positive lifecycle events reopen them; stock Pi reports no cancelled/failed switch, fork, or tree event, so that outcome requires `/reload` after the transition command returns and then `/asks`—idle time is never guessed to mean completion. Collapsing an async-only pane returns to the mounted loan origin or authoritative core and preserves drafts. It requires the public terminal-input hook for reopening; hosts without it keep the pane expanded and can still use `/asks` or pause it. Configured SDK external-editor actions edit the original field through a private temporary file, restore the TUI and sanitize the replacement. UI detachment, shutdown and tool abort are not human Cancel.
30
+
31
+ Draft editor state belongs to a question incarnation, so reordering retains caret/undo/notes while a new request reusing IDs cannot inherit them. If an unannounced overlay outlives blocker completion, the exact modal return stays deferred until the pane regains focus; its first input is handed to that return target rather than an async draft. A retired pane transfers this debt to a newer question owner before handling stale overlay focus. A blocker claims the initial stock input, then recognizes only that retained origin unless explicit entry establishes a fresh loan.
32
+
33
+ The presentation port separates pending projection from explicit reveal/focus intent. Terminal widgets do not own storage, results or provider consumption. A future attached FlightDeck UI can replace terminal presentation without redefining those authorities; attachment switching is not implemented here.
34
+
35
+ ## Evidence boundary
36
+
37
+ Saved/pending truth is independent of UI health. A failed projection or reveal does not undo persistence; a queued send is not a receipt. A saved promoted answer is reserved before projection removes its tab, so one answer is not simultaneously returned by the wait and queued as new async feedback. Its answer-bearing handoff—and a fresh blocking group’s completion—must be accepted by the originating activation immediately before the tool result returns; a session boundary in the settle-to-continuation gap detaches that result instead. If the async route already queued or received it, the later wait reports that state without returning a second answer. Blocking-result receipts carry the exact session/question/answer identity and receive the same physical-journal check as feedback receipts. Real sessions, controlled callbacks, synthetic SDK/TUI tests, physical input, provider consumption, post-exit disk receipts and human acceptance are different evidence.
38
+
39
+ The SDK can mutate its branch before a failed disk write. Private append failures that changed that branch are therefore **storage unconfirmed**, not answers we can deliver. The backend excludes failed entry identities and blocks further private writes/delivery on that manager/session, including after `/reload`. Persisted feedback receipts also require a complete row in the public SDK session file; memory-only hosts make no disk assertion. A refusal before branch mutation remains retryable. This is fail-closed handling, not SDK journal rollback or repair: recover the original journal before continuing, and preserve/copy the retained draft before any process replacement.
40
+
41
+ Earlier signed checkpoints passed independent source and extracted-package physical checks for normal saving, external editing, priority, partial review, reload and bounded original-session recovery. Those results do not validate this in-progress focus/SDK-identity refinement. Storage uncertainty, draft retention and before-startup transcript rendering were checked separately through the real SDK and actual filesystem failures; normal-path physical runs did not inject disk failures. No SDK journal repair, hard-crash durability, exactly-once AI action, human acceptance or production activation is claimed here.
@@ -0,0 +1,82 @@
1
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
+ import { registerNativeAsks } from '../native/index.ts';
3
+ import type { NativeQuestionPresentation } from '../native/presentation.ts';
4
+ import { createQuestionHost, supportsQuestionHost } from './host.ts';
5
+ import { registerBlockingQuestions } from './tool.ts';
6
+
7
+ /** Explicit private-question composition. Registers each producer once; does not
8
+ * install resources, replace another extension, or connect to Threadroom. */
9
+ export function registerPrivateQuestions(pi: ExtensionAPI) {
10
+ let host: ReturnType<typeof createQuestionHost> | undefined;
11
+ let owner: { manager: object; sessionId: string } | undefined;
12
+ let foreign = false;
13
+ function ensure(ctx: ExtensionContext) {
14
+ const sessionId = ctx.sessionManager.getSessionId();
15
+ if (!host || owner?.manager !== ctx.sessionManager || owner.sessionId !== sessionId) {
16
+ host?.dispose(); host = createQuestionHost(ctx); owner = { manager: ctx.sessionManager, sessionId }; host.suspend(foreign);
17
+ }
18
+ return host;
19
+ }
20
+ const presentation: NativeQuestionPresentation = {
21
+ canPresent(context) { return supportsQuestionHost(context); },
22
+ connect(binding) {
23
+ type Handle = { detach(): void; answered(): void; require(value: boolean): void };
24
+ const captured = ensure(binding.context), handles = new Map<string, Handle>(); let live = true;
25
+ return {
26
+ replace(questions, state) {
27
+ if (!live) return;
28
+ const pending = new Set(questions.map((question) => question.id));
29
+ const answered = new Set(state?.answeredQuestionIds || []);
30
+ const required = new Set(state?.requiredQuestionIds || []);
31
+ for (const [id, handle] of handles) if (!pending.has(id)) {
32
+ if (answered.has(id)) handle.answered(); else handle.detach();
33
+ handles.delete(id);
34
+ }
35
+ for (const question of questions) {
36
+ let handle = handles.get(question.id);
37
+ if (!handle) {
38
+ handle = captured.enqueue({ id: `native:${question.id}`, mode: 'async', questions: [{ id: question.id, question: question.prompt.question,
39
+ header: question.prompt.header, context: question.prompt.context, options: question.prompt.options,
40
+ multiSelect: question.prompt.multiSelect, plainPreview: true, allowNotes: false }],
41
+ commit(questionId, answer) {
42
+ if (!live) throw Object.assign(new Error('Private source detached.'), { code: 'presentation_detached' });
43
+ binding.commit({ questionId, text: answer.answer || '', optionIndex: answer.optionIndex, optionIndices: answer.optionIndices });
44
+ },
45
+ releaseRequirement(questionId) {
46
+ if (!live) return;
47
+ binding.cancelWait(questionId);
48
+ } });
49
+ handles.set(question.id, handle);
50
+ }
51
+ handle.require(required.has(question.id));
52
+ }
53
+ },
54
+ reveal(questionId, options) {
55
+ if (!live) return false;
56
+ // Automatic resume preserves an existing blocker; explicit /asks ID can
57
+ // select another pending question. Arrival itself does not steal drafts.
58
+ if (!questionId && captured.snapshot().current?.tab.mode === 'blocking') {
59
+ if (options?.focus) captured.activate();
60
+ return true;
61
+ }
62
+ const id = questionId || handles.keys().next().value;
63
+ if (id && handles.has(id)) { captured.select(`native:${id}`, id); if (options?.focus) captured.activate(); return true; }
64
+ return false;
65
+ },
66
+ dispose() { if (!live) return; live = false; captured.dispose(); handles.clear(); if (host === captured) { host = undefined; owner = undefined; } },
67
+ };
68
+ },
69
+ };
70
+ const native = registerNativeAsks(pi, { presentation, waitToolName: 'ask_user_question', registerStandaloneTool: false });
71
+ registerBlockingQuestions(pi, async (group, ctx, signal) => {
72
+ if (!supportsQuestionHost(ctx)) throw Object.assign(new Error('This Pi host does not expose inline widgets; no question was presented and no human declined.'), { code: 'unsupported_host' });
73
+ const accept = native.captureAdmission(ctx);
74
+ const handle = ensure(ctx).enqueue(group), detach = () => handle.detach();
75
+ if (signal?.aborted) detach(); else signal?.addEventListener('abort', detach, { once: true });
76
+ try { return { result: await handle.outcome!, accept }; } finally { signal?.removeEventListener('abort', detach); }
77
+ }, native.waitForQuestion, native.captureAdmission,
78
+ (toolCallId, question, ctx, signal) => native.askQuestion(toolCallId, question, signal, ctx));
79
+ pi.on('ui_prompt_start', () => { foreign = true; host?.suspend(true); });
80
+ pi.on('ui_prompt_end', () => { foreign = false; host?.suspend(false); });
81
+ return { snapshot: () => host?.snapshot(), dispose() { host?.dispose(); host = undefined; owner = undefined; } };
82
+ }
@@ -0,0 +1,24 @@
1
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ /** Explicit user action only. The command is trusted SDK/user configuration;
7
+ * question/paste text is file data, never shell source. Owns file and TUI cleanup. */
8
+ export function editTextOutsidePi(tui: any, command: string, text: string): string {
9
+ if (!command.trim()) throw new Error('No external editor is configured.');
10
+ const directory = mkdtempSync(join(tmpdir(), 'pi-question-edit-')), file = join(directory, 'reply.txt');
11
+ let stopped = false;
12
+ try {
13
+ writeFileSync(file, text, { mode: 0o600 });
14
+ tui.stop(); stopped = true;
15
+ const quote = (value: string) => `'${value.replace(/'/gu, `'\\''`)}'`;
16
+ const child = spawnSync('/bin/sh', ['-c', `${command} ${quote(file)}`], { stdio: 'inherit' });
17
+ if (child.error) throw child.error;
18
+ if (child.status !== 0) throw new Error(`External editor exited ${child.signal || child.status}. Original draft retained.`);
19
+ return readFileSync(file, 'utf8');
20
+ } finally {
21
+ try { rmSync(directory, { recursive: true, force: true }); }
22
+ finally { if (stopped) { tui.start(); tui.requestRender(true); } }
23
+ }
24
+ }