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,40 @@
1
+ export type QuestionOption = Readonly<{ label: string; description?: string; preview?: string }>;
2
+ export type QuestionSpec = Readonly<{
3
+ id: string;
4
+ header?: string;
5
+ question: string;
6
+ context?: string;
7
+ options?: readonly QuestionOption[];
8
+ multiSelect?: boolean;
9
+ plainPreview?: boolean;
10
+ /** Sources that cannot retain notes must not accept them in the presentation. */
11
+ allowNotes?: boolean;
12
+ }>;
13
+ export type QuestionAnswer = Readonly<{
14
+ questionIndex: number;
15
+ question: string;
16
+ answer?: string;
17
+ selected?: readonly string[];
18
+ optionIndex?: number;
19
+ optionIndices?: readonly number[];
20
+ notes?: string;
21
+ preview?: string;
22
+ /** Position-aligned with selected; null means the option has no preview. */
23
+ previews?: readonly (string | null)[];
24
+ wasCustom?: boolean;
25
+ }>;
26
+ export type QuestionResult = Readonly<{ answers: readonly QuestionAnswer[]; cancelled: boolean }>;
27
+ export type QuestionGroup = Readonly<{
28
+ id: string;
29
+ mode: 'blocking' | 'async';
30
+ questions: readonly QuestionSpec[];
31
+ /** Successful resolution means source persistence, not AI consumption. */
32
+ commit?: (questionId: string, answer: QuestionAnswer) => void | Promise<void>;
33
+ /** A person can release a temporary required lease without withdrawing the
34
+ * original async question. The source settles the waiting consumer. */
35
+ releaseRequirement?: (questionId: string) => void;
36
+ }>;
37
+
38
+ /** A native async question can temporarily become required while a tool waits
39
+ * on the same saved identity. Persistence remains owned by its original source. */
40
+ export type AsyncQuestionRequirement = 'answered' | 'detached';
@@ -0,0 +1,221 @@
1
+ import { Editor, Text, Markdown, CURSOR_MARKER, getKeybindings, isKeyRelease, isKeyRepeat, matchesKey, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
2
+ import { getMarkdownTheme } from '@earendil-works/pi-coding-agent';
3
+ import { QuestionModel, type QuestionState } from './model.ts';
4
+ import { pastedReplyText, readable, replyText } from './text.ts';
5
+
6
+ function expandedText(editor: Editor): string {
7
+ return typeof (editor as any).getExpandedText === 'function' ? (editor as any).getExpandedText() : editor.getText();
8
+ }
9
+ function handleEditorInput(editor: Editor, data: string) {
10
+ const paste = /^\x1b\[200~([\s\S]*)\x1b\[201~$/u.exec(data);
11
+ editor.handleInput(paste ? `\x1b[200~${pastedReplyText(paste[1])}\x1b[201~` : data);
12
+ }
13
+
14
+ class ReplyEditor {
15
+ private editor: Editor;
16
+ constructor(tui: any, theme: any) {
17
+ this.editor = new Editor(tui, { borderColor: (text: string) => theme.fg('accent', text), selectList: {
18
+ selectedPrefix: (text: string) => theme.fg('accent', text), selectedText: (text: string) => theme.fg('accent', text),
19
+ description: (text: string) => theme.fg('dim', text), scrollInfo: (text: string) => theme.fg('dim', text),
20
+ noMatch: (text: string) => theme.fg('warning', text),
21
+ } }, getKeybindings());
22
+ this.editor.disableSubmit = true;
23
+ }
24
+ get focused() { return this.editor.focused; }
25
+ set focused(value: boolean) { this.editor.focused = value; }
26
+ getValue() { return expandedText(this.editor); }
27
+ setValue(value: string) { this.editor.setText(value); }
28
+ handleInput(data: string) { handleEditorInput(this.editor, data); }
29
+ render(width: number) { const rows = this.editor.render(width); return rows.slice(1, -1); }
30
+ invalidate() { this.editor.invalidate(); }
31
+ }
32
+
33
+ type DraftEditors = { input: ReplyEditor; notes: Editor; custom: boolean; editingNotes: boolean; submittedNotes?: string; externalEditing?: boolean; editError?: string };
34
+ export type QuestionFrame = { header: string[]; lines: string[]; focus: [number, number]; footer: string };
35
+
36
+ /** SDK components belong to stable question identity, never tab position.
37
+ * Returns complete frame content/geometry; the host owns its bounded viewport. */
38
+ export class QuestionView {
39
+ focused = false;
40
+ /** A known Pi prompt temporarily owns input while this pane stays projected. */
41
+ suspended = false;
42
+ /** Focus hints exist only when the host owns the complete public route.
43
+ * These flags affect presentation, never drafts. */
44
+ chatFocused = false;
45
+ inputIsCore = true;
46
+ /** Explicit selection can currently acquire pane focus; independent of return capability. */
47
+ entryAvailable = false;
48
+ chatReturnKey: string | undefined;
49
+ focusToggleKey: string | undefined;
50
+ private disposed = false;
51
+ private drafts = new Map<string, DraftEditors>();
52
+ private model: QuestionModel;
53
+ private tui: any;
54
+ private theme: any;
55
+ private editText?: (text: string) => Promise<string | undefined>;
56
+ constructor(model: QuestionModel, tui: any, theme: any, editText?: (text: string) => Promise<string | undefined>) { this.model = model; this.tui = tui; this.theme = theme; this.editText = editText; }
57
+ private async editOutside(editors: DraftEditors) {
58
+ const edit = this.model.draftEdit(); if (!edit || !this.editText || editors.externalEditing) return;
59
+ const notes = editors.editingNotes; editors.externalEditing = true; editors.editError = undefined;
60
+ try {
61
+ const replacement = await this.editText(notes ? edit.notes : edit.reply);
62
+ if (replacement !== undefined) { if (notes) edit.replaceNotes(readable(replacement)); else edit.replaceReply(replyText(replacement)); }
63
+ } catch (error) { editors.editError = String(error); }
64
+ finally { editors.externalEditing = false; if (!this.disposed) this.tui.requestRender(true); }
65
+ }
66
+ private editors(current: QuestionState) {
67
+ const draftKey = JSON.stringify([current.tab.key, current.tab.incarnation]);
68
+ const live = new Set(this.model.tabs().filter((tab) => !tab.review).map((tab) => JSON.stringify([tab.key, tab.incarnation])));
69
+ for (const key of this.drafts.keys()) if (!live.has(key)) this.drafts.delete(key);
70
+ let editors = this.drafts.get(draftKey);
71
+ if (!editors) {
72
+ editors = { input: new ReplyEditor(this.tui, this.theme), notes: new Editor(this.tui, { borderColor: (text) => this.theme.fg('accent', text), selectList: { selectedPrefix: (text) => this.theme.fg('accent', text), selectedText: (text) => this.theme.fg('accent', text), description: (text) => this.theme.fg('dim', text), scrollInfo: (text) => this.theme.fg('dim', text), noMatch: (text) => this.theme.fg('warning', text) } }, getKeybindings()), custom: !current.question?.options?.length, editingNotes: false };
73
+ const captured = editors;
74
+ editors.notes.onSubmit = (text) => { captured.submittedNotes = readable(text); captured.editingNotes = false; this.tui.requestRender(); };
75
+ this.drafts.set(draftKey, editors);
76
+ }
77
+ if (editors.input.getValue() !== current.reply) editors.input.setValue(current.reply || '');
78
+ if (expandedText(editors.notes) !== current.notes) editors.notes.setText(current.notes || '');
79
+ editors.custom = current.custom;
80
+ editors.input.focused = this.focused && editors.custom && !editors.editingNotes;
81
+ editors.notes.focused = this.focused && editors.editingNotes;
82
+ return editors;
83
+ }
84
+ private tabs(width: number, current: QuestionState) {
85
+ const tabs = this.model.tabs(), blocking = tabs.some((tab) => tab.mode === 'blocking');
86
+ const at = tabs.findIndex((tab) => tab.key === current.tab.key && tab.incarnation === current.tab.incarnation);
87
+ const label = (index: number) => {
88
+ const tab = tabs[index], mode = tab.mode === 'blocking' ? '★ Required' : 'Async';
89
+ const title = tab.review ? 'review' : tab.header ? truncateToWidth(readable(tab.header).replace(/\s+/gu, ' ').trim(), 24, '') : String(index + 1);
90
+ return `${index === at ? '›' : ''}[${mode} ${title}]`;
91
+ };
92
+ let left = at, right = at, questions = label(at);
93
+ while (left > 0 || right < tabs.length - 1) {
94
+ const nextLeft = left > 0 ? label(left - 1) + ' ' + questions : undefined;
95
+ if (nextLeft && visibleWidth(nextLeft) <= width) { questions = nextLeft; left--; continue; }
96
+ const nextRight = right < tabs.length - 1 ? questions + ' ' + label(right + 1) : undefined;
97
+ if (nextRight && visibleWidth(nextRight) <= width) { questions = nextRight; right++; continue; }
98
+ break;
99
+ }
100
+ return this.theme.fg(blocking ? 'warning' : 'accent', truncateToWidth(questions, width, ''));
101
+ }
102
+ private keyLabel(key: string) {
103
+ return key.split('+').map((part) => ({ ctrl: 'Ctrl', shift: 'Shift', alt: 'Alt', meta: 'Meta', tab: 'Tab' })[part.toLowerCase()] || (part.length === 1 ? part.toUpperCase() : part)).join('+');
104
+ }
105
+ private caret(lines: string[], fallback: number): [number, number] {
106
+ const row = lines.findIndex((line) => line.includes(CURSOR_MARKER));
107
+ return [row < 0 ? fallback : row, (row < 0 ? fallback : row) + 1];
108
+ }
109
+ frame(width: number, inspect = false): QuestionFrame {
110
+ const current = this.model.current();
111
+ if (!current) return { header: [], lines: [], focus: [0, 1], footer: '' };
112
+ const wrap = (text: unknown) => new Text(readable(text), 0, 0).render(width);
113
+ const blocking = this.model.tabs().some((tab) => tab.mode === 'blocking');
114
+ const header = [this.tabs(width, current)];
115
+ if (blocking) header.push(this.theme.fg('warning', truncateToWidth('★ Response required · Chat waits for an answer or cancellation', width, '')));
116
+ const toggle = !blocking && this.focusToggleKey && this.keyLabel(this.focusToggleKey);
117
+ const collapse = !blocking && this.chatReturnKey && this.keyLabel(this.chatReturnKey);
118
+ const questionNavigation = `Tab next question${toggle ? ` · ${toggle} ${this.inputIsCore ? 'Chat' : 'previous input'}` : ''}`;
119
+ const notes = current.tab.mode === 'blocking' && current.question?.allowNotes !== false ? ' · Alt+N notes' : '';
120
+ const questionFooter = blocking
121
+ ? `Enter confirm · Esc ${current.tab.mode === 'async' ? 'pause' : 'cancel request'} · Tab next question${notes} · PgUp/Dn details`
122
+ : `${notes ? 'Alt+N notes · ' : ''}${questionNavigation}${collapse ? ` · ${collapse} collapse` : ''} · PgUp/Dn details · Enter confirm · Esc ${current.tab.mode === 'async' ? 'pause' : 'cancel request'}`;
123
+ const chatFooter = blocking
124
+ ? this.suspended ? '★ Response required · waiting for current Pi prompt' : '★ Response required · returning to questions'
125
+ : `${this.inputIsCore && this.chatFocused ? 'Chat editor · Tab completion' : 'Input outside questions'}${this.entryAvailable ? toggle ? ` · ${toggle} questions` : ' · /asks selects questions' : ' · Question focus unavailable'}${collapse ? ` · ${collapse} returns to questions` : ''}`;
126
+ const footer = this.theme.fg(blocking ? 'warning' : 'dim', truncateToWidth(this.focused ? questionFooter : chatFooter, width, ''));
127
+ if (current.error || current.saving) header.push(this.theme.fg('warning', truncateToWidth(current.saving ? 'Saving answer…' : current.errorCode === 'storage_unconfirmed' ? 'Storage unconfirmed · PgDn details' : 'Save failed · draft retained · PgDn details', width, '')));
128
+ if (current.paused) return { header, lines: wrap(`Paused; this question remains pending.${this.entryAvailable ? ' /asks resumes it.' : ' Draft retained.'}`), focus: [0, 1], footer };
129
+ if (current.tab.review) {
130
+ const answers = this.model.answers(current.tab.groupId);
131
+ const summary = wrap(`Review this group\n${answers.map((answer) => `${answer.questionIndex + 1}. ${answer.question}: ${[answer.selected?.join(', '), answer.answer].filter(Boolean).join('; ')}${answer.notes ? `\nNotes: ${answer.notes}` : ''}`).join('\n')}\n${answers.length < this.model.tabs().filter((tab) => tab.groupId === current.tab.groupId && !tab.review).length ? 'Some questions are unanswered; partial submission is allowed.' : 'Ready to submit.'}`);
132
+ const picker = ['Submit answers', 'Cancel'].map((label, index) => this.theme.fg(index === current.reviewChoice ? 'accent' : 'text', truncateToWidth(`${index === current.reviewChoice ? '❯' : ' '} ${index + 1}. ${label}`, width, '')));
133
+ return { header, lines: [...summary, ...picker], focus: [summary.length + current.reviewChoice, summary.length + current.reviewChoice + 1], footer };
134
+ }
135
+ const question = current.question!, editors = this.editors(current), options = question.options || [], selected = options[current.option || 0];
136
+ if (editors.editError || editors.externalEditing) {
137
+ const status = current.saving ? 'Saving · editor issue · PgDn details' : current.error ? 'Save/editor issues · PgDn details' : editors.externalEditing ? 'Editing outside Pi…' : 'Editor failed · draft retained · PgDn details';
138
+ const row = this.theme.fg('warning', truncateToWidth(status, width, '')); if (header.length > 1) header[1] = row; else header.push(row);
139
+ }
140
+ if (inspect) {
141
+ const detail = question.question + (question.context ? `\n\n${question.context}` : '') + (selected ? `\n\nSelected ${(current.option || 0) + 1}: ${selected.label}${selected.description ? `\n\n${selected.description}` : ''}${selected.preview !== undefined ? `\n\nPreview:\n${selected.preview}` : ''}` : '') + (current.reply ? `\n\nReply draft:\n${current.reply}` : '') + (current.error ? `\n\nSave diagnostic:\n${current.error}` : '') + (editors.editError ? `\n\nEditor diagnostic:\n${editors.editError}` : '');
142
+ return { header, lines: wrap(detail), focus: [0, 1], footer };
143
+ }
144
+ const lines = [...wrap(question.question), ...(question.context ? wrap(question.context) : [])], optionStart = lines.length;
145
+ let focus = optionStart, focusEnd = optionStart + 1;
146
+ for (const [index, option] of options.entries()) {
147
+ const pointer = index === current.option && !editors.custom ? '❯' : ' ';
148
+ const checkbox = question.multiSelect ? (current.checked?.includes(index) ? '[x] ' : '[ ] ') : '';
149
+ const rows = wrap(`${pointer} ${index + 1}. ${checkbox}${option.label}`);
150
+ if (index === current.option && !editors.custom) { focus = lines.length; focusEnd = focus + rows.length; }
151
+ lines.push(...rows.map((line) => this.theme.fg(index === current.option && !editors.custom ? 'accent' : 'text', line)));
152
+ }
153
+ const selectedCustom = editors.custom || current.option === options.length;
154
+ const prefix = truncateToWidth(options.length ? `${selectedCustom ? '❯' : ' '} ${options.length + 1}. Reply: ` : 'Reply: ', Math.max(0, width - 1), '');
155
+ const fieldWidth = Math.max(1, width - visibleWidth(prefix));
156
+ const input = editors.input.focused ? editors.input.render(fieldWidth)
157
+ : new Text(this.theme.fg(current.reply ? 'text' : 'dim', readable(current.reply || 'Write a reply…')), 0, 0).render(fieldWidth);
158
+ const start = lines.length;
159
+ lines.push(...input.map((line, index) => (index ? ' '.repeat(visibleWidth(prefix)) : this.theme.fg(selectedCustom ? 'accent' : 'text', prefix)) + line));
160
+ if (selectedCustom) { focus = start + this.caret(input, 0)[0]; focusEnd = focus + 1; }
161
+ if (editors.editingNotes) {
162
+ lines.push(...wrap('Notes:'));
163
+ const start = lines.length, notes = editors.notes.focused ? editors.notes.render(width) : wrap(current.notes || 'Write notes…');
164
+ lines.push(...notes); focus = start + this.caret(notes, 0)[0]; focusEnd = focus + 1;
165
+ } else if (current.notes) lines.push(...wrap(`Notes: ${current.notes}`));
166
+ if (!editors.editingNotes && !editors.custom) {
167
+ if (selected?.description) lines.push(...wrap(selected.description));
168
+ if (selected?.preview !== undefined) {
169
+ const preview = readable(selected.preview);
170
+ lines.push(...(question.plainPreview ? wrap(preview) : new Markdown(preview, 0, 0, getMarkdownTheme()).render(width)));
171
+ }
172
+ }
173
+ return { header, lines, focus: [focus, focusEnd], footer };
174
+ }
175
+ handleInput(data: string) {
176
+ const current = this.model.current(); if (this.disposed || !current || isKeyRelease(data)) return;
177
+ const kb = getKeybindings();
178
+ if (isKeyRepeat(data) && (kb.matches(data, 'tui.select.cancel') || kb.matches(data, 'tui.select.confirm') || kb.matches(data, 'tui.input.submit'))) return;
179
+ if (matchesKey(data, 'tab')) { this.model.navigate(1); return; }
180
+ if (matchesKey(data, 'shift+tab')) return;
181
+ if (current.paused || current.saving) return;
182
+ if (current.tab.review) {
183
+ if (kb.matches(data, 'tui.select.up') || kb.matches(data, 'tui.select.down')) this.model.moveReview();
184
+ else if (kb.matches(data, 'tui.select.confirm') || kb.matches(data, 'tui.input.submit')) void this.model.confirm();
185
+ else if (kb.matches(data, 'tui.select.cancel')) this.model.cancel();
186
+ return;
187
+ }
188
+ const editors = this.editors(current), options = current.question!.options || [];
189
+ if (editors.externalEditing) return;
190
+ const externalKeys = kb.getKeys('app.editor.external' as any);
191
+ if (this.editText && (externalKeys.length ? kb.matches(data, 'app.editor.external') : matchesKey(data, 'ctrl+g'))) {
192
+ if (!isKeyRepeat(data)) void this.editOutside(editors); return;
193
+ }
194
+ if (kb.matches(data, 'tui.select.cancel')) {
195
+ if (editors.editingNotes) editors.editingNotes = false;
196
+ else if (editors.custom && current.tab.mode === 'blocking' && options.length) { editors.custom = false; this.model.useChoices(); }
197
+ else this.model.cancel();
198
+ } else if (current.tab.mode === 'blocking' && current.question?.allowNotes !== false && matchesKey(data, 'alt+n')) {
199
+ editors.editingNotes = !editors.editingNotes;
200
+ } else if (editors.editingNotes) {
201
+ handleEditorInput(editors.notes, data);
202
+ const raw = editors.submittedNotes ?? expandedText(editors.notes), safe = readable(raw); editors.submittedNotes = undefined;
203
+ if (expandedText(editors.notes) !== safe) editors.notes.setText(safe);
204
+ this.model.setNotes(safe);
205
+ } else if (kb.matches(data, 'tui.select.confirm') || kb.matches(data, 'tui.input.submit')) {
206
+ if (!editors.custom && current.option === options.length) { editors.custom = true; this.model.useCustom(); }
207
+ else void this.model.confirm();
208
+ } else if (!editors.custom && (kb.matches(data, 'tui.select.up') || kb.matches(data, 'tui.select.down'))) this.model.moveOption(kb.matches(data, 'tui.select.down') ? 1 : -1);
209
+ else if (!editors.custom && current.question!.multiSelect && matchesKey(data, 'space')) { if (!isKeyRepeat(data)) this.model.toggleOption(); }
210
+ else {
211
+ const before = editors.input.getValue(); editors.input.handleInput(data);
212
+ const raw = editors.input.getValue(), safe = replyText(raw); if (safe !== raw) editors.input.setValue(safe);
213
+ if (safe !== before) {
214
+ this.model.setReply(safe); editors.custom = !!safe || !options.length;
215
+ }
216
+ }
217
+ this.tui.requestRender();
218
+ }
219
+ invalidate() { for (const draft of this.drafts.values()) { draft.input.invalidate(); draft.notes.invalidate(); } }
220
+ dispose() { this.disposed = true; this.drafts.clear(); }
221
+ }
@@ -0,0 +1,73 @@
1
+ # Threadroom service
2
+
3
+ A private, dependency-free Node 24+ package for the local Threadroom API and website. Neither service mode needs Pi or a checkout. The Pi adapter bundles this package and can lazily launch its combined mode; direct CLI commands remain foreground processes under the caller’s control.
4
+
5
+ ## Pack and run
6
+
7
+ From the source checkout:
8
+
9
+ ```sh
10
+ npm pack --workspace threadroom-service
11
+ ```
12
+
13
+ `prepack` copies the current `src/` and `public/` into generated `dist/` resources. The tarball includes the CLI and those resources, not Pi, tests, examples, or a database. There is no separate backend implementation to maintain. The package is private: use the tarball, not a registry release.
14
+
15
+ Extract the tarball into a stable directory of your choice, then run its CLI with Node 24+ (replace `/absolute/install/package` below). Package-manager installation of the tarball also exposes the `threadroom-service` executable.
16
+
17
+ ```sh
18
+ # Combined API + website at http://127.0.0.1:4310
19
+ node /absolute/install/package/bin/threadroom-service.js serve
20
+
21
+ # Or independent foreground processes:
22
+ node /absolute/install/package/bin/threadroom-service.js api
23
+ node /absolute/install/package/bin/threadroom-service.js ui
24
+ ```
25
+
26
+ Independent API and website defaults are `4310` and `4311`. All direct commands run in the foreground until Ctrl-C. The adapter’s managed combined child is detached, survives Pi reload/shutdown, and is reused by other Pi sessions after a version/storage health check. Do not use Pi's background-task launcher for everyday hosting: those tasks have different shutdown ownership. The API starts empty, without demo records. The UI never opens a database. To run directly from this checkout, use `node packages/service/bin/threadroom-service.js` with the same commands. The recognized checkout CLI uses current source even after a previous pack; an extracted package uses only its bundled snapshot.
27
+
28
+ These are **local, unauthenticated loopback services**, not safe public endpoints. The CLI forces loopback binding even if `HOST` is set. Browser CORS checks are not authentication. Do not expose them through a public proxy or tunnel without a separate security design.
29
+
30
+ ## Data and explicit endpoints
31
+
32
+ Default storage is stable across working directories:
33
+
34
+ - macOS: `~/Library/Application Support/Threadroom/threadroom.sqlite`
35
+ - Linux/other Unix: `$XDG_DATA_HOME/threadroom/threadroom.sqlite` when that variable is absolute, otherwise `~/.local/share/threadroom/threadroom.sqlite`
36
+ - Windows: `%LOCALAPPDATA%\Threadroom\threadroom.sqlite`, or `~/AppData/Local/Threadroom/threadroom.sqlite`
37
+
38
+ No existing checkout database is discovered, copied, or adopted. To select one intentionally:
39
+
40
+ ```sh
41
+ node /absolute/install/package/bin/threadroom-service.js api \
42
+ --database /absolute/data/threadroom.sqlite --port 4310
43
+ node /absolute/install/package/bin/threadroom-service.js ui \
44
+ --api-url http://127.0.0.1:4310 --port 4311
45
+ ```
46
+
47
+ `--database` requires an absolute path. Otherwise `THREADROOM_DB` is honored (a relative environment value is explicitly resolved against the invocation directory); without either, the per-user path above is used. New API directories/files have private permissions; existing directories are not chmodded. Back up the database with a SQLite-aware backup or while the API is stopped, retaining any WAL state.
48
+
49
+ Ports accept `0` for an OS-selected free port; the ready message reports the actual URL. `--port` overrides `PORT` for `serve`/API or `UI_PORT` for UI. `--api-url` overrides `THREADROOM_API_URL`. The CLI itself performs no discovery or background activation. The Pi adapter’s separate ensure boundary starts only its default loopback origin; explicit API URLs remain externally owned. For nondefault UI ports, set `THREADROOM_UI_ORIGINS` on the API to comma-separated allowed browser origins. A port-0 UI origin cannot be known until it reports readiness; configure the API's origins accordingly. `--help` and `--version` do not start services.
50
+
51
+ ## Optional macOS launchd configuration
52
+
53
+ Generating configuration is not installation or activation:
54
+
55
+ ```sh
56
+ node /absolute/install/package/bin/threadroom-service.js launchd-config \
57
+ --output-dir /absolute/review-directory \
58
+ --database /absolute/data/threadroom.sqlite
59
+ ```
60
+
61
+ This writes only `local.threadroom.api.plist` and `local.threadroom.ui.plist` in the requested directory, replacing those files if present. It never writes `~/Library/LaunchAgents`, calls `launchctl`, creates a database, or starts anything. `--api-port` and `--ui-port` select distinct, nonzero ports; `--api-url` optionally selects the UI's API endpoint. The API job receives the matching UI origins.
62
+
63
+ The two independent jobs use `KeepAlive`, an absolute Node executable and installed CLI path, an absolute database path, and a stable per-user working directory. Standard/error logs are `api.log`, `api.error.log`, `ui.log`, and `ui.error.log` in that working directory. Paths with spaces or XML characters are escaped, not shell-expanded. Keep the installed package and Node at those paths; regenerate configuration if either moves.
64
+
65
+ **Before any separately approved manual activation:** inspect the generated plists and ports, then prepare the printed working/log directory and database parent privately. For the default macOS path, the explicit preparation is:
66
+
67
+ ```sh
68
+ mkdir -p "$HOME/Library/Application Support/Threadroom"
69
+ chmod 700 "$HOME/Library/Application Support/Threadroom"
70
+ # Prepare a different explicit database parent similarly, if selected.
71
+ ```
72
+
73
+ Only after review/approval would you copy the plists to LaunchAgents and activate them using your usual launchd workflow. This package does not do that step, and generating files does not mean the service is installed, running, or configured in Pi. Running the foreground commands remains a complete alternative. Job logs may contain durable record paths and diagnostics; keep them private and manage retention yourself.
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../lib/cli.js';
3
+
4
+ try {
5
+ await run(process.argv.slice(2));
6
+ } catch (error) {
7
+ console.error(`threadroom-service: ${error.message}`);
8
+ process.exitCode = 1;
9
+ }