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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Scott Meyer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # Threadroom for Pi
2
+
3
+ Ask private questions without making the AI guess or stop useful independent work. `threadroom-pi` owns one `ask_user_question` tool with a shared input-area surface: questions block by default, while `blocking: false` leaves them open as the AI continues. The optional shared Threadroom lane is experimental, off by default, and never starts or contacts its bundled local service during private questions.
4
+
5
+ This is beta software. The private-question experience is usable and tested on Pi 0.86.x; shared discussions, remote deployment, authentication, and broader host compatibility are still evolving. Pi extensions execute with the same system access as Pi, so review packages before installing them.
6
+
7
+ ## Install
8
+
9
+ Requires Node 24 or newer. The beta release is intentionally outside npm's `latest` channel:
10
+
11
+ ```sh
12
+ pi install npm:threadroom-pi@beta
13
+ ```
14
+
15
+ Reload or restart Pi after changing packages. `threadroom-pi` must be the only active producer of `ask_user_question`; disable another questionnaire extension before enabling it. To try the current checkout without installation:
16
+
17
+ ```sh
18
+ pi --no-extensions -e ./packages/pi-extension/extensions/index.ts
19
+ ```
20
+
21
+ ## Private questions
22
+
23
+ Blocking questions take priority within the question pane and await only their own group. Nonblocking questions appear automatically while the AI continues working—not in a hidden inbox. Async-only arrivals leave the current input focused: use Shift+Tab or `/asks` to enter the pane. A blocker selects and focuses the same shared surface; Chat remains unavailable until every blocker is answered or cancelled, while pending async tabs remain usable beside it. Answering the last blocker keeps a remaining nonblocking question focused instead of automatically returning to Chat; cancellation still restores the blocker’s input loan. Same-priority arrivals retain the current draft; suggestions stay visible during writing, and clearing the reply restores choice selection.
24
+
25
+ `ask_user_question({ questions, blocking: false })` returns stable IDs and `waitWith` references. If later work depends on one of those unanswered questions, passing `questions: [{ questionId }]` to the same tool temporarily makes its existing tab required and waits for its native saved answer. It neither republishes nor recreates the question, and its original draft, choices, limits, branch identity, and persistence owner remain intact. Escape releases only the wait and returns the still-pending question to async. If its answer was already queued or received through async feedback, the wait reports that state rather than returning the answer a second time.
26
+
27
+ Use the SDK select keys to choose/confirm, type or paste a free reply, and Tab to cycle forward through question and group-review tabs. In an async-only pane, Shift+Tab selects/unselects questions while preserving both drafts; a solid border means the pane owns focus, and a dotted border means it does not. A blocker has an explicit required marker and warning border; Shift+Tab and collapse cannot leave it. Chat keeps native Tab completion when no blocker is pending. Blocking review supports partial submission, live checks, custom text and Alt+N notes; cancel affects only that group. Native async does not offer notes because its stored answer contract cannot retain them. Async previews are literal text; blocking previews support Markdown.
28
+
29
+ Escape pauses async questions without declining them or discarding their drafts; with a blocker pending, that returns to a required tab rather than Chat. `/asks [id]` selects pending questions. Collapse is available only without a blocker and returns to the exact input that lent focus, while it remains mounted. The extension never guesses a replacement from editor methods, classes or text. Normal SDK dialog events suppress question focus while another prompt is active; an established modal loan also recognizes its exact stock input. Hosts with optional public `ui.getCoreEditor()` identity can distinguish Chat from unannounced foreign prompts. A blocker returns to its exact modal loan; actually reclaiming an authoritative replacement or explicit stock `/asks` refreshes that loan, while an overlapping unannounced prompt defers the return without losing it. Normal Pi needs no SDK patch. Configured external-editor actions edit the original field and restore the TUI afterward.
30
+
31
+ Private prompts and answers remain in the original Pi session/branch, never Threadroom’s API or discoverable outline. Saved native feedback retains its original prompt and answer identity, then steers a busy agent at a legal boundary or wakes an idle one. A closed Pi cannot be woken by this extension, and copied/forked different-session histories do not inherit ownership. Native async requires interactive TUI. Blocking questions also support SDK RPC/ACP dialogs; unsupported hosts and aborts are not human declines.
32
+
33
+ All private admission closes as soon as Pi announces a switch, fork, tree change, or compaction. Positive lifecycle events reopen it; idle time is not treated as transition completion. Stock Pi does not report a cancelled or failed switch, fork, or tree transition back to extensions, so that rare outcome stays fail-closed: after the transition command itself has returned, use `/reload` and then `/asks`. This reload guidance is only presentation-lifecycle recovery—`/reload` never repairs storage uncertainty.
34
+
35
+ Saving an answer, queueing feedback and recording its consumption receipt are different outcomes. Process-local submission IDs prevent blind resend across extension reload; they are not durable receipts. Cold recovery can replay unreceipted feedback, so subsequent AI work is not exactly-once. `/asks` exposes pending questions, answer identities and recovery diagnostics—do not submit again to repair delivery.
36
+
37
+ The SDK can mutate memory before a failed disk write. Native records then remain **storage unconfirmed**: further private writes/delivery are blocked on that manager/session, even after `/reload`. Feedback receipts require a complete physical session-file row; memory-only hosts make no disk assertion. A pre-append refusal without branch mutation remains retryable. Recover the original journal and preserve/copy drafts before replacing the process; do not quit/resume as a storage-error feedback retry. This guard is not SDK journal rollback or repair.
38
+
39
+ ## Optional shared discussions
40
+
41
+ Threadroom can bring someone a question, an experiment, or an interaction you designed—and let the discussion outlive this chat turn. Plain text is enough. A self-contained HTML/CSS/JS document can be a canvas, prototype, comparison, or something we haven't anticipated.
42
+
43
+ The shared lane is **off by default**; private questions remain available. `/threadroom` opens the extension’s menu for status, watched discussions, and configuration. It can save a computer-wide default or, in a trusted project, an overriding project value. Direct command arguments are also accepted:
44
+
45
+ ```text
46
+ /threadroom computer on
47
+ /threadroom computer off
48
+ /threadroom computer inherit
49
+ /threadroom project on
50
+ /threadroom project off
51
+ /threadroom project inherit
52
+ /threadroom status
53
+ ```
54
+
55
+ The durable files are `~/.pi/agent/threadroom.json` (or Pi’s configured agent directory) and `<project>/.pi/threadroom.json`. Project configuration is not read before Pi trusts that project. Missing settings inherit downward from the built-in off default. A malformed effective setting fails the shared lane closed without disabling private questions; an explicit valid trusted-project value can still override a malformed computer default, which remains visible as a warning. Run `/reload` after changing the setting. When off, shared tools are absent from the model’s active tool set, configured shared endpoints are not parsed or contacted, restored shared watches do not connect, and no managed service starts.
56
+
57
+ ## Local development
58
+
59
+ Private nonblocking questions need only normal interactive Pi—no Threadroom API, website, or patched SDK. The checkout command under Install isolates a trial from other extensions; preparing source does not reload a running session. Shared Threadroom tools lazily ensure the bundled local service only after that lane is explicitly enabled, unless an endpoint or startup opt-out assigns service ownership elsewhere.
60
+
61
+ For normal project loading, first exclude any other producer of `ask_user_question`; do not load duplicate implementations. In a trusted project, Pi’s project package entry overrides an inherited entry with the same npm identity, so a project-local `{ "source": "npm:@juicesharp/rpiv-ask-user-question", "autoload": false, "extensions": ["-index.ts"] }` excludes that package's sole producer without changing global settings. An empty `extensions` delta does not exclude it. This is configuration guidance, not automatic installation or activation. Coordinate reload only after the ordinary editor has focus.
62
+
63
+ The packed adapter includes the local service runtime. After the shared lane is enabled, the first shared Threadroom request—or restoration of active shared watches—reuses a compatible service or starts a detached API + website at `http://127.0.0.1:4310`. Private questions alone never start it. The detached service uses stable per-user storage and survives Pi reload/shutdown. Concurrent Pi sessions share a startup lease; health checks bind compatibility to the API version, website capability, and selected database rather than adopting an unrelated port owner.
64
+
65
+ After shared Threadroom is enabled through `/threadroom`, runtime/service configuration uses environment variables:
66
+
67
+ - `THREADROOM_API_URL`: an explicitly owned API endpoint. Setting it disables automatic local startup, even when it names localhost. Set the literal default URL too when intentionally using a checkout/supervised service with its own database.
68
+ - `THREADROOM_UI_URL`: website address; defaults to the API address. Set it separately for an independently hosted website.
69
+ - `THREADROOM_AUTO_START=0`: keep the default endpoint but require external service ownership.
70
+ - `THREADROOM_DB`: local managed-service database. Relative values resolve against Pi’s invocation directory before the detached service changes directory; otherwise the platform’s per-user Threadroom data path is used.
71
+
72
+ The experimental `THREADROOM_REPLACE_ASK` API alias has been retired. Threadroom always uses its own tool names; a stale setting cannot redirect ordinary native asks into the shared service.
73
+
74
+ The current service is single-user and unauthenticated. Keep it local or use a trusted tunnel; author/session metadata is correlation, not permission. No production identity or authorized handoff claim is made here.
75
+
76
+ Shared tools use owned Node HTTP/HTTPS connections, not host `fetch` or its global dispatcher. This avoids the [known Undici socket-QoS crash](https://github.com/nodejs/undici/issues/5544) without changing host networking or suppressing process exceptions. No new runtime dependency is needed. Native asks still make no HTTP requests; this does not fix unrelated uses of host fetch.
77
+
78
+ ## Participation
79
+
80
+ `threadroom_ask` publishes a plain question or authored interaction in one call and watches its replies in this session. It returns immediately unless `waitMs` is supplied. There is no required options list, header, or form layout. It is a separate shared capability, not an emulator of the native questionnaire schema.
81
+
82
+ `threadroom` covers ordinary contributions and replies, readable history, outline browsing, direct-node watches, and waiting on an existing question. Discussions can branch beneath any node, including an answer. Watches cover direct replies; a deeper branch can have its own watch. `/threadroom` shows the chosen API/website addresses, connectivity, and local participation. Connection errors also identify those endpoints. Enabled local use can start the bundled service; explicitly configured endpoints are only connected to, never started or replaced.
83
+
84
+ Pi shows readable questions, saved reply intent, and durable discussion links—not raw authored programs or JSON envelopes. Expanded views reveal more history and receipt identity. Saved text is literal terminal text, not executable controls or Markdown. The full machine-facing records remain unchanged.
85
+
86
+ Action results carry durable identities/links, saved status, provenance, and a small view of this session's watches. Large authored source and image values stay at their saved API address instead of being echoed into every turn. Browsing currently filters the full service outline locally and returns at most 50 matches; this isn't a server search/paging implementation.
87
+
88
+ ## What a reply means
89
+
90
+ Later saved feedback enters the originating Pi session as a custom message. Busy agents receive it at Pi's next safe steering boundary; idle agents can wake. Delivery pauses around compaction and resumes afterward. During tree navigation/summarization it preserves the old watch without waking on the outgoing branch. Because Pi lacks a cancel/failure completion event there, a deferred receipt owns a small idle recheck timer; success rebinds the chosen branch, while cancellation/failure releases the gate when the host becomes idle. A clarification, rejection, or deferral retains its own meaning—it isn't silently turned into approval.
91
+
92
+ Service events replay on reconnect. Adding a watch currently replays from the beginning, and an unsequenced answer found by a wait conservatively holds the checkpoint there until its transcript receipt exists. That trades transport work for recovery safety; scoped per-watch replay is future scalability work. Local checkpoints remember watched IDs and a safe replay position; they don't become another conversation database. A queued Pi message does **not** advance past an unconfirmed response. Only a corresponding persisted transcript receipt does. Reload reconciles receipts, so an interrupted checkpoint doesn't require asking the person again. The current host does not reliably expose whether a custom steering queue was discarded or remains queued after abort. The adapter therefore does not guess and resend during the same runtime; reading/waiting or a session reload recovers unpersisted feedback. This is not an exactly-once guarantee for whatever an agent does afterward.
93
+
94
+ Wait results carry the request snapshot that established the outcome, not the original outstanding state beside a saved answer. Timeouts carry the last completed wait read, or the publication snapshot if no read completed; later changes remain readable. Timeout (maximum 120 seconds) and cancellation release the wait, not the question or watch. Connection failure is explicit; an ambiguous write returns a retry key for unchanged content. Reading an old discussion does not automatically inherit its original session's subscription. Forked sessions don't inherit live watches merely by copying a transcript; they can adopt a node explicitly.
95
+
96
+ ## Boundaries and next slice
97
+
98
+ Threadroom owns saved conversations and presentation isolation. Authored scripts can propose drafts, but host controls own saving feedback. Pi owns session participation and model-message delivery. Neither side grants generated content filesystem, terminal, or model credentials.
99
+
100
+ Private question state, results and persistence are separate from terminal focus. A future attached FlightDeck presenter can become the primary UI with terminal fallback; attachment selection and handoff are not implemented in this slice.
101
+
102
+ The Node transport and participation modules under `src/` have no Pi or rendering imports. Another Node host can use them without implementing a Pi TUI. Constructing a `ThreadroomClient` allocates no agents or connections; its first request opens its own HTTP/HTTPS pool. REST deadlines include the response body; SSE stays open until its signal, stream failure, or client closure. Redirects are not followed and HTTPS retains ordinary certificate verification.
103
+
104
+ A consumer owns the client's lifetime: close its participants first, then `await client.close()`. Client closure cancels its active requests/streams and releases its sockets, is idempotent, and permanently rejects new requests; it does not affect another client. `Participation.close()` ends that subscription, not the reusable client. The Pi extension releases its outgoing client on quit/reload/session replacement, giving a replacement session a fresh lazy client. A future terminal UI can consume the same service; its integration point remains open.
105
+
106
+ Captured presentations are immutable today. An evolving job dashboard or two-way live workspace needs an explicit service capability, not a hidden local server or a Pi-only conversation store. Hard CPU isolation, authentication, remote deployment, large-history scalability, notification preferences, and broader host lifecycle evidence remain unfinished.
107
+
108
+ ## License
109
+
110
+ The code distributed in this package, including its bundled local service runtime, is available under the [MIT License](LICENSE).
@@ -0,0 +1,15 @@
1
+ # Pi host adapter
2
+
3
+ `index.ts` registers our owned private question producer once, through `questions/`. It blocks by default and uses `blocking: false` for questions that can remain pending while work continues. Separate shared Threadroom tools are inactive by default and bind to Pi’s session/message lifecycle only after the `/threadroom` menu enables them for the computer or trusted project. Ordinary questions share flat input-area tabs; async arrivals stay passive, while any blocker makes that one surface required until it is answered or cancelled. A nonblocking identity can later acquire a blocking wait through the same tool without creating a second tab or changing its private persistence owner. The inline widget retains drafts, keeps async tabs available during a blocker, yields to foreign host prompts, and never replaces an editor or holds a custom-UI promise. `/asks` reopens pending private questions. Exclude other producers with the same ordinary ask name before loading this entry point; preparing source wiring does not activate a running seat. Shared discussions and authored interactions stay in the independent service.
4
+
5
+ Tool rows and saved-feedback messages show the discussion title, durable website address, and what happened. Expand a row for nearby saved context, provenance labels, receipt identities and participation state. Authored programs never render as terminal source. The original tool result and message details remain unchanged for the model and session replay.
6
+
7
+ The presentation helpers use Pi's `Text` components, with terminal controls and directional overrides made visible as text. Only validated HTTP(S) addresses become hyperlinks; terminals without hyperlink support still show the address. Author labels describe caller-supplied provenance, not authenticated approval.
8
+
9
+ `/threadroom` owns the shared lane’s status, watched-discussion, and configuration menu. It writes the computer-wide default or trusted-project override, and `/reload` applies configuration changes. Project configuration is not read before trust. Missing configuration defaults off, and an invalid effective setting fails only the shared lane closed. When enabled, `/threadroom` shows the selected API and website addresses, connection state and session watches. Failed shared-tool operations show those addresses too. With no explicit API configuration, the first enabled shared operation or restored watch ensures a compatible detached local API + website is running. Private-only or disabled use does not parse configured endpoints and remains network- and service-free. Explicit endpoints retain external ownership and are never replaced or auto-started.
10
+
11
+ The runtime uses the `@earendil-works/pi-tui` peer exposed by Pi's extension loader. Helpers live beneath `presentation/` without an extension entry point.
12
+
13
+ ## Host evidence
14
+
15
+ `packages/pi-extension/test/presentation.test.js` loads the real adapter through Pi's discovery/loader, executes it against a real service, and renders through Pi's `ToolExecutionComponent` and `CustomMessageComponent`. It checks readable links, wait outcomes, feedback intent/provenance, receipt preservation, terminal safety, expansion and narrow widths. With a local Pi peer installed, the check participates in `npm test`. For a global install, `THREADROOM_PI_SDK_ROOT` names the installed `pi-coding-agent` package directory. A service-only checkout reports these host checks as skipped.
@@ -0,0 +1,280 @@
1
+ import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import { Type } from 'typebox';
3
+ import { ThreadroomClient } from '../src/client.js';
4
+ import { Participation } from '../src/participation.js';
5
+ import { renderAskCall, renderDiscussionCall, renderToolResult, renderFeedback, participationNotice } from './presentation/renderers.ts';
6
+ import { registerPrivateQuestions } from './questions/index.ts';
7
+ import { createManagedThreadroomService } from '../src/service-runtime.js';
8
+ import { resolveThreadroomConfig, threadroomConfigPaths, writeThreadroomConfig } from '../src/config.js';
9
+
10
+ const STATE = 'threadroom.participation.v1';
11
+ const ACTIVITY = 'threadroom.reply.v1';
12
+
13
+ export default function threadroom(pi: ExtensionAPI) {
14
+ registerPrivateQuestions(pi);
15
+ // Ordinary asks are the default. The shared lane is an explicit, reload-bound
16
+ // computer/project setting and never a prerequisite for private questions.
17
+ let sharedEnabled = false;
18
+ const sharedToolNames = new Set(['threadroom_ask', 'threadroom']);
19
+ function removeSharedTools() {
20
+ const active = pi.getActiveTools();
21
+ const privateOnly = active.filter((name) => !sharedToolNames.has(name));
22
+ if (privateOnly.length !== active.length) pi.setActiveTools(privateOnly);
23
+ }
24
+ async function configureShared(args: string, ctx: any) {
25
+ const trusted = typeof ctx.isProjectTrusted === 'function' && ctx.isProjectTrusted() === true;
26
+ const paths = threadroomConfigPaths({ cwd: ctx.cwd, agentDir: getAgentDir(), configDirName: CONFIG_DIR_NAME });
27
+ const words = args.trim().toLowerCase().split(/\s+/u).filter(Boolean);
28
+ let scope = words[0];
29
+ if (scope === 'global') scope = 'computer';
30
+ if (scope === 'local') scope = 'project';
31
+ if (!['computer', 'project'].includes(scope)) {
32
+ scope = await ctx.ui.select('Configure shared Threadroom', [
33
+ 'Computer-wide default', ...(trusted ? ['This project'] : []),
34
+ ]).then((choice: string | undefined) => choice === 'Computer-wide default' ? 'computer' : choice === 'This project' ? 'project' : undefined);
35
+ }
36
+ if (!scope) return;
37
+ if (scope === 'project' && !trusted) { ctx.ui.notify('Project Threadroom overrides require a trusted project.', 'warning'); return; }
38
+ let setting = words[1];
39
+ if (!['on', 'off', 'inherit'].includes(setting)) {
40
+ const choices = scope === 'project' ? ['On', 'Off', 'Inherit computer-wide default'] : ['On', 'Off', 'Use built-in default (off)'];
41
+ setting = await ctx.ui.select(`${scope === 'project' ? 'Project' : 'Computer-wide'} shared Threadroom`, choices)
42
+ .then((choice: string | undefined) => choice === 'On' ? 'on' : choice === 'Off' ? 'off' : choice ? 'inherit' : undefined);
43
+ }
44
+ if (!setting) return;
45
+ const path = scope === 'project' ? paths.projectPath : paths.globalPath;
46
+ writeThreadroomConfig({ path, shared: setting === 'inherit' ? undefined : setting === 'on' });
47
+ ctx.ui.notify(`Saved ${scope === 'project' ? 'project override' : 'computer-wide default'} in ${path}. Run /reload to apply it.`, 'info');
48
+ }
49
+ const askToolName = 'threadroom_ask';
50
+ pi.registerMessageRenderer(ACTIVITY, renderFeedback);
51
+ let room: Participation | undefined;
52
+ let context: any;
53
+ let paused = false;
54
+ let navigating = false;
55
+ let running = false;
56
+ let navigationTimer: ReturnType<typeof setTimeout> | undefined;
57
+ let epoch = 0;
58
+ const configuredApiUrl = process.env.THREADROOM_API_URL;
59
+ const apiUrl = configuredApiUrl || 'http://127.0.0.1:4310';
60
+ const uiUrl = process.env.THREADROOM_UI_URL || apiUrl;
61
+ let endpoints = { apiUrl, uiUrl };
62
+ let managedService: ReturnType<typeof createManagedThreadroomService> | undefined;
63
+ let client: ThreadroomClient | undefined;
64
+ function ensureSharedRuntime() {
65
+ if (client) return;
66
+ managedService = !configuredApiUrl && process.env.THREADROOM_AUTO_START !== '0'
67
+ ? createManagedThreadroomService({ baseUrl: apiUrl, databaseValue: process.env.THREADROOM_DB, invocationCwd: process.cwd() })
68
+ : undefined;
69
+ client = new ThreadroomClient(apiUrl, { uiUrl, beforeConnect: managedService ? () => managedService!.ensure() : undefined });
70
+ endpoints = { apiUrl: client.baseUrl, uiUrl: client.uiUrl };
71
+ }
72
+
73
+ // Only active-branch, same-session checkpoints are inherited. Display names
74
+ // and copied/forked histories do not automatically confer a live subscription.
75
+ function receipts(entries: any[]) {
76
+ return entries.flatMap((entry) => {
77
+ if (entry.type === 'custom_message' && entry.customType === ACTIVITY) return entry.details?.receivedResponseIds || [];
78
+ if (entry.type === 'message' && entry.message.role === 'toolResult' &&
79
+ ['threadroom_ask', 'ask_user_question', 'threadroom'].includes(entry.message.toolName)) {
80
+ return entry.message.details?.receivedResponseIds || [];
81
+ }
82
+ return [];
83
+ });
84
+ }
85
+ // Pi has no tree-cancel/tree-failed event. Its isIdle() does include branch
86
+ // summarization. A deferred receipt therefore waits for host quiescence,
87
+ // without closing the old watch or waking a model on the outgoing branch.
88
+ function afterNavigation(mine: number, ctx: any) {
89
+ if (navigationTimer) return;
90
+ navigationTimer = setTimeout(() => {
91
+ navigationTimer = undefined;
92
+ if (mine !== epoch) return;
93
+ if (ctx.isIdle()) { navigating = false; room?.flush(); }
94
+ else afterNavigation(mine, ctx);
95
+ }, 100);
96
+ navigationTimer.unref();
97
+ }
98
+ async function bind(ctx: any) {
99
+ ensureSharedRuntime(); const activeClient = client!;
100
+ const mine = ++epoch;
101
+ clearTimeout(navigationTimer); navigationTimer = undefined;
102
+ const old = room; room = undefined; await old?.close();
103
+ if (mine !== epoch) return;
104
+ context = ctx; paused = false; navigating = false; running = false;
105
+ const sessionId = ctx.sessionManager.getSessionId();
106
+ const entries = ctx.sessionManager.getBranch();
107
+ const saved = entries.filter((entry: any) => entry.type === 'custom' && entry.customType === STATE &&
108
+ entry.data?.sessionId === sessionId && entry.data?.apiUrl === activeClient.baseUrl).at(-1)?.data;
109
+ const author = saved?.author || { id: `pi:${sessionId}`, sessionId, name: pi.getSessionName() || 'Pi teammate' };
110
+ room = new Participation(activeClient, { state: saved?.state, received: receipts(entries), author,
111
+ checkpoint(state: any) {
112
+ if (mine === epoch) pi.appendEntry(STATE, { sessionId, apiUrl: activeClient.baseUrl, author, state });
113
+ },
114
+ deliver(receipt: any) {
115
+ if (mine !== epoch) return false;
116
+ if (paused) return false;
117
+ if (navigating || (!running && !ctx.isIdle())) { afterNavigation(mine, ctx); return false; }
118
+ const details = { target: receipt.target, response: receipt.response,
119
+ receivedResponseIds: [receipt.responseId], apiUrl: activeClient.baseUrl,
120
+ delivery: { eventId: receipt.eventId, sequence: receipt.sequence } };
121
+ pi.sendMessage({ customType: ACTIVITY, display: true, details,
122
+ content: `Saved Threadroom feedback (caller-provided author labels; not blanket authorization):\n${JSON.stringify(details)}` },
123
+ { deliverAs: 'steer', triggerTurn: true });
124
+ pi.events.emit('threadroom:activity', details);
125
+ },
126
+ connection(state: any) {
127
+ if (mine !== epoch) return;
128
+ ctx.ui.setStatus('threadroom', `Threadroom: ${state.status}`);
129
+ pi.events.emit('threadroom:connection', state);
130
+ },
131
+ });
132
+ room.start();
133
+ }
134
+ function confirmPersisted(ctx: any) {
135
+ if (!room || context?.sessionManager.getSessionId() !== ctx.sessionManager.getSessionId()) return;
136
+ room.acknowledge(receipts(ctx.sessionManager.getBranch()));
137
+ }
138
+ async function use(ctx: any) {
139
+ if (!sharedEnabled) throw Object.assign(new Error('Shared Threadroom is disabled. Use /threadroom, then /reload, to enable it for this computer or project.'), { code: 'threadroom_disabled' });
140
+ if (!room) await bind(ctx);
141
+ if (!room) throw new Error('Threadroom session is changing; retry in the active session.');
142
+ confirmPersisted(ctx);
143
+ return room;
144
+ }
145
+ pi.on('session_start', async (_event, ctx) => {
146
+ const config = resolveThreadroomConfig({ cwd: ctx.cwd, agentDir: getAgentDir(), configDirName: CONFIG_DIR_NAME,
147
+ projectTrusted: typeof ctx.isProjectTrusted === 'function' && ctx.isProjectTrusted() === true });
148
+ sharedEnabled = config.enabled;
149
+ for (const warning of config.warnings) ctx.ui.notify(`Threadroom configuration warning: ${warning}`, 'warning');
150
+ if (!sharedEnabled) {
151
+ removeSharedTools(); ctx.ui.setStatus('threadroom', undefined);
152
+ const old = room; room = undefined; await old?.close();
153
+ return;
154
+ }
155
+ await bind(ctx);
156
+ });
157
+ pi.on('session_shutdown', async () => {
158
+ sharedEnabled = false; ++epoch; clearTimeout(navigationTimer); navigationTimer = undefined;
159
+ const old = room; room = undefined;
160
+ // Session replacement can reuse this factory; the next session gets its own
161
+ // lazy client while outgoing HTTP work belongs to the captured old client.
162
+ const oldClient = client; client = undefined; managedService = undefined;
163
+ try { await old?.close(); } finally { await oldClient?.close(); }
164
+ });
165
+ pi.on('session_before_tree', () => { if (sharedEnabled) navigating = true; });
166
+ pi.on('agent_start', () => { if (sharedEnabled) running = true; });
167
+ pi.on('session_tree', async (_event, ctx) => { if (sharedEnabled) await bind(ctx); });
168
+ pi.on('session_before_compact', () => { if (sharedEnabled) paused = true; });
169
+ pi.on('session_compact', (_event, ctx) => { if (sharedEnabled) { paused = false; confirmPersisted(ctx); room?.flush(); } });
170
+ pi.on('session_compact_failed', (_event, ctx) => { if (sharedEnabled) { paused = false; confirmPersisted(ctx); room?.flush(); } });
171
+ // These boundaries occur after messages/tool results have been appended.
172
+ pi.on('turn_end', (_event, ctx) => { if (sharedEnabled) confirmPersisted(ctx); });
173
+ pi.on('agent_settled', (_event, ctx) => {
174
+ running = false;
175
+ if (sharedEnabled) { confirmPersisted(ctx); room?.flush(); }
176
+ });
177
+
178
+ function renderResult(result: any, options: any, theme: any, context: any) {
179
+ return renderToolResult(result, options, theme, { ...context, endpoints });
180
+ }
181
+ function result(value: any) { return { content: [{ type: 'text' as const, text: JSON.stringify(value) }], details: value }; }
182
+ function failure(error: any) {
183
+ return result({ error: error.message, status: error.status, retryKey: error.retryKey,
184
+ publication: error.ambiguous ? 'unknown; the write may already be saved. Reuse the retry key with unchanged content.' : 'not confirmed',
185
+ participation: room?.adjacent() });
186
+ }
187
+ const timeout = Type.Optional(Type.Integer({ minimum: 0, maximum: 120000,
188
+ description: 'Wait up to this many milliseconds. Omit to continue immediately; timeout/cancel does not withdraw the question.' }));
189
+
190
+ pi.registerTool({
191
+ name: askToolName, label: 'Ask in Threadroom',
192
+ renderCall: renderAskCall, renderResult,
193
+ description: 'Bring the person a question or an interaction you designed. Threadroom saves it and its presentation, returns a durable address, and brings later replies into this session. Returns immediately unless you choose to wait. Authored HTML/JS runs in the service sandbox; its proposals are drafts, while the surrounding host owns saving an answer. Plain text and suggested choices are conveniences, not limits on what you can show.',
194
+ parameters: Type.Object({
195
+ question: Type.String({ description: 'What you want to discuss.' }),
196
+ body: Type.Optional(Type.String({ description: 'Written context.' })),
197
+ html: Type.Optional(Type.String({ description: 'Self-contained HTML/CSS/JS interaction, captured with the question.' })),
198
+ fallback: Type.Optional(Type.String({ description: 'Readable meaning of the authored interaction, without running it.' })),
199
+ choices: Type.Optional(Type.Array(Type.String(), { description: 'Optional suggested answers; the person can respond freely.' })),
200
+ multiple: Type.Optional(Type.Boolean()),
201
+ project: Type.Optional(Type.String({ description: 'Convenient scope name; use this, path, or parentId.' })),
202
+ path: Type.Optional(Type.Array(Type.String(), { description: 'Ancestor titles, resolved or created in the same call.' })),
203
+ parentId: Type.Optional(Type.String({ description: 'An existing node, including an answer, to continue beneath.' })),
204
+ waitMs: timeout,
205
+ retryKey: Type.Optional(Type.String({ description: 'Recover an ambiguous publication with unchanged content.' })),
206
+ }),
207
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
208
+ try {
209
+ const active = await use(ctx);
210
+ const { waitMs, retryKey, ...input } = params;
211
+ const published = await active.publish(input, { waitMs, signal,
212
+ key: retryKey || `pi:${ctx.sessionManager.getSessionId()}:${toolCallId}` });
213
+ pi.events.emit('threadroom:published', published);
214
+ return result(published);
215
+ } catch (error) { return failure(error); }
216
+ },
217
+ });
218
+
219
+ pi.registerTool({
220
+ name: 'threadroom', label: 'Threadroom',
221
+ renderCall: renderDiscussionCall, renderResult,
222
+ description: 'Participate in lasting discussions: read saved work and feedback, browse the outline/history, publish a contribution, reply beneath a node, or watch a node in this session. Waiting is optional and does not change the shared conversation. Action results carry identities, response provenance, durable links, and this session’s nearby participation state. Watches receive direct replies; deeper branches can be watched independently.',
223
+ parameters: Type.Object({
224
+ action: Type.Union(['read', 'browse', 'publish', 'reply', 'watch', 'unwatch', 'wait'].map((value) => Type.Literal(value))),
225
+ id: Type.Optional(Type.String({ description: 'Target node identity.' })),
226
+ input: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
227
+ description: 'Service contribution: title/question, body, authored html/fallback or presentation, and path/parentId scope. Replies may carry response intent (kind), selections and their own presentation.' })),
228
+ waitMs: timeout,
229
+ retryKey: Type.Optional(Type.String()),
230
+ query: Type.Optional(Type.String({ description: 'Filter outline titles locally.' })),
231
+ status: Type.Optional(Type.String({ description: 'Filter outline answer-request status.' })),
232
+ }),
233
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
234
+ try {
235
+ const active = await use(ctx);
236
+ const key = params.retryKey || `pi:${ctx.sessionManager.getSessionId()}:${toolCallId}`;
237
+ const id = params.id;
238
+ if (!['browse', 'publish'].includes(params.action) && !id) throw new Error('This action needs a node id.');
239
+ switch (params.action) {
240
+ case 'publish': return result(await active.publish(params.input || {}, { key, signal, waitMs: params.waitMs }));
241
+ case 'reply': return result(await active.respond(id, params.input || {}, { key, signal }));
242
+ case 'read': return result(await active.read(id, { signal }));
243
+ case 'watch': return result(await active.watch(id, { signal }));
244
+ case 'unwatch': return result(active.unwatch(id));
245
+ case 'wait': return result({ id, url: client!.link(id), ...await active.wait(id, { timeoutMs: params.waitMs, signal }) });
246
+ case 'browse': {
247
+ const tree = await client!.tree({ signal });
248
+ const nodes = tree.nodes.filter((node: any) => (!params.query || node.title.toLowerCase().includes(params.query.toLowerCase())) &&
249
+ (!params.status || node.status === params.status));
250
+ return result({ nodes: nodes.slice(0, 50).map((node: any) => ({ ...node, url: client!.link(node.id) })),
251
+ omitted: Math.max(0, nodes.length - 50), participation: active.adjacent() });
252
+ }
253
+ }
254
+ } catch (error) { return failure(error); }
255
+ },
256
+ });
257
+
258
+ pi.registerCommand('threadroom', { description: 'Configure Threadroom or inspect its connectivity and watched discussions.',
259
+ async handler(args, ctx) {
260
+ const words = args.trim().toLowerCase().split(/\s+/u).filter(Boolean);
261
+ if (['computer', 'global', 'project', 'local'].includes(words[0])) { await configureShared(args, ctx); return; }
262
+ let action = words[0];
263
+ if (!action) {
264
+ const trusted = typeof ctx.isProjectTrusted === 'function' && ctx.isProjectTrusted() === true;
265
+ const choice = await ctx.ui.select(`Threadroom (${sharedEnabled ? 'on' : 'off'})`, [
266
+ ...(sharedEnabled ? ['Show status and watched discussions'] : []),
267
+ 'Configure computer-wide default', ...(trusted ? ['Configure this project'] : []),
268
+ ]);
269
+ if (choice === 'Configure computer-wide default') { await configureShared('computer', ctx); return; }
270
+ if (choice === 'Configure this project') { await configureShared('project', ctx); return; }
271
+ if (choice === 'Show status and watched discussions') action = 'status'; else return;
272
+ }
273
+ if (action !== 'status') {
274
+ ctx.ui.notify('Usage: /threadroom, /threadroom status, or /threadroom <computer|project> <on|off|inherit>.', 'warning');
275
+ return;
276
+ }
277
+ if (!sharedEnabled) { ctx.ui.notify('Shared Threadroom is off. Run /threadroom to configure it.', 'info'); return; }
278
+ ensureSharedRuntime(); await managedService?.ensure(); const active = await use(ctx); ctx.ui.notify(participationNotice(active.adjacent(), endpoints), 'info');
279
+ } });
280
+ }