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,157 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { request } from 'node:http';
5
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { setTimeout as delay } from 'node:timers/promises';
8
+ import { threadroomDatabasePath } from './paths.js';
9
+
10
+ const cli = fileURLToPath(new URL('../bin/threadroom-service.js', import.meta.url));
11
+ const stderrLimit = 4000;
12
+ const storageIdentity = (database) => createHash('sha256').update(database).digest('hex').slice(0, 24);
13
+
14
+ function endpoint(value) {
15
+ const url = new URL(value);
16
+ if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost'].includes(url.hostname) ||
17
+ url.username || url.password || (url.pathname !== '/' && url.pathname !== '') || url.search || url.hash) {
18
+ throw Object.assign(new Error('Automatic Threadroom startup only owns an uncredentialed loopback HTTP origin.'), { code: 'unmanaged_endpoint' });
19
+ }
20
+ return url;
21
+ }
22
+
23
+ function probe(baseUrl, expectedStorageId, timeoutMs = 1000) {
24
+ return new Promise((resolve) => {
25
+ const url = new URL('/api/health', baseUrl);
26
+ let settled = false;
27
+ const finish = (value) => { if (settled) return; settled = true; clearTimeout(timer); resolve(value); };
28
+ const call = request(url, { method: 'GET', family: 4, agent: false, headers: { Accept: 'application/json' } }, (response) => {
29
+ const chunks = []; let bytes = 0;
30
+ response.on('data', (chunk) => {
31
+ bytes += chunk.length;
32
+ if (bytes > 64 * 1024) { response.destroy(); finish({ status: 'incompatible', reason: 'health response exceeded 64 KiB' }); }
33
+ else chunks.push(chunk);
34
+ });
35
+ response.on('error', () => finish({ status: 'unavailable' }));
36
+ response.on('end', () => {
37
+ let body;
38
+ try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); }
39
+ catch { return finish({ status: 'incompatible', reason: 'health response was not JSON' }); }
40
+ if (response.statusCode === 200 && body?.ok === true && body.service === 'threadroom' && body.apiVersion === 2 &&
41
+ body.website === true && body.storageId === expectedStorageId) return finish({ status: 'healthy', health: body });
42
+ const reason = body?.service === 'threadroom' && body?.apiVersion !== 2
43
+ ? `API version ${body?.apiVersion ?? 'missing'} is incompatible; restart the detached Threadroom service for API version 2`
44
+ : 'service identity, API version, website, or storage does not match';
45
+ finish({ status: 'incompatible', reason, health: body });
46
+ });
47
+ });
48
+ call.on('error', () => finish({ status: 'unavailable' }));
49
+ call.end();
50
+ const timer = setTimeout(() => { call.destroy(); finish({ status: 'unavailable' }); }, timeoutMs);
51
+ timer.unref?.();
52
+ });
53
+ }
54
+
55
+ function startupError(message, stderr, cause) {
56
+ const diagnostic = stderr.trim();
57
+ const text = diagnostic ? `${message}\nThreadroom service stderr:\n${diagnostic}` : message;
58
+ return Object.assign(cause === undefined ? new Error(text) : new Error(text, { cause }), { code: 'service_start_failed' });
59
+ }
60
+
61
+ /** Idempotently ensure one compatible detached local API + website process.
62
+ * The returned boundary performs no write retry; callers invoke it before each
63
+ * request so an ambiguous mutation remains ambiguous. */
64
+ export function createThreadroomServiceEnsurer({
65
+ baseUrl = 'http://127.0.0.1:4310', database = threadroomDatabasePath(), timeoutMs = 5000,
66
+ env = process.env, spawnProcess = spawn,
67
+ } = {}) {
68
+ const url = endpoint(baseUrl);
69
+ if (url.port === '0') throw Object.assign(new Error('Automatic Threadroom startup needs a stable nonzero port.'), { code: 'unmanaged_endpoint' });
70
+ if (!isAbsolute(database)) throw new Error('Automatic Threadroom startup needs an absolute database path.');
71
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 100) throw new Error('Threadroom startup timeout must be at least 100 ms.');
72
+ const databasePath = resolve(database), storageId = storageIdentity(databasePath);
73
+ const dataDir = dirname(databasePath);
74
+ const lock = join(dataDir, `.startup-${createHash('sha256').update(url.origin).digest('hex').slice(0, 12)}`);
75
+ let inflight;
76
+ async function ensureOnce() {
77
+ const initial = await probe(url, storageId);
78
+ if (initial.status === 'healthy') return initial.health;
79
+ if (initial.status === 'incompatible') throw Object.assign(new Error(`Threadroom endpoint ${url.origin} is already occupied by an incompatible runtime (${initial.reason}).`), { code: 'incompatible_service' });
80
+
81
+ mkdirSync(dataDir, { recursive: true, mode: 0o700 });
82
+ let owned = false, ownerToken;
83
+ const claim = () => {
84
+ mkdirSync(lock, { mode: 0o700 });
85
+ ownerToken = randomUUID(); writeFileSync(join(lock, 'owner'), ownerToken, { mode: 0o600 }); owned = true;
86
+ };
87
+ try {
88
+ try { claim(); }
89
+ catch (error) {
90
+ if (error?.code !== 'EEXIST') throw error;
91
+ // Never delete a lease we do not own. If its starter disappeared, the
92
+ // bounded wait below falls back to the HTTP port's atomic bind; extra
93
+ // contenders may launch, but only one compatible service can listen.
94
+ }
95
+ if (!owned) {
96
+ const deadline = Date.now() + Math.max(100, Math.floor(timeoutMs / 2));
97
+ while (Date.now() < deadline) {
98
+ const observed = await probe(url, storageId);
99
+ if (observed.status === 'healthy') return observed.health;
100
+ if (observed.status === 'incompatible') throw Object.assign(new Error(`Threadroom endpoint ${url.origin} became occupied by an incompatible runtime (${observed.reason}).`), { code: 'incompatible_service' });
101
+ await delay(100);
102
+ }
103
+ // The lease may be orphaned. Continue without mutating it; the port is
104
+ // the final cross-process ownership claim and losers exit on EADDRINUSE.
105
+ }
106
+
107
+ const afterLock = await probe(url, storageId);
108
+ if (afterLock.status === 'healthy') return afterLock.health;
109
+ if (afterLock.status === 'incompatible') throw Object.assign(new Error(`Threadroom endpoint ${url.origin} is occupied by an incompatible runtime (${afterLock.reason}).`), { code: 'incompatible_service' });
110
+
111
+ const port = url.port || '80';
112
+ const child = spawnProcess(process.execPath, [cli, 'serve', '--database', databasePath, '--port', port], {
113
+ cwd: dataDir, detached: true, windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'],
114
+ env: { ...env, THREADROOM_DB: databasePath, THREADROOM_SEED_DEMO: '0' },
115
+ });
116
+ let stderr = '', launchFailure, exit, ready = false;
117
+ const remember = (chunk) => { stderr = `${stderr}${chunk}`.slice(-stderrLimit); };
118
+ child.stderr?.on('data', remember);
119
+ child.stderr?.unref?.(); child.unref();
120
+ child.once('error', (error) => { launchFailure = error; });
121
+ child.once('exit', (code, signal) => { exit = { code, signal }; });
122
+ try {
123
+ let deadline = Date.now() + timeoutMs, raceGrace = false;
124
+ while (Date.now() < deadline) {
125
+ const observed = await probe(url, storageId);
126
+ if (observed.status === 'healthy') { ready = true; return observed.health; }
127
+ if (observed.status === 'incompatible') throw Object.assign(new Error(`Threadroom endpoint ${url.origin} became occupied by an incompatible runtime (${observed.reason}).`), { code: 'incompatible_service' });
128
+ // A fallback contender can lose the port or an early SQLite race while
129
+ // another compatible starter is still becoming ready. Preserve its
130
+ // diagnostic, but let health—not child lifetime—decide. A failed lease
131
+ // owner grants one bounded window for its waiters to take the port.
132
+ if (owned && !raceGrace && (launchFailure || exit)) { deadline += timeoutMs; raceGrace = true; }
133
+ await delay(100);
134
+ }
135
+ try { child.kill(); } catch {}
136
+ if (launchFailure) throw startupError(`Failed to launch Threadroom: ${launchFailure.message}`, stderr, launchFailure);
137
+ if (exit) throw startupError(`Threadroom exited before readiness${exit.signal ? ` with signal ${exit.signal}` : ` with code ${exit.code ?? 'unknown'}`}.`, stderr);
138
+ throw startupError('Threadroom did not become healthy before the startup deadline.', stderr);
139
+ } finally {
140
+ if (!ready && child.exitCode === null) { try { child.kill(); } catch {} }
141
+ child.stderr?.off('data', remember); child.stderr?.resume(); child.stderr?.unref?.();
142
+ }
143
+ } finally {
144
+ if (owned) {
145
+ try { if (readFileSync(join(lock, 'owner'), 'utf8') === ownerToken) rmSync(lock, { recursive: true, force: true }); }
146
+ catch (error) { if (error?.code !== 'ENOENT') throw error; }
147
+ }
148
+ }
149
+ }
150
+ return {
151
+ baseUrl: url.origin, database: databasePath, storageId,
152
+ ensure() {
153
+ if (!inflight) inflight = ensureOnce().finally(() => { inflight = undefined; });
154
+ return inflight;
155
+ },
156
+ };
157
+ }
@@ -0,0 +1,19 @@
1
+ import { homedir } from 'node:os';
2
+ import { isAbsolute, join, resolve } from 'node:path';
3
+
4
+ /** Stable per-user service data; never relative to a checkout or plugin install. */
5
+ export function threadroomDataDirectory(env = process.env, home = homedir(), platform = process.platform) {
6
+ if (platform === 'darwin') return join(home, 'Library', 'Application Support', 'Threadroom');
7
+ if (platform === 'win32') {
8
+ const local = env.LOCALAPPDATA;
9
+ return join(local && isAbsolute(local) ? local : join(home, 'AppData', 'Local'), 'Threadroom');
10
+ }
11
+ const xdg = env.XDG_DATA_HOME;
12
+ return join(xdg && isAbsolute(xdg) ? xdg : join(home, '.local', 'share'), 'threadroom');
13
+ }
14
+
15
+ /** Resolve an explicit environment value before a launcher changes cwd. */
16
+ export function threadroomDatabasePath({ value = process.env.THREADROOM_DB, cwd = process.cwd(), env = process.env,
17
+ home = homedir(), platform = process.platform } = {}) {
18
+ return value ? resolve(cwd, value) : join(threadroomDataDirectory(env, home, platform), 'threadroom.sqlite');
19
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "threadroom-service",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Independent local Threadroom API and website service",
6
+ "type": "module",
7
+ "engines": { "node": ">=24" },
8
+ "bin": { "threadroom-service": "bin/threadroom-service.js" },
9
+ "exports": {
10
+ "./ensure": "./lib/ensure.js",
11
+ "./paths": "./lib/paths.js"
12
+ },
13
+ "files": ["bin/", "lib/", "dist/", "README.md"],
14
+ "scripts": {
15
+ "build": "node scripts/build.js",
16
+ "prepack": "npm run build",
17
+ "test": "node --test test/*.test.js"
18
+ }
19
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "threadroom-pi",
3
+ "version": "0.1.0-beta.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Private blocking or nonblocking questions and optional durable Threadroom discussions for Pi",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi-extension",
10
+ "ask-user-question",
11
+ "questionnaire",
12
+ "ai-agent"
13
+ ],
14
+ "license": "MIT",
15
+ "author": "Scott Meyer <17101862+Scott-Meyer@users.noreply.github.com>",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/Scott-Meyer/threadroom.git",
19
+ "directory": "packages/pi-extension"
20
+ },
21
+ "homepage": "https://github.com/Scott-Meyer/threadroom/tree/main/packages/pi-extension#readme",
22
+ "bugs": {
23
+ "url": "https://github.com/Scott-Meyer/threadroom/issues"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "files": ["extensions/", "src/", "scripts/", "README.md", "LICENSE"],
29
+ "engines": { "node": ">=24" },
30
+ "dependencies": {
31
+ "threadroom-service": "0.1.0"
32
+ },
33
+ "bundleDependencies": ["threadroom-service"],
34
+ "scripts": {
35
+ "prepublishOnly": "node scripts/verify-release.js",
36
+ "prepack": "node scripts/stage-service.js stage",
37
+ "postpack": "node scripts/stage-service.js clean"
38
+ },
39
+ "peerDependencies": {
40
+ "@earendil-works/pi-coding-agent": "*",
41
+ "@earendil-works/pi-tui": "*",
42
+ "typebox": "*"
43
+ },
44
+ "pi": {
45
+ "extensions": ["./extensions/index.ts"],
46
+ "skills": [],
47
+ "prompts": [],
48
+ "themes": []
49
+ }
50
+ }
@@ -0,0 +1,32 @@
1
+ import { access, cp, mkdir, rm, writeFile } from 'node:fs/promises';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { join } from 'node:path';
4
+
5
+ const here = fileURLToPath(new URL('../', import.meta.url));
6
+ const service = fileURLToPath(new URL('../../service/', import.meta.url));
7
+ const target = join(here, 'node_modules', 'threadroom-service');
8
+ const marker = join(here, '.threadroom-service-pack-staged');
9
+ const action = process.argv[2];
10
+ const exists = async (path) => access(path).then(() => true, () => false);
11
+
12
+ if (action === 'clean') {
13
+ if (await exists(marker)) {
14
+ await rm(target, { recursive: true, force: true });
15
+ await rm(marker, { force: true });
16
+ }
17
+ } else if (action === 'stage') {
18
+ await rm(marker, { force: true });
19
+ if (await exists(join(service, 'scripts', 'build.js'))) {
20
+ await import(new URL('../../service/scripts/build.js', import.meta.url).href);
21
+ await rm(target, { recursive: true, force: true });
22
+ await mkdir(target, { recursive: true });
23
+ for (const entry of ['bin', 'lib', 'dist', 'README.md', 'package.json']) {
24
+ await cp(join(service, entry), join(target, entry), { recursive: true });
25
+ }
26
+ await writeFile(marker, 'staged from the Threadroom source workspace\n');
27
+ } else if (!await exists(join(target, 'package.json'))) {
28
+ throw new Error('Threadroom service source and bundled package are both missing.');
29
+ }
30
+ } else {
31
+ throw new Error('Use stage or clean.');
32
+ }
@@ -0,0 +1,85 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync, spawnSync } from 'node:child_process';
3
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { fileURLToPath, pathToFileURL } from 'node:url';
7
+
8
+ const repositoryRoot = fileURLToPath(new URL('../../../', import.meta.url));
9
+ const sdk = process.env.THREADROOM_PI_SDK_ROOT;
10
+ if (!sdk) throw new Error('Set THREADROOM_PI_SDK_ROOT to the supported installed Pi package before checking a release artifact.');
11
+
12
+ const directory = await mkdtemp(join(tmpdir(), 'threadroom-pi-packed-'));
13
+ const agentDir = join(directory, 'agent');
14
+ process.env.PI_CODING_AGENT_DIR = agentDir;
15
+ process.env.THREADROOM_API_URL = '::release-check-must-not-connect::';
16
+ try {
17
+ const packed = spawnSync('npm', ['pack', '--workspace', 'threadroom-pi', '--json', '--pack-destination', directory], {
18
+ cwd: repositoryRoot,
19
+ encoding: 'utf8',
20
+ timeout: 120_000,
21
+ });
22
+ assert.equal(packed.status, 0, packed.stderr || packed.stdout);
23
+ const starts = [packed.stdout.indexOf('\n{'), packed.stdout.indexOf('\n[')].filter(index => index >= 0);
24
+ const jsonStart = starts.length ? Math.min(...starts) + 1 : 0;
25
+ const report = JSON.parse(packed.stdout.slice(jsonStart));
26
+ const packedPackage = Array.isArray(report) ? report[0] : report['threadroom-pi'] || Object.values(report)[0];
27
+ assert.ok(packedPackage);
28
+ const tarball = join(directory, packedPackage.filename);
29
+ const archive = execFileSync('tar', ['-tzf', tarball], { encoding: 'utf8' }).split('\n');
30
+ for (const path of [
31
+ 'package/extensions/index.ts',
32
+ 'package/LICENSE',
33
+ 'package/node_modules/threadroom-service/package.json',
34
+ 'package/node_modules/threadroom-service/bin/threadroom-service.js',
35
+ 'package/node_modules/threadroom-service/lib/ensure.js',
36
+ 'package/node_modules/threadroom-service/lib/paths.js',
37
+ 'package/node_modules/threadroom-service/dist/src/main.js',
38
+ 'package/node_modules/threadroom-service/dist/public/index.html',
39
+ ]) assert.ok(archive.includes(path), `Packed artifact is missing ${path}`);
40
+
41
+ const installRoot = join(directory, 'install');
42
+ const installed = spawnSync('npm', ['install', '--prefix', installRoot, '--ignore-scripts', '--legacy-peer-deps', '--offline', tarball], {
43
+ encoding: 'utf8',
44
+ timeout: 120_000,
45
+ });
46
+ assert.equal(installed.status, 0, installed.stderr || installed.stdout);
47
+ const installedRoot = join(installRoot, 'node_modules', 'threadroom-pi');
48
+ const installedManifest = JSON.parse(await readFile(join(installedRoot, 'package.json'), 'utf8'));
49
+ assert.equal(installedManifest.name, 'threadroom-pi');
50
+ assert.equal(installedManifest.version, packedPackage.version);
51
+ const bundledManifest = JSON.parse(await readFile(join(installedRoot, 'node_modules', 'threadroom-service', 'package.json'), 'utf8'));
52
+ assert.equal(bundledManifest.name, 'threadroom-service');
53
+
54
+ const { loadExtensions } = await import(pathToFileURL(join(sdk, 'dist/core/extensions/loader.js')).href);
55
+ const { SessionManager } = await import(pathToFileURL(join(sdk, 'dist/core/session-manager.js')).href);
56
+ const target = join(installedRoot, 'extensions', 'index.ts');
57
+ const loaded = await loadExtensions([target], installRoot);
58
+ assert.deepEqual(loaded.errors, []);
59
+ assert.equal(loaded.extensions.length, 1);
60
+ const extension = loaded.extensions[0];
61
+ assert.ok(extension.tools.has('ask_user_question'));
62
+ assert.equal(extension.tools.has('ask_user_question_async'), false);
63
+
64
+ let activeTools = [...extension.tools.keys()];
65
+ loaded.runtime.getActiveTools = () => [...activeTools];
66
+ loaded.runtime.setActiveTools = names => { activeTools = [...names]; };
67
+ loaded.runtime.appendEntry = () => {};
68
+ loaded.runtime.sendMessage = () => {};
69
+ const context = {
70
+ mode: 'print', hasUI: false, cwd: installRoot, isIdle: () => true,
71
+ sessionManager: SessionManager.inMemory(installRoot),
72
+ ui: { setStatus() {}, notify() {} },
73
+ };
74
+ for (const handler of extension.handlers.get('session_start') || []) await handler({}, context);
75
+ assert.deepEqual(activeTools, ['ask_user_question'], 'Shared tools must remain inactive after a clean default start.');
76
+ const ask = extension.tools.get('ask_user_question').definition;
77
+ await assert.rejects(() => ask.execute('release-print-check', { questions: [{
78
+ question: 'Can this clean package present a private question?', options: [{ label: 'Yes' }, { label: 'No' }],
79
+ }] }, undefined, undefined, context), { code: 'unsupported_host' });
80
+ for (const handler of extension.handlers.get('session_shutdown') || []) await handler({}, context);
81
+
82
+ console.log(`Verified ${packedPackage.filename}: clean offline install, bundled service, installed Pi load, private tool, and default-off shared lane.`);
83
+ } finally {
84
+ await rm(directory, { recursive: true, force: true });
85
+ }
@@ -0,0 +1,79 @@
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
+ import { readFile, readdir } from 'node:fs/promises';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { join } from 'node:path';
5
+
6
+ const packageRoot = fileURLToPath(new URL('../', import.meta.url));
7
+ const repositoryRoot = fileURLToPath(new URL('../../../', import.meta.url));
8
+ const manifest = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8'));
9
+ const serviceManifest = JSON.parse(await readFile(join(repositoryRoot, 'packages/service/package.json'), 'utf8'));
10
+ const readme = await readFile(join(packageRoot, 'README.md'), 'utf8');
11
+ const license = await readFile(join(packageRoot, 'LICENSE'), 'utf8');
12
+ const problems = [];
13
+
14
+ const expect = (condition, message) => { if (!condition) problems.push(message); };
15
+ expect(manifest.name === 'threadroom-pi', 'Package name must remain threadroom-pi.');
16
+ expect(/^0\.1\.0-beta\.\d+$/.test(manifest.version), 'Version must be a 0.1.0 beta prerelease.');
17
+ expect(manifest.private === false, 'Package must explicitly set private=false.');
18
+ expect(manifest.license === 'MIT', 'Package metadata must declare the selected MIT license.');
19
+ expect(manifest.publishConfig?.access === 'public', 'publishConfig.access must be public.');
20
+ if (process.env.npm_lifecycle_event === 'prepublishOnly') {
21
+ expect(process.env.npm_config_tag === 'beta', 'Publishing requires an explicit --tag beta; refusing npm\'s latest default.');
22
+ }
23
+ expect(manifest.repository?.url === 'git+https://github.com/Scott-Meyer/threadroom.git', 'Repository provenance is missing or unexpected.');
24
+ expect(manifest.homepage && manifest.bugs?.url, 'Homepage and issue metadata are required.');
25
+ expect(manifest.keywords?.includes('pi-package'), 'Pi package discovery keyword is missing.');
26
+ expect(manifest.pi?.extensions?.length === 1 && manifest.pi.extensions[0] === './extensions/index.ts', 'Published Pi entry point is unexpected.');
27
+ expect(manifest.bundleDependencies?.length === 1 && manifest.bundleDependencies[0] === 'threadroom-service', 'The local service must remain the sole bundled dependency.');
28
+ expect(manifest.dependencies?.['threadroom-service'] === serviceManifest.version, 'Bundled service dependency must match the staged service version.');
29
+ expect(['@earendil-works/pi-coding-agent', '@earendil-works/pi-tui', 'typebox'].every(name => manifest.peerDependencies?.[name] === '*'), 'Pi-provided imports must remain wildcard peer dependencies.');
30
+ expect(manifest.files?.includes('LICENSE'), 'The published file list must include LICENSE.');
31
+ expect(readme.includes('pi install npm:threadroom-pi@beta'), 'README must contain the beta Pi installation command.');
32
+ expect(readme.includes('shared Threadroom lane is experimental, off by default'), 'README must keep the unfinished shared lane boundary visible.');
33
+ expect(license.startsWith('MIT License\n') && license.includes('Copyright (c) 2026 Scott Meyer'), 'LICENSE does not contain the selected MIT grant and copyright.');
34
+
35
+ const releaseInputs = ['packages/pi-extension', 'packages/service', 'src', 'public'];
36
+ try {
37
+ const dirty = execFileSync('git', ['status', '--porcelain=v1', '--untracked-files=all', '--', ...releaseInputs], {
38
+ cwd: repositoryRoot,
39
+ encoding: 'utf8',
40
+ }).trim();
41
+ expect(!dirty, `Release inputs are not committed:\n${dirty}`);
42
+ execFileSync('git', ['diff', '--check', '--', ...releaseInputs], { cwd: repositoryRoot, stdio: 'pipe' });
43
+ } catch (error) {
44
+ if (!problems.some(problem => problem.startsWith('Release inputs are not committed:'))) {
45
+ problems.push(`Git release-input verification failed: ${error instanceof Error ? error.message : String(error)}`);
46
+ }
47
+ }
48
+
49
+ if (problems.length) {
50
+ console.error(`threadroom-pi release check failed:\n\n- ${problems.join('\n- ')}`);
51
+ process.exit(1);
52
+ }
53
+
54
+ console.log(`threadroom-pi ${manifest.version} metadata and release inputs are ready for a beta publish.`);
55
+ if (process.argv.includes('--full')) {
56
+ const sdk = process.env.THREADROOM_PI_SDK_ROOT;
57
+ if (!sdk) throw new Error('Set THREADROOM_PI_SDK_ROOT before running the full release check.');
58
+ const testDirectory = join(repositoryRoot, 'packages/pi-extension/test');
59
+ const tests = (await readdir(testDirectory)).filter(name => name.endsWith('.test.js')).sort().map(name => join(testDirectory, name));
60
+ const checked = spawnSync(process.execPath, ['--test', ...tests], {
61
+ cwd: repositoryRoot,
62
+ encoding: 'utf8',
63
+ timeout: 300_000,
64
+ stdio: 'inherit',
65
+ env: { ...process.env, THREADROOM_PI_TUI_SMOKE: '1' },
66
+ });
67
+ if (checked.error) throw checked.error;
68
+ if (checked.status !== 0) throw new Error(`Pi extension tests failed with status ${checked.status}.`);
69
+
70
+ for (const [command, args] of [
71
+ [process.execPath, [join(packageRoot, 'scripts/verify-packed.js')]],
72
+ ['npm', ['publish', '--workspace', 'threadroom-pi', '--dry-run', '--tag', 'beta', '--access', 'public']],
73
+ ]) {
74
+ const result = spawnSync(command, args, { cwd: repositoryRoot, encoding: 'utf8', timeout: 300_000, stdio: 'inherit', env: process.env });
75
+ if (result.error) throw result.error;
76
+ if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} failed with status ${result.status}.`);
77
+ }
78
+ console.log(`threadroom-pi ${manifest.version} passed the full beta release check; nothing was published.`);
79
+ }
package/src/client.js ADDED
@@ -0,0 +1,135 @@
1
+ // Rendering-independent Node transport; no Pi, website, database or fetch globals.
2
+ import { HttpTransport } from './http-transport.js';
3
+ export class ThreadroomError extends Error {
4
+ constructor(message, { status, ambiguous = false } = {}) {
5
+ super(message); this.name = 'ThreadroomError'; this.status = status; this.ambiguous = ambiguous;
6
+ }
7
+ }
8
+
9
+ function serviceUrl(value) {
10
+ const url = new URL(value);
11
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.href.includes('?') || url.href.includes('#')) {
12
+ throw new Error('Threadroom needs HTTP(S) addresses without embedded credentials, query strings, or fragments.');
13
+ }
14
+ return url.href.replace(/\/$/, '');
15
+ }
16
+
17
+ export class ThreadroomClient {
18
+ constructor(baseUrl = 'http://127.0.0.1:4310', { uiUrl = baseUrl, timeoutMs = 15000, beforeConnect } = {}) {
19
+ this.baseUrl = serviceUrl(baseUrl);
20
+ this.uiUrl = serviceUrl(uiUrl);
21
+ this.timeoutMs = timeoutMs;
22
+ this.beforeConnect = beforeConnect;
23
+ this.lifetime = new AbortController();
24
+ this.transport = new HttpTransport();
25
+ }
26
+ /** Cancel this client's requests/streams and release its sockets. Permanent,
27
+ * idempotent, and independent of Participation.close() and other clients. */
28
+ close() { this.lifetime.abort(new Error('Threadroom client is closed.')); return this.transport.close(); }
29
+ link(id) { return `${this.uiUrl}/threads/${encodeURIComponent(id)}`; }
30
+ async ready(signal) {
31
+ signal.throwIfAborted();
32
+ if (!this.beforeConnect) return;
33
+ const work = Promise.resolve().then(() => this.beforeConnect({ signal }));
34
+ await new Promise((resolve, reject) => {
35
+ const abort = () => reject(signal.reason);
36
+ signal.addEventListener('abort', abort, { once: true });
37
+ work.then((value) => { signal.removeEventListener('abort', abort); resolve(value); },
38
+ (error) => { signal.removeEventListener('abort', abort); reject(error); });
39
+ });
40
+ signal.throwIfAborted();
41
+ }
42
+ async request(path, { method = 'GET', input, key, signal } = {}) {
43
+ const combined = AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(this.timeoutMs), ...(signal ? [signal] : [])]);
44
+ await this.ready(combined);
45
+ let response;
46
+ try {
47
+ response = await this.transport.open(`${this.baseUrl}${path}`, { method, signal: combined,
48
+ headers: { 'Content-Type': 'application/json', ...(key ? { 'Idempotency-Key': key } : {}) },
49
+ ...(input !== undefined ? { body: JSON.stringify(input) } : {}) });
50
+ if (response.statusCode >= 300 && response.statusCode < 400) {
51
+ response.destroy();
52
+ throw new Error('Threadroom redirects are not followed.');
53
+ }
54
+ const chunks = [];
55
+ for await (const chunk of response) chunks.push(chunk);
56
+ const result = JSON.parse(Buffer.concat(chunks).toString('utf8'));
57
+ if (response.statusCode < 200 || response.statusCode >= 300) {
58
+ throw new ThreadroomError(result.error || `HTTP ${response.statusCode}`, { status: response.statusCode });
59
+ }
60
+ return result;
61
+ } catch (error) {
62
+ if (error instanceof ThreadroomError) throw error;
63
+ throw new ThreadroomError(`Threadroom connection failed: ${error.message}`, { ambiguous: method !== 'GET' });
64
+ }
65
+ }
66
+ publish(input, options = {}) { return this.request('/api/ask', { ...options, method: 'POST', input }); }
67
+ read(id, options) { return this.request(`/api/nodes/${encodeURIComponent(id)}`, options); }
68
+ respond(id, input, options = {}) { return this.request(`/api/nodes/${encodeURIComponent(id)}/respond`, { ...options, method: 'POST', input }); }
69
+ tree(options) { return this.request('/api/tree', options); }
70
+
71
+ // Owned Node SSE supports cancellation/replay without EventSource or fetch.
72
+ async *events(after, signal, onOpen = () => {}) {
73
+ const combined = AbortSignal.any([this.lifetime.signal, ...(signal ? [signal] : [])]);
74
+ await this.ready(combined);
75
+ const response = await this.transport.open(`${this.baseUrl}/api/stream?after=${after}`, { signal: combined,
76
+ headers: { Accept: 'text/event-stream' } });
77
+ if (response.statusCode < 200 || response.statusCode >= 300 ||
78
+ !response.headers['content-type']?.includes('text/event-stream')) {
79
+ response.destroy();
80
+ throw new ThreadroomError(`Threadroom stream failed (HTTP ${response.statusCode})`);
81
+ }
82
+ const decoder = new TextDecoder();
83
+ let buffer = '', data = [], eventType = '', frameSize = 0;
84
+ try {
85
+ onOpen();
86
+ for await (const value of response) {
87
+ buffer += decoder.decode(value, { stream: true });
88
+ if (buffer.length > 1024 * 1024) throw new ThreadroomError('Threadroom stream frame exceeded 1 MiB');
89
+ let newline;
90
+ while ((newline = buffer.indexOf('\n')) !== -1) {
91
+ const line = buffer.slice(0, newline).replace(/\r$/, '');
92
+ buffer = buffer.slice(newline + 1);
93
+ if (!line) {
94
+ if (data.length && eventType === 'change') yield JSON.parse(data.join('\n'));
95
+ data = []; eventType = ''; frameSize = 0;
96
+ } else {
97
+ frameSize += line.length;
98
+ if (frameSize > 1024 * 1024) throw new ThreadroomError('Threadroom stream frame exceeded 1 MiB');
99
+ if (line.startsWith('event:')) eventType = line.slice(6).trim();
100
+ else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''));
101
+ }
102
+ }
103
+ }
104
+ throw new ThreadroomError('Threadroom stream disconnected');
105
+ } finally { response.destroy(); }
106
+ }
107
+ }
108
+
109
+ // Readable records never require executing an old authored document. Large source,
110
+ // image data and arbitrary values remain at their durable address, not in every turn.
111
+ export function readable(record, client) {
112
+ const node = record.node;
113
+ const summarize = (n) => ({ id: n.id, parentId: n.parentId, title: n.title,
114
+ body: clip(n.body, n === node ? 8000 : 1500), expectsAnswer: n.expectsAnswer, status: n.status,
115
+ author: n.author, createdAt: n.createdAt, url: client.link(n.id),
116
+ ...(n.presentation ? { presentation: { kind: n.presentation.kind,
117
+ revision: n.presentation.revision, fallback: clip(n.presentation.fallback, 4000),
118
+ url: `${client.baseUrl}/api/nodes/${encodeURIComponent(n.id)}/presentation` } } : {}),
119
+ ...(n.response ? { response: { ...n.response,
120
+ selections: compactValues(n.response.selections, n === node ? 6000 : 1500) } } : {}) });
121
+ return { node: summarize(node), ancestors: (record.ancestors || []).map((ancestor) => ({
122
+ id: ancestor.id, parentId: ancestor.parentId, title: clip(ancestor.title, 2000),
123
+ expectsAnswer: ancestor.expectsAnswer, status: ancestor.status, author: ancestor.author,
124
+ })), counts: record.counts,
125
+ children: (record.children || []).slice(-10).map(summarize),
126
+ ...(record.children?.length > 10 ? { omittedChildren: record.children.length - 10 } : {}),
127
+ url: client.link(node.id), ...(record.deduplicated !== undefined ? { deduplicated: record.deduplicated } : {}) };
128
+ }
129
+ function clip(value, length) {
130
+ return typeof value === 'string' && value.length > length ? `${value.slice(0, length)}\n[Truncated; full content at the node URL]` : value;
131
+ }
132
+ function compactValues(values, limit) {
133
+ if (JSON.stringify(values || []).length <= limit) return values;
134
+ return { omitted: true, reason: 'Large semantic values or images; retrieve the durable node for the full selections.' };
135
+ }
package/src/config.js ADDED
@@ -0,0 +1,57 @@
1
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ function readLayer(path) {
6
+ let value;
7
+ try { value = JSON.parse(readFileSync(path, 'utf8')); }
8
+ catch (error) {
9
+ if (error?.code === 'ENOENT') return { present: false };
10
+ return { present: true, error: `Cannot read ${path}: ${error instanceof Error ? error.message : String(error)}` };
11
+ }
12
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
13
+ return { present: true, error: `${path} must contain a JSON object.` };
14
+ }
15
+ const unknown = Object.keys(value).filter((key) => key !== 'shared');
16
+ if (unknown.length) return { present: true, error: `${path} has unknown setting${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}.` };
17
+ if (value.shared !== undefined && typeof value.shared !== 'boolean') {
18
+ return { present: true, error: `${path} setting "shared" must be true or false.` };
19
+ }
20
+ return { present: true, value: value.shared };
21
+ }
22
+
23
+ /** Locate the extension-owned global default and project override. */
24
+ export function threadroomConfigPaths({ cwd, agentDir, configDirName = '.pi' }) {
25
+ return Object.freeze({ globalPath: join(agentDir, 'threadroom.json'), projectPath: join(cwd, configDirName, 'threadroom.json') });
26
+ }
27
+
28
+ /** Resolve the optional shared lane. Project configuration is never read before
29
+ * Pi has established project trust. An invalid effective layer fails closed;
30
+ * an explicit trusted-project value can supersede a lower-layer warning. */
31
+ export function resolveThreadroomConfig({ cwd, agentDir, configDirName = '.pi', projectTrusted = false }) {
32
+ const { globalPath, projectPath } = threadroomConfigPaths({ cwd, agentDir, configDirName });
33
+ let enabled = false, source = 'default';
34
+ const warnings = [];
35
+ const global = readLayer(globalPath);
36
+ if (global.error) { warnings.push(global.error); enabled = false; source = 'invalid'; }
37
+ else if (global.value !== undefined) { enabled = global.value; source = 'global'; }
38
+ if (projectTrusted) {
39
+ const project = readLayer(projectPath);
40
+ if (project.error) { warnings.push(project.error); enabled = false; source = 'invalid'; }
41
+ else if (project.value !== undefined) { enabled = project.value; source = 'project'; }
42
+ }
43
+ return Object.freeze({ enabled, source, globalPath, projectPath, warnings: Object.freeze(warnings) });
44
+ }
45
+
46
+ /** Persist the one owned setting. Undefined removes an override and inherits the
47
+ * lower scope (or the built-in off default). */
48
+ export function writeThreadroomConfig({ path, shared }) {
49
+ if (shared === undefined) { rmSync(path, { force: true }); return; }
50
+ if (typeof shared !== 'boolean') throw new TypeError('Threadroom shared setting must be boolean or undefined.');
51
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
52
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
53
+ try {
54
+ writeFileSync(temporary, `${JSON.stringify({ shared }, null, 2)}\n`, { mode: 0o600 });
55
+ renameSync(temporary, path);
56
+ } finally { rmSync(temporary, { force: true }); }
57
+ }