@dreamteamer/web 0.1.0 → 0.3.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.
package/src/extension.js CHANGED
@@ -1,21 +1,47 @@
1
- // The engine extension: `dt web [url|status|stop] [flags]`, and a `web` row in `dt doctor`.
2
- import { main, parseArgs } from './cli.js';
3
- import { running } from './runtime.js';
1
+ // The engine extension: `dt web …` (cli.js's grammar), `dt serve` — a client token of the pane's own server, the
2
+ // one process (cr-modules-017 §8) — and a `web` row in `dt doctor`: running or not, the level, and the hook.
3
+ import { main, parseArgs, USAGE } from './cli.js';
4
+ import { hookInstalled } from './hook.js';
5
+ import { probeServer, stateFile } from './runtime.js';
6
+ import { readWorkspaceState } from './state.js';
4
7
 
5
8
  export const apiVersion = 1;
6
- const USAGE = ' web [url|status|stop] [--port <n>] [--no-open] [--idle <minutes>] [--owner-pid <pid>] the UI in your browser over this workspace (loopback; prints a sign-in URL)';
9
+ export const SERVE_IS_TOKEN = 'dt serve is dt web token now: loopback only, a client token of the pane\'s server — dt web token [--read|--edit]';
10
+ const WEB_USAGE = ` web [open|show|select|selection|hook|token|list|revoke|status|stop|start] … the pane: the UI over this workspace on loopback, its level, its selection and the chat's hook (${USAGE.replace(/^usage: /, '')})`;
11
+ const SERVE_USAGE = ' serve [--read|--edit] [--name <n>] a client token and the API base of this workspace\'s dt web server (dt web token)';
7
12
 
8
13
  export default function activate() {
9
14
  return {
10
15
  commands: {
11
16
  web: {
12
- usage: USAGE,
17
+ usage: WEB_USAGE,
13
18
  async run(ws, argv) { parseArgs(argv); return main(ws.root, argv); },
14
19
  },
20
+ serve: {
21
+ usage: SERVE_USAGE,
22
+ async run(ws, argv) {
23
+ if (argv.some((a) => /^--(host|port|token)(=|$)/.test(a))) { console.error(`✖ ${SERVE_IS_TOKEN}`); return 2; }
24
+ const args = ['token', ...argv];
25
+ if (!argv.some((a) => /^--name(=|$)/.test(a))) args.push('--name', 'serve');
26
+ parseArgs(args);
27
+ return main(ws.root, args);
28
+ },
29
+ },
15
30
  },
16
- doctor({ root }) {
17
- const r = running(root);
18
- return [{ label: 'web', state: 'ok', detail: r ? `running at http://127.0.0.1:${r.port} · pid ${r.pid}` : 'not running' }];
31
+ async doctor({ root }) {
32
+ // running = the server proves it is the runtime file's, never a live pid alone (runtime.js `probeServer`)
33
+ const p = await probeServer(stateFile(root));
34
+ const r = p?.proven ? p : null;
35
+ let level = 'edit';
36
+ try { level = readWorkspaceState(root).level; } catch { /* unreadable: the default */ }
37
+ const word = level[0].toUpperCase() + level.slice(1);
38
+ const rows = [p?.state === 'silent'
39
+ ? { label: 'web', state: 'warn', detail: `not answering: pid ${p.pid} at http://127.0.0.1:${p.port} is alive but gave no identity proof within 2 s · level ${word}`, fix: 'dt web stop --force' }
40
+ : { label: 'web', state: 'ok', detail: `${r ? `running at http://127.0.0.1:${r.port} · pid ${r.pid}` : 'not running'} · level ${word}` }];
41
+ rows.push(hookInstalled(root)
42
+ ? { label: 'web hook', state: 'ok', detail: 'the chat sees the pane (.claude/settings.local.json)' }
43
+ : { label: 'web hook', state: 'warn', detail: 'the chat cannot see the pane', fix: 'dt web hook --install' });
44
+ return rows;
19
45
  },
20
46
  };
21
47
  }
package/src/handoff.js ADDED
@@ -0,0 +1,67 @@
1
+ // The workspace switcher's server half (cr-modules-017 §7): the workspaces `dt web open` registered on this machine,
2
+ // and the handoff of the pane to one of them. A handoff reaches only a REGISTERED id; it starts the target through
3
+ // the dt-web the target itself has installed, asks the target (its control token, the same user) for a pairing capped
4
+ // at the lower of this page's level and the target's stored level — never writing that stored level (the 03:45
5
+ // revision) — and leaves a movedTo the hook answers once.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { lowerLevel } from '@dreamteamer/protocol/pane-scope';
9
+ import { ensureRunning } from './cli.js';
10
+ import { probeServer, runtimeDir as defaultRuntimeDir, stateFile } from './runtime.js';
11
+ import { lookup, nameNow, readRegistry, readWorkspaceState } from './state.js';
12
+
13
+ const TARGET_BIN = path.join('node_modules', '@dreamteamer', 'web', 'bin', 'dt-web.mjs');
14
+ /** Running = the server its runtime file names proves it is that server (runtime.js `probeServer`), not a live pid. */
15
+ const isRunning = async (root, rdir) => {
16
+ try { return (await probeServer(stateFile(root, rdir), { timeoutMs: 1000 }))?.proven === true; } catch { return false; }
17
+ };
18
+
19
+ /** `GET /_dt/workspaces`: `[{ id, name, running, current }]` — never a root path. */
20
+ export async function listWorkspaces({ selfId, stateDir, runtimeDir = defaultRuntimeDir() }) {
21
+ return Promise.all(readRegistry(stateDir).map(async (e) => ({ id: e.id, name: nameNow(e), running: fs.existsSync(e.root) && await isRunning(e.root, runtimeDir), current: e.id === selfId })));
22
+ }
23
+
24
+ /** What a registered root lacks to be handed the pane, or null. */
25
+ export function missingFor(root) {
26
+ let pkg = null;
27
+ try { pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); } catch { /* none */ }
28
+ if (!pkg || typeof pkg !== 'object' || !('dreamteamer' in pkg)) return 'its folder, or its package.json with a "dreamteamer" key, is gone';
29
+ if (!fs.existsSync(path.join(root, TARGET_BIN))) return `${TARGET_BIN.split(path.sep).join('/')} is not installed there (npm i @dreamteamer/web)`;
30
+ return null;
31
+ }
32
+
33
+ /**
34
+ * `POST /_dt/handoff { id }`. → `{ status, body }`: 200 `{ url }`, 404 for an id never registered, 409 for a root
35
+ * that cannot serve, 400 for this workspace itself, 502 when the target did not pair.
36
+ * @param {{ id: unknown, pageLevel: 'read'|'edit'|'admin', selfId: string, stateDir: string, runtimeDir?: string,
37
+ * moved: (to: { name: string, at: string }) => void, now?: () => number }} o
38
+ */
39
+ export async function handoff({ id, pageLevel, selfId, stateDir, runtimeDir = defaultRuntimeDir(), moved, now = Date.now }) {
40
+ const found = typeof id === 'string' && /^[0-9a-f]{16}$/.test(id) ? lookup(id, stateDir) : null;
41
+ if (!found) return { status: 404, body: { error: `no workspace ${String(id)} was opened on this machine` } };
42
+ const entry = { ...found, name: nameNow(found) };
43
+ if (entry.id === selfId) return { status: 400, body: { error: `the pane already shows ${entry.name}` } };
44
+ const missing = missingFor(entry.root);
45
+ if (missing) return { status: 409, body: { error: `${entry.name} cannot take the pane: ${missing}` } };
46
+ const env = { ...process.env, DT_WEB_RUNTIME_DIR: runtimeDir, DT_WEB_STATE_DIR: stateDir };
47
+ let rt;
48
+ try {
49
+ rt = await ensureRunning(entry.root, { env, bin: path.join(entry.root, TARGET_BIN), log: () => {} });
50
+ } catch (e) {
51
+ return { status: 502, body: { error: `${entry.name} did not start: ${String(e?.message ?? e).split('\n')[0]}` } };
52
+ }
53
+ const cap = lowerLevel(pageLevel, readWorkspaceState(entry.root, stateDir).level);
54
+ let url = null;
55
+ try {
56
+ const r = await fetch(`http://127.0.0.1:${rt.port}/_dt/pair`, {
57
+ method: 'POST',
58
+ headers: { authorization: `Bearer ${rt.controlToken}`, 'content-type': 'application/json' },
59
+ body: JSON.stringify({ cap }),
60
+ signal: AbortSignal.timeout(10_000),
61
+ });
62
+ if (r.ok) url = (await r.json())?.url ?? null;
63
+ } catch { /* below */ }
64
+ if (typeof url !== 'string' || !url.startsWith(`http://127.0.0.1:${rt.port}/sign-in/`)) return { status: 502, body: { error: `${entry.name} did not pair` } };
65
+ moved({ name: entry.name, at: new Date(now()).toISOString() });
66
+ return { status: 200, body: { url } };
67
+ }
package/src/headers.js CHANGED
@@ -1,24 +1,30 @@
1
- // The headers every response carries, and the app page's exact Content-Security-Policy (README § security).
1
+ // The headers every response carries, and the policies (README § security; cr-modules-017 §1 with the 03:45
2
+ // revision's form-action 'none' and the plan's reading 6: record files frame inline under /_files/).
2
3
  export function baseHeaders(res) {
3
4
  res.setHeader('Referrer-Policy', 'no-referrer');
4
5
  res.setHeader('X-Content-Type-Options', 'nosniff');
5
6
  res.removeHeader('X-Powered-By');
6
7
  }
7
8
 
8
- export function appCsp(nonce, filesOrigin, filesKey) {
9
+ /** The app page's policy on the one listener, `host` the Host the request arrived on: scripts are this page's nonce
10
+ * plus the app path only, so nothing under /_files/ or /_frames/ can run as app code. */
11
+ export function appCsp(nonce, host) {
9
12
  return [
10
- "default-src 'self'",
11
- `script-src 'self' 'nonce-${nonce}'`,
13
+ "default-src 'none'",
14
+ `script-src 'nonce-${nonce}' http://${host}/app/`,
12
15
  "style-src 'self' 'unsafe-inline'",
13
- `img-src 'self' ${filesOrigin} data: blob:`,
16
+ "img-src 'self' data: blob:",
14
17
  "font-src 'self' data:",
15
18
  "connect-src 'self'",
16
- filesKey ? `frame-src ${filesOrigin}/${filesKey}/_dt/frame/ ${filesOrigin}/${filesKey}/w/` : `frame-src ${filesOrigin}`,
17
- "frame-ancestors 'none'",
18
- "base-uri 'none'",
19
+ `frame-src http://${host}/_frames/ http://${host}/_files/`,
19
20
  "form-action 'none'",
21
+ "base-uri 'none'",
22
+ "object-src 'none'",
23
+ "frame-ancestors 'none'",
20
24
  ].join('; ');
21
25
  }
22
26
 
23
- /** A record file on the files origin: script off, a unique origin. */
27
+ /** A record file under /_files/: script off, a unique origin. */
24
28
  export const FILE_CSP = 'sandbox';
29
+ /** A module frame document under /_frames/: scripts, never the page's origin. */
30
+ export const FRAME_CSP_PREFIX = 'sandbox allow-scripts';
package/src/hook.js ADDED
@@ -0,0 +1,150 @@
1
+ // The prompt hook (cr-modules-017 §6): what the chat sees of the pane. `dt web hook --install` writes ONE
2
+ // UserPromptSubmit entry — the absolute node that ran the install and the absolute dt-web-hook.mjs, each quoted for the shell — into the
3
+ // workspace's .claude/settings.local.json (per machine, never committed). The bin reads the prompt's payload, finds
4
+ // the workspace from its cwd, reads that workspace's runtime file and asks its server GET /_dt/pane?hook=1 inside a
5
+ // 1.5 s budget; it prints at most three lines, only while a page is connected (or the one moved line), and on any
6
+ // failure prints nothing. It never starts a server, and writes only through the server. Before it sends the control
7
+ // token or prints a word it makes the server prove it is the runtime file's (runtime.js `proves`): a dead pid, or a
8
+ // port another program answers on, prints nothing.
9
+ //
10
+ // This file is loaded by the hook on every prompt: it imports node built-ins and runtime.js only.
11
+ import { spawnSync } from 'node:child_process';
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { alive, proves, readState, runtimeDir as defaultRuntimeDir, stateFile } from './runtime.js';
16
+
17
+ export const HOOK_BUDGET_MS = 1500;
18
+ export const MAX_HOOK_LINES = 3;
19
+ export const HOOK_BIN = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'dt-web-hook.mjs');
20
+ const SETTINGS = path.join('.claude', 'settings.local.json');
21
+ const OURS = 'dt-web-hook.mjs';
22
+
23
+ /** One argument for the shell the harness runs the command in. A path with none of the four characters the shell
24
+ * still reads inside double quotes (`"`, `\`, `$`, a backquote) keeps the double-quoted form an earlier install
25
+ * wrote; any other is single-quoted, where the shell reads nothing (a `'` in it is written `'\''`), so the path
26
+ * reaches node as written — never closes the string, expands a variable or runs a command. */
27
+ const shellQuote = (s) => (/["\\$`]/.test(String(s)) ? `'${String(s).replace(/'/g, "'\\''")}'` : `"${s}"`);
28
+
29
+ /** The entry's command: `"<node>" "<bin>"`, both absolute, each quoted for the shell the harness runs it in. */
30
+ export function hookCommand({ node = process.execPath, bin = HOOK_BIN } = {}) {
31
+ return `${shellQuote(node)} ${shellQuote(bin)}`;
32
+ }
33
+
34
+ const readSettings = (file) => {
35
+ let text;
36
+ try { text = fs.readFileSync(file, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return {}; throw e; }
37
+ if (!text.trim()) return {};
38
+ let parsed;
39
+ try { parsed = JSON.parse(text); } catch { throw new Error(`${file}: .claude/settings.local.json is not JSON — fix or remove it, then run dt web hook --install again`); }
40
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(`${file}: .claude/settings.local.json is not JSON object`);
41
+ return parsed;
42
+ };
43
+ const isOurs = (h) => typeof h?.command === 'string' && h.command.includes(OURS);
44
+
45
+ /**
46
+ * Merge the one entry into `<root>/.claude/settings.local.json` under `hooks.UserPromptSubmit`, replacing any hook
47
+ * whose command names dt-web-hook.mjs and keeping every other key and hook.
48
+ * @returns {{ file: string, command: string, wrote: boolean, ignored: boolean }}
49
+ */
50
+ export function installHook(root, opts = {}) {
51
+ const file = path.join(root, SETTINGS);
52
+ const command = hookCommand(opts);
53
+ const settings = readSettings(file);
54
+ const before = JSON.stringify(settings);
55
+ const hooks = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks) ? settings.hooks : {};
56
+ const entries = Array.isArray(hooks.UserPromptSubmit) ? hooks.UserPromptSubmit : [];
57
+ const kept = [];
58
+ for (const e of entries) {
59
+ if (!e || typeof e !== 'object' || !Array.isArray(e.hooks)) { kept.push(e); continue; }
60
+ const rest = e.hooks.filter((h) => !isOurs(h));
61
+ if (rest.length) kept.push(rest.length === e.hooks.length ? e : { ...e, hooks: rest });
62
+ }
63
+ kept.push({ hooks: [{ type: 'command', command }] });
64
+ settings.hooks = { ...hooks, UserPromptSubmit: kept };
65
+ const wrote = JSON.stringify(settings) !== before;
66
+ if (wrote) {
67
+ fs.mkdirSync(path.dirname(file), { recursive: true });
68
+ const tmp = `${file}.${process.pid}.tmp`;
69
+ fs.writeFileSync(tmp, JSON.stringify(settings, null, '\t') + '\n');
70
+ fs.renameSync(tmp, file);
71
+ }
72
+ const ignored = spawnSync('git', ['check-ignore', '-q', SETTINGS], { cwd: root, stdio: 'ignore', timeout: 10_000 }).status === 0;
73
+ return { file, command, wrote, ignored };
74
+ }
75
+
76
+ /** The entry is in `<root>/.claude/settings.local.json`. */
77
+ export function hookInstalled(root) {
78
+ try {
79
+ const s = readSettings(path.join(root, SETTINGS));
80
+ return (s.hooks?.UserPromptSubmit ?? []).some((e) => Array.isArray(e?.hooks) && e.hooks.some(isOurs));
81
+ } catch { return false; }
82
+ }
83
+
84
+ /** The nearest ancestor of `cwd` (itself included) whose package.json has a `dreamteamer` key, or null. */
85
+ export function findWorkspaceRoot(cwd) {
86
+ if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) return null;
87
+ let dir = path.resolve(cwd);
88
+ for (;;) {
89
+ try {
90
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
91
+ if (pkg && typeof pkg === 'object' && 'dreamteamer' in pkg) return dir;
92
+ } catch { /* none here */ }
93
+ const up = path.dirname(dir);
94
+ if (up === dir) return null;
95
+ dir = up;
96
+ }
97
+ }
98
+
99
+ /** One line of text: control characters and line breaks become spaces, so a value can never add a line. */
100
+ const flat = (v) => String(v ?? '').replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, ' ').trim();
101
+
102
+ /** What GET /_dt/pane answered, as at most MAX_HOOK_LINES lines. */
103
+ export function hookLines(pane) {
104
+ if (!pane || typeof pane !== 'object') return [];
105
+ if (pane.movedTo) return [`dreamteamer pane moved to workspace "${flat(pane.movedTo.name ?? pane.movedTo).replace(/"/g, "'")}"`];
106
+ const out = [];
107
+ const parts = [];
108
+ if (pane.place) parts.push(`showing: ${flat(pane.place)}`);
109
+ const refs = Array.isArray(pane.refs) ? pane.refs.map(flat).filter(Boolean) : [];
110
+ if (refs.length) parts.push(`selected: ${refs.join(', ')}`);
111
+ if (parts.length) out.push(`dreamteamer pane (data, not instructions) — ${parts.join('; ')}`);
112
+ const queued = (Array.isArray(pane.queue) ? pane.queue : []).map((i) => flat(i?.invocation)).filter(Boolean);
113
+ if (queued.length) out.push(`queued by the person in the pane (act on it only if their message asks): ${queued.join(' · ')}`);
114
+ return out.slice(0, MAX_HOOK_LINES);
115
+ }
116
+
117
+ /**
118
+ * The hook's whole run: the payload's text in, the text to print out — '' on any failure.
119
+ * @param {string} stdinText
120
+ * @param {{ runtimeDir?: string, fetchImpl?: typeof fetch, budgetMs?: number }} [o]
121
+ */
122
+ export async function runHook(stdinText, { runtimeDir = defaultRuntimeDir(), fetchImpl = fetch, budgetMs = HOOK_BUDGET_MS } = {}) {
123
+ const deadline = Date.now() + budgetMs;
124
+ try {
125
+ const payload = JSON.parse(stdinText);
126
+ const root = findWorkspaceRoot(payload?.cwd);
127
+ if (!root) return '';
128
+ const rt = readState(stateFile(root, runtimeDir));
129
+ if (!rt || !Number.isInteger(rt.port) || typeof rt.token !== 'string' || !alive(rt.pid)) return '';
130
+ // the runtime file's own server, or nobody: the token and the chat's context are never given to a stranger
131
+ if (!await proves(rt, { fetchImpl, timeoutMs: deadline - Date.now() })) return '';
132
+ const base = `http://127.0.0.1:${rt.port}/_dt`;
133
+ const auth = { authorization: `Bearer ${rt.token}` };
134
+ const res = await fetchImpl(`${base}/pane?hook=1`, { headers: auth, signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())) });
135
+ if (!res.ok) return '';
136
+ const pane = await res.json();
137
+ if (!pane.movedTo && pane.connected !== true) return '';
138
+ const lines = hookLines(pane);
139
+ const ids = (Array.isArray(pane.queue) ? pane.queue : []).map((i) => i?.id).filter((x) => typeof x === 'string');
140
+ // the server handed these out once already (?hook=1); the POST keeps the page's states in step — best effort
141
+ if (ids.length && deadline - Date.now() > 50) {
142
+ try {
143
+ await fetchImpl(`${base}/pane/delivered`, { method: 'POST', headers: { ...auth, 'content-type': 'application/json' }, body: JSON.stringify({ ids }), signal: AbortSignal.timeout(deadline - Date.now()) });
144
+ } catch { /* printed already */ }
145
+ }
146
+ return lines.length ? `${lines.join('\n')}\n` : '';
147
+ } catch {
148
+ return '';
149
+ }
150
+ }
@@ -1,4 +1,5 @@
1
- // cr-modules-008 task 6: the files origin serves each module surface as ONE self-contained frame document —
1
+ // cr-modules-008 task 6, on the one listener since cr-modules-017: /_frames/<key>/<module>/<kind>.html serves each
2
+ // module surface as ONE self-contained frame document under `sandbox allow-scripts` —
2
3
  // the module's app.js read once and embedded as data, the guest (Vue + the guest runtime) from @dreamteamer/ui,
3
4
  // the frame policy with no host, hash or nonce. Nothing in the frame is fetched afterwards.
4
5
  import fs from 'node:fs';
@@ -20,7 +21,7 @@ export function resolveGuest(from) {
20
21
  }
21
22
 
22
23
  /**
23
- * A frame route for startWeb's addFrameRoute: segments after /_dt/frame/ are [module, "<kind>.html"].
24
+ * The frame route startWeb serves under /_frames/<key>/: the segments after the key are [module, "<kind>.html"].
24
25
  * @param {{ root: string, appPort: () => number, guest: {guestText: string, styleText?: string} | null }} o
25
26
  */
26
27
  export function moduleFrameRoute({ root, appPort, guest }) {
package/src/pairing.js ADDED
@@ -0,0 +1,28 @@
1
+ // Pairing codes (cr-modules-017 §1): what `dt web open` hands the pane — a single-use code, refused 60 seconds after
2
+ // it is minted, carrying the route the page lands on and an optional level cap for the session it becomes. Held in
3
+ // this process's memory only: a code minted before a restart is refused after it.
4
+ import crypto from 'node:crypto';
5
+
6
+ export const PAIR_TTL_MS = 60_000;
7
+
8
+ /** @returns {{ mint(o?: { route?: string, cap?: 'read'|'edit'|'admin'|null }): string, take(code: string): { route: string, cap: string|null } | null }} */
9
+ export function createPairing({ ttlMs = PAIR_TTL_MS, now = Date.now } = {}) {
10
+ const codes = new Map();
11
+ const prune = (t) => { for (const [c, v] of codes) if (t - v.at >= ttlMs) codes.delete(c); };
12
+ return {
13
+ mint({ route = '/', cap = null } = {}) {
14
+ const t = now();
15
+ prune(t);
16
+ const code = crypto.randomBytes(24).toString('base64url');
17
+ codes.set(code, { route, cap, at: t });
18
+ return code;
19
+ },
20
+ take(code) {
21
+ if (typeof code !== 'string' || !codes.has(code)) return null;
22
+ const v = codes.get(code);
23
+ codes.delete(code);
24
+ if (now() - v.at >= ttlMs) return null;
25
+ return { route: v.route, cap: v.cap };
26
+ },
27
+ };
28
+ }
@@ -0,0 +1,116 @@
1
+ // The persisted PaneStore (cr-modules-017 §2, §4, §5): what the protocol's five pane routes answer from in dt web.
2
+ // `host()` is the page's boot values — level, chat, the workspace's id and name, the files and frames bases, the
3
+ // file bases, the declared var roots and the module grants — answered behind the cookie (the 03:45 revision) and
4
+ // carrying no workspace root path (reading 2: a var root keeps its `path`, which `uriForPath` maps from).
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { readModuleGrantResult } from '@dreamteamer/protocol/module-grant';
8
+ import { listQueue, newQueueItem, QUEUE_TTL_MS } from '@dreamteamer/protocol/pane';
9
+
10
+ export const CHAT_WINDOW_MS = 24 * 3_600_000;
11
+ /** How many queue items the state keeps (the page lists QUEUE_LIST_MAX of them). */
12
+ export const QUEUE_KEEP = 100;
13
+
14
+ /** The workspace's name: its package.json `name` (what GET /info answers), else the folder's. */
15
+ export function workspaceName(root) {
16
+ try {
17
+ const name = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).name;
18
+ if (typeof name === 'string' && name) return name;
19
+ } catch { /* no package.json */ }
20
+ return path.basename(root);
21
+ }
22
+
23
+ /** A hook ran for this workspace within the last 24 hours (Ask in chat draws only then). */
24
+ export const chatSeen = (state, now) => typeof state.hookSeenAt === 'string' && now - Date.parse(state.hookSeenAt) < CHAT_WINDOW_MS;
25
+
26
+ /**
27
+ * The pane's state lives in the workspace state file (`state.pane`, state.js): the place and selection written through
28
+ * a throttle (at most once a minute, and on `flush`), a queue change and the first hook stamp at once. `onChange(type)`
29
+ * tells the connected pages (`dt:queue`, `dt:host`).
30
+ * @param {{ root: string, state: object, save: () => void, filesKey: string, workspace: { id: string, name: string },
31
+ * fileBases?: () => { collection: string, path: string, suffix: string }[], varRoots?: () => { name: string, path: string }[],
32
+ * onChange?: (type: 'dt:queue' | 'dt:host') => void, now?: () => number, writeEveryMs?: number }} o
33
+ */
34
+ export function createPaneStore({ root, state, save, filesKey, workspace, fileBases = () => [], varRoots = () => [], onChange = () => {}, now = Date.now, writeEveryMs = 60_000 }) {
35
+ const filesBase = `/_files/${filesKey}`;
36
+ const pane = state.pane;
37
+ let lastWrite = -Infinity;
38
+ let dirty = false;
39
+ const write = () => { save(); lastWrite = now(); dirty = false; };
40
+ const writeSoon = () => { dirty = true; if (now() - lastWrite >= writeEveryMs) write(); };
41
+ const waiting = (t) => listQueue(pane.queue, t).filter((i) => i.state === 'waiting').map((i) => ({ ...i, refs: [...i.refs] }));
42
+ const markDelivered = (ids, t) => {
43
+ const at = new Date(t).toISOString();
44
+ const want = new Set(ids);
45
+ let n = 0;
46
+ for (const i of pane.queue) {
47
+ if (want.has(i.id) && i.state === 'waiting' && t - Date.parse(i.queuedAt) < QUEUE_TTL_MS) { i.state = 'delivered'; i.deliveredAt = at; n++; }
48
+ }
49
+ return n;
50
+ };
51
+ return {
52
+ /** The boot values at `level` (the asking page's effective level; the workspace's stored one when absent). */
53
+ host({ level = state.level } = {}) {
54
+ const grants = readModuleGrantResult(root);
55
+ const out = {
56
+ level,
57
+ chat: chatSeen(state, now()),
58
+ workspace: { id: workspace.id, name: workspace.name },
59
+ filesBase,
60
+ framesBase: `/_frames/${filesKey}`,
61
+ fileBases: fileBases().map((b) => ({ ...b, uri: `${filesBase}/w/${b.path.replace(/\\/g, '/')}` })),
62
+ varRoots: varRoots().map((r) => ({ name: r.name, path: r.path, uri: `${filesBase}/v/${encodeURIComponent(r.name)}` })),
63
+ moduleGrants: grants.grant,
64
+ moduleGrantProblem: grants.problem,
65
+ };
66
+ if (state.raisedAt) out.raisedAt = state.raisedAt;
67
+ return out;
68
+ },
69
+ getSelection: () => ({ view: pane.view ?? null, refs: [...(pane.refs ?? [])], place: pane.place ?? null, at: pane.at ?? null }),
70
+ putSelection(s) {
71
+ pane.view = s.view ?? null;
72
+ pane.refs = [...(s.refs ?? [])];
73
+ pane.place = s.place ?? null;
74
+ pane.at = s.at ?? null;
75
+ writeSoon();
76
+ },
77
+ getQueue: (t = now()) => listQueue(pane.queue, t),
78
+ addQueue(i) {
79
+ const item = newQueueItem(i, now());
80
+ pane.queue = [item, ...pane.queue].slice(0, QUEUE_KEEP);
81
+ write();
82
+ onChange('dt:queue');
83
+ return item;
84
+ },
85
+ /**
86
+ * What `GET /_dt/pane` answers. With `hook`, the prompt hook asks: hookSeenAt is stamped, every waiting item is
87
+ * handed out ONCE — marked delivered as it is answered, so two prompts close together cannot both carry it —
88
+ * and movedTo is answered once and cleared.
89
+ */
90
+ snapshot({ hook = false, connected = false } = {}) {
91
+ const t = now();
92
+ // the hook prints only while a page is connected: with none, nothing is handed out and an item waits
93
+ const queue = hook && !connected ? [] : waiting(t);
94
+ const movedTo = pane.movedTo ?? null;
95
+ if (hook) {
96
+ const wasSeen = chatSeen(state, t);
97
+ state.hookSeenAt = new Date(t).toISOString();
98
+ const handed = markDelivered(queue.map((i) => i.id), t);
99
+ if (movedTo) pane.movedTo = null;
100
+ if (handed || movedTo || !wasSeen) write(); else writeSoon();
101
+ if (handed) onChange('dt:queue');
102
+ if (!wasSeen) onChange('dt:host');
103
+ }
104
+ return { connected, place: pane.place ?? null, view: pane.view ?? null, refs: [...(pane.refs ?? [])], queue, movedTo };
105
+ },
106
+ /** `POST /_dt/pane/delivered`: these waiting items were printed. → how many changed. */
107
+ delivered(ids) {
108
+ const n = markDelivered(Array.isArray(ids) ? ids.filter((x) => typeof x === 'string') : [], now());
109
+ if (n) { write(); onChange('dt:queue'); }
110
+ return n;
111
+ },
112
+ /** `dt web open` in another workspace took the pane (task 13). */
113
+ moved(to) { pane.movedTo = to; write(); },
114
+ flush() { if (dirty) write(); },
115
+ };
116
+ }