create-pmos 0.5.0 → 0.6.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/README.md +114 -0
- package/bin/create-pmos.js +93 -7
- package/package.json +7 -3
- package/payload/AGENTS-HUMAN.md +6 -0
- package/payload/AGENTS.md +35 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/README.md +6 -0
- package/payload/SECURITY.md +6 -0
- package/payload/okf/core/concepts/frontmatter-format.md +75 -0
- package/payload/okf/core/concepts/index.md +3 -0
- package/payload/okf/core/concepts/log.md +13 -0
- package/payload/okf/core/templates/eval-rubric-template.md +12 -10
- package/payload/okf/core/templates/prd-template.md +6 -0
- package/payload/okf/core/templates/prototype-brief-template.md +45 -21
- package/payload/planning/evals/agent-run-eval-rubric.md +10 -7
- package/payload/scripts/metrics.py +142 -121
- package/payload/skills/SKILL-FORMAT.md +6 -0
- package/payload/skills/discovery.skill +4 -0
- package/payload/skills/eval-rubric.skill +1 -1
- package/payload/skills/explain-surface.skill +156 -0
- package/payload/skills/pmos-router.skill +25 -3
- package/payload/skills/prd.skill +8 -0
- package/payload/skills/prototype.skill +106 -41
- package/payload/skills-native/discovery/SKILL.md +4 -0
- package/payload/skills-native/eval-rubric/SKILL.md +1 -1
- package/payload/skills-native/explain-surface/SKILL.md +158 -0
- package/payload/skills-native/pmos-router/SKILL.md +25 -3
- package/payload/skills-native/prd/SKILL.md +8 -0
- package/payload/skills-native/prototype/SKILL.md +106 -41
- package/payload/templates/agent-run-eval-rubric.md +10 -7
- package/payload/templates/ticket-template.md +2 -0
- package/workspace/README.md +112 -0
- package/workspace/REMOTE.md +131 -0
- package/workspace/bin/pmos.js +206 -0
- package/workspace/doctor.js +99 -0
- package/workspace/lib/console-state.js +117 -0
- package/workspace/lib/file-mode-parse.js +58 -0
- package/workspace/lib/harness-parse.js +222 -0
- package/workspace/lib/preview-kind.js +98 -0
- package/workspace/lib/rate-limit.js +26 -0
- package/workspace/lib/remote-access.js +235 -0
- package/workspace/lib/review-gate.js +39 -0
- package/workspace/lib/schedule-logic.js +132 -0
- package/workspace/lib/skill-store.js +143 -0
- package/workspace/lib/surface-contract.js +79 -0
- package/workspace/lib/transcript-read.js +247 -0
- package/workspace/package-lock.json +94 -0
- package/workspace/package.json +23 -0
- package/workspace/public/app.js +1710 -0
- package/workspace/public/index.html +175 -0
- package/workspace/public/styles.css +766 -0
- package/workspace/server.js +1753 -0
- package/workspace/skills/backlog-triage.skill +13 -0
- package/workspace/skills/weekly-report.skill +19 -0
|
@@ -0,0 +1,1753 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* PMOS Terminal Workspace — session server (D62 as ratified/amended).
|
|
4
|
+
*
|
|
5
|
+
* A persistent, LOCAL, headless server that owns real PTYs for the PM's agent CLIs.
|
|
6
|
+
* Clients (the GUI, later a native TUI, a phone) ATTACH over WebSocket; close a client
|
|
7
|
+
* and the sessions keep running — re-attach replays scrollback and resumes live (R7).
|
|
8
|
+
* Binds localhost only: the D62 hosted-PTY-bridge rejection stands — never public.
|
|
9
|
+
*
|
|
10
|
+
* The server hosts MANY workspaces (repo roots), not one: `pmos` in any repository
|
|
11
|
+
* registers that repository and the client switches to it. Sessions, the library and
|
|
12
|
+
* every path guard are scoped to the workspace they belong to — workspace A can never
|
|
13
|
+
* read workspace B's files (workspace-multi-repo-library R1/R3).
|
|
14
|
+
*
|
|
15
|
+
* The agent harness runs UNTOUCHED inside the PTY (Claude Code / Codex / a shell): this
|
|
16
|
+
* server is the terminal on the other end, not a wrapper. Subscription login, slash
|
|
17
|
+
* commands, keybindings — all the CLI's own.
|
|
18
|
+
*
|
|
19
|
+
* No dependency on the hosted backend. Gate actions write the file-mode PMOS vocabulary
|
|
20
|
+
* under state/ (the reconcile seam). Request headers are never logged (D08).
|
|
21
|
+
*/
|
|
22
|
+
'use strict';
|
|
23
|
+
const http = require('http');
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const os = require('os');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const crypto = require('crypto');
|
|
28
|
+
const { execFile, spawnSync } = require('child_process');
|
|
29
|
+
const { WebSocketServer } = require('ws');
|
|
30
|
+
|
|
31
|
+
// node-pty is a NATIVE module; a broken/mismatched build is the #1 field failure
|
|
32
|
+
// (posix_spawnp failed on macOS = wrong-arch binary). Fail with the fix, not a stack.
|
|
33
|
+
let pty;
|
|
34
|
+
try {
|
|
35
|
+
pty = require('node-pty');
|
|
36
|
+
} catch (e) {
|
|
37
|
+
console.error('✗ node-pty failed to load — the native module is missing or built for the wrong platform.');
|
|
38
|
+
console.error(` ${e.message.split('\n')[0]}`);
|
|
39
|
+
console.error(' Fix: npm run doctor (diagnoses arch/toolchain)');
|
|
40
|
+
console.error(' npm rebuild node-pty --build-from-source');
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const { checkSurface } = require('./lib/surface-contract');
|
|
45
|
+
const scheduleLogic = require('./lib/schedule-logic');
|
|
46
|
+
const harness = require('./lib/harness-parse');
|
|
47
|
+
const transcript = require('./lib/transcript-read');
|
|
48
|
+
const skillStore = require('./lib/skill-store');
|
|
49
|
+
const remoteAccess = require('./lib/remote-access'); // tailnet bind + token guard (D62) + origin/host allowlist (D64)
|
|
50
|
+
const rateLimit = require('./lib/rate-limit'); // per-connection token bucket for spawn/input (D64 A3)
|
|
51
|
+
const consoleState = require('./lib/console-state'); // read-only console surfaces over file-mode state (D64 B)
|
|
52
|
+
const fmParse = require('./lib/file-mode-parse'); // canonical file-mode parsers, shared with metrics.py (D64 D)
|
|
53
|
+
const SELF_ROOT = path.resolve(__dirname, '..'); // the repo the workspace ships inside
|
|
54
|
+
const PUB = path.join(__dirname, 'public');
|
|
55
|
+
const XROOT = path.join(__dirname, 'node_modules', '@xterm');
|
|
56
|
+
const STATE = path.join(__dirname, 'state');
|
|
57
|
+
const SESS_DIR = path.join(STATE, 'sessions');
|
|
58
|
+
const WS_FILE = path.join(STATE, 'workspaces.json');
|
|
59
|
+
const PORT = process.env.PMOS_WS_PORT ? Number(process.env.PMOS_WS_PORT) : 7433;
|
|
60
|
+
const preview = require('./lib/preview-kind'); // the single reader classifier (md|text|html|image|inert)
|
|
61
|
+
// Bind + token policy (D62). DEFAULT = loopback only, no token (unchanged). The opt-in remote flag
|
|
62
|
+
// (PMOS_REMOTE=tailnet / PMOS_BIND_TAILNET=1) adds the machine's OWN tailnet IPv4 (100.64.0.0/10)
|
|
63
|
+
// and requires a bearer token on every non-loopback peer. Any non-loopback, non-tailnet bind is
|
|
64
|
+
// REFUSED (BIND.refused) so the server exits — there is no code path to a public interface.
|
|
65
|
+
const BIND = remoteAccess.resolveBinds({ env: process.env });
|
|
66
|
+
const TOKEN = BIND.token || null; // non-loopback peers must present it; never logged (D08)
|
|
67
|
+
// Per-OS default shell. On Windows there is no `bash` — use ComSpec/PowerShell.
|
|
68
|
+
function defaultShell() {
|
|
69
|
+
if (process.platform === 'win32') return process.env.COMSPEC || 'powershell.exe';
|
|
70
|
+
return process.env.SHELL || 'bash';
|
|
71
|
+
}
|
|
72
|
+
const SHELL = defaultShell();
|
|
73
|
+
|
|
74
|
+
// The environment a spawned SESSION gets. The workspace server is long-lived and independent;
|
|
75
|
+
// a `claude` (or `codex`, or shell) started inside it is a fresh TOP-LEVEL session, not a child
|
|
76
|
+
// of whatever launched the server. But the server inherits its launcher's environment — and if
|
|
77
|
+
// that launcher was itself a Claude Code session (e.g. `pmos` was run from inside one), the
|
|
78
|
+
// CLAUDE_CODE_* markers leak through into every session we spawn, so the agent CLI sees a
|
|
79
|
+
// "child session" marker and disables its own transcript saving. Strip those markers here: the
|
|
80
|
+
// harness inside the PTY must run as though the PM launched it directly. Login/subscription do
|
|
81
|
+
// NOT live in these vars (they're in the CLI's own config/keychain), so this never affects auth.
|
|
82
|
+
const INHERITED_SESSION_MARKERS = [
|
|
83
|
+
'CLAUDECODE', 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_ENTRYPOINT',
|
|
84
|
+
'CLAUDE_CODE_EXECPATH', 'CLAUDE_PID', 'AI_AGENT', 'CLAUDE_EFFORT',
|
|
85
|
+
];
|
|
86
|
+
function sessionEnv() {
|
|
87
|
+
const env = { ...process.env };
|
|
88
|
+
for (const k of INHERITED_SESSION_MARKERS) delete env[k];
|
|
89
|
+
// also drop any other CLAUDE_CODE_* the CLI adds to mark a nested run
|
|
90
|
+
for (const k of Object.keys(env)) if (/^CLAUDE_CODE_/.test(k)) delete env[k];
|
|
91
|
+
return env;
|
|
92
|
+
}
|
|
93
|
+
const SCROLLBACK = 200_000; // bytes of ring buffer per session
|
|
94
|
+
|
|
95
|
+
fs.mkdirSync(SESS_DIR, { recursive: true });
|
|
96
|
+
|
|
97
|
+
function nowISO() { return new Date(Date.now()).toISOString(); }
|
|
98
|
+
|
|
99
|
+
// D64 (Track A2): record-of-truth writes must FAIL LOUDLY, never swallow. This surfaces a visible
|
|
100
|
+
// server error (no header/secret content, D08) rather than a silent `catch {}` that lets a green
|
|
101
|
+
// click sit over an empty record. The Acceptance-Gate append at /api/gate goes further and returns
|
|
102
|
+
// an HTTP 500 to the caller; these secondary writes at least never fail in silence.
|
|
103
|
+
function warnWriteFail(what, e) {
|
|
104
|
+
const msg = String((e && e.message) || e || 'unknown error').split('\n')[0].slice(0, 160);
|
|
105
|
+
try { console.error(`✗ file-mode write failed: ${what} — ${msg}`); } catch {}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Workspaces: the registry of repo roots this server hosts. ─────────────────────────
|
|
109
|
+
// One server, many repos. The id is derived from the absolute path, so registering the
|
|
110
|
+
// same directory twice is idempotent — `pmos` can be run in a repo any number of times.
|
|
111
|
+
/** @type {Map<string,{id,name,root,addedAt}>} */
|
|
112
|
+
const workspaces = new Map();
|
|
113
|
+
|
|
114
|
+
function wsId(root) { return crypto.createHash('sha1').update(path.resolve(root)).digest('hex').slice(0, 10); }
|
|
115
|
+
function saveWorkspaces() {
|
|
116
|
+
try { fs.writeFileSync(WS_FILE, JSON.stringify([...workspaces.values()], null, 2)); }
|
|
117
|
+
catch (e) { warnWriteFail('workspaces.json (the workspace registry)', e); }
|
|
118
|
+
}
|
|
119
|
+
function loadWorkspaces() {
|
|
120
|
+
let list = [];
|
|
121
|
+
try { list = JSON.parse(fs.readFileSync(WS_FILE, 'utf8')); } catch {}
|
|
122
|
+
if (Array.isArray(list)) {
|
|
123
|
+
for (const w of list) {
|
|
124
|
+
if (!w || !w.root) continue;
|
|
125
|
+
const root = path.resolve(w.root);
|
|
126
|
+
if (!isDir(root)) continue; // a moved/deleted repo drops out quietly
|
|
127
|
+
workspaces.set(wsId(root), { id: wsId(root), name: w.name || path.basename(root), root, addedAt: w.addedAt || nowISO() });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function isDir(p) { try { return fs.statSync(p).isDirectory(); } catch { return false; } }
|
|
132
|
+
|
|
133
|
+
function registerWorkspace(root, name) {
|
|
134
|
+
if (!root) return null;
|
|
135
|
+
const abs = path.resolve(root);
|
|
136
|
+
if (!isDir(abs)) return null;
|
|
137
|
+
const id = wsId(abs);
|
|
138
|
+
const existing = workspaces.get(id);
|
|
139
|
+
if (existing) {
|
|
140
|
+
if (name && name !== existing.name) { existing.name = name; saveWorkspaces(); pushWorkspaces(); }
|
|
141
|
+
return existing;
|
|
142
|
+
}
|
|
143
|
+
const w = { id, name: name || path.basename(abs) || abs, root: abs, addedAt: nowISO() };
|
|
144
|
+
workspaces.set(id, w);
|
|
145
|
+
saveWorkspaces(); pushWorkspaces();
|
|
146
|
+
return w;
|
|
147
|
+
}
|
|
148
|
+
// Forgetting a workspace never touches the repo — it only stops hosting it here. Blocked
|
|
149
|
+
// while it still owns sessions, so scrollback is never orphaned by a click.
|
|
150
|
+
function forgetWorkspace(id) {
|
|
151
|
+
const w = workspaces.get(id);
|
|
152
|
+
if (!w || w.root === SELF_ROOT) return false;
|
|
153
|
+
if ([...sessions.values()].some((s) => s.ws === id)) return false;
|
|
154
|
+
workspaces.delete(id); saveWorkspaces(); pushWorkspaces();
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
function homeWorkspace() { return workspaces.get(wsId(SELF_ROOT)); }
|
|
158
|
+
function getWs(id) { return (id && workspaces.get(id)) || homeWorkspace(); }
|
|
159
|
+
function wsList() {
|
|
160
|
+
return [...workspaces.values()].map((w) => ({
|
|
161
|
+
id: w.id, name: w.name, root: w.root, home: w.root === SELF_ROOT,
|
|
162
|
+
shape: wsShape(w.root), addedAt: w.addedAt,
|
|
163
|
+
sessions: [...sessions.values()].filter((s) => s.ws === w.id).length,
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'vendor', 'target', 'coverage', '.git', 'tmp', 'out']);
|
|
167
|
+
const PLAIN_MAX_DEPTH = 3; // the curated (knowledge) view stays shallow
|
|
168
|
+
const TREE_MAX_DEPTH = 6; // the folder lens is a real explorer, but bounded on huge/slow repos
|
|
169
|
+
const TREE_MAX_NODES = 4000; // …and node-budgeted so a giant repo (or Google Drive) never hangs
|
|
170
|
+
const BUNDLE_MAX_DEPTH = 2; // find a planning/okf/skills bundle at the root OR one level nested
|
|
171
|
+
const LIB_FILES = ['DECISIONS.md', 'AGENTS.md', 'PRINCIPLES.md', 'CLAUDE.md', 'discoveries.md', 'README.md'];
|
|
172
|
+
|
|
173
|
+
function insideRoot(root, p) {
|
|
174
|
+
const r = path.resolve(p);
|
|
175
|
+
return r === root || r.startsWith(root + path.sep);
|
|
176
|
+
}
|
|
177
|
+
// Find sub-directories with a given basename, at any depth up to maxDepth (skipping junk dirs).
|
|
178
|
+
// This is how a PMOS bundle is located whether it sits at the repo root OR nested — e.g. IDPF
|
|
179
|
+
// keeps its whole bundle under `pmos/`, so `pmos/planning/prd/*` are real initiatives too.
|
|
180
|
+
function findDirsNamed(root, name, maxDepth) {
|
|
181
|
+
const found = [];
|
|
182
|
+
(function walk(dir, depth) {
|
|
183
|
+
let ents = [];
|
|
184
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
185
|
+
for (const e of ents) {
|
|
186
|
+
if (!e.isDirectory() || e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue;
|
|
187
|
+
const full = path.join(dir, e.name);
|
|
188
|
+
if (e.name === name) found.push(full);
|
|
189
|
+
if (depth < maxDepth) walk(full, depth + 1);
|
|
190
|
+
}
|
|
191
|
+
})(root, 1);
|
|
192
|
+
return found;
|
|
193
|
+
}
|
|
194
|
+
function planningRoots(root) { return findDirsNamed(root, 'planning', BUNDLE_MAX_DEPTH); }
|
|
195
|
+
// A repo with a planning/ bundle (at any shallow depth) or a DECISIONS.md gets the initiative lens
|
|
196
|
+
// + curated knowledge; anything else is a plain document tree. Detection is nested-aware so a repo
|
|
197
|
+
// that vendors a PMOS bundle under a sub-folder still shows its initiatives (the reported defect).
|
|
198
|
+
function wsShape(root) {
|
|
199
|
+
return planningRoots(root).length || fs.existsSync(path.join(root, 'DECISIONS.md')) ? 'pmos' : 'plain';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Library: the file tree the sidebar browses. ───────────────────────────────────────
|
|
203
|
+
// One hierarchical node: {name, path, dirs:[…], docs:[…]}. opts.all → a COMPLETE explorer:
|
|
204
|
+
// every sub-directory (even empty) and every file (each flagged {md}). Otherwise the curated
|
|
205
|
+
// view: .md only, empty branches pruned (used by the initiative lens's knowledge section).
|
|
206
|
+
// DOC READS are workspace-guarded (readTextDoc): markdown/text/html preview; images stream via
|
|
207
|
+
// /api/asset; a truly non-previewable binary is shown but never served.
|
|
208
|
+
function tree(root, dir, opts = {}, depth = 0, budget = { n: TREE_MAX_NODES }) {
|
|
209
|
+
const rel = path.relative(root, dir);
|
|
210
|
+
const out = { name: path.basename(dir) || rel || path.basename(root), path: rel, dirs: [], docs: [] };
|
|
211
|
+
let ents = [];
|
|
212
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; }
|
|
213
|
+
const maxDepth = opts.all ? TREE_MAX_DEPTH : PLAIN_MAX_DEPTH;
|
|
214
|
+
for (const e of ents.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
215
|
+
if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue;
|
|
216
|
+
if (budget.n <= 0) break;
|
|
217
|
+
const full = path.join(dir, e.name);
|
|
218
|
+
if (e.isDirectory()) {
|
|
219
|
+
if (depth < maxDepth) { budget.n--; out.dirs.push(tree(root, full, opts, depth + 1, budget)); }
|
|
220
|
+
} else if (e.isFile()) {
|
|
221
|
+
const kind = preview.previewKind(e.name);
|
|
222
|
+
const isMd = kind === 'markdown'; // markdown incl. .skill — opens in the reader
|
|
223
|
+
if (opts.all || isMd) { budget.n--; out.docs.push({ name: e.name, path: path.relative(root, full), md: isMd, kind }); }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (!opts.all) out.dirs = out.dirs.filter((d) => d.dirs.length || d.docs.length); // curated: prune empty
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
// The folder lens is a COMPLETE map of the repo — every top-level folder and file, at any shape,
|
|
230
|
+
// browsed the same way whether or not the repo has ever seen PMOS. Curated roots no longer gate it:
|
|
231
|
+
// the reported defect was that it showed "only selected" folders and a new folder never appeared.
|
|
232
|
+
function libraryTree(root) {
|
|
233
|
+
const roots = [];
|
|
234
|
+
const budget = { n: TREE_MAX_NODES };
|
|
235
|
+
let ents = [];
|
|
236
|
+
try { ents = fs.readdirSync(root, { withFileTypes: true }); } catch {}
|
|
237
|
+
for (const e of ents.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
238
|
+
if (!e.isDirectory() || e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue;
|
|
239
|
+
roots.push(tree(root, path.join(root, e.name), { all: true }, 1, budget));
|
|
240
|
+
}
|
|
241
|
+
const loose = looseFiles(root);
|
|
242
|
+
if (loose.length) roots.push({ name: '(root)', path: '', dirs: [], docs: loose });
|
|
243
|
+
return roots;
|
|
244
|
+
}
|
|
245
|
+
// every top-level file (md + non-md), so the folder lens shows the real root, not a curated subset
|
|
246
|
+
function looseFiles(root) {
|
|
247
|
+
let ents = [];
|
|
248
|
+
try { ents = fs.readdirSync(root, { withFileTypes: true }); } catch {}
|
|
249
|
+
return ents.filter((e) => e.isFile() && !e.name.startsWith('.'))
|
|
250
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
251
|
+
.map((e) => { const kind = preview.previewKind(e.name); return { name: e.name, path: e.name, md: kind === 'markdown', kind }; });
|
|
252
|
+
}
|
|
253
|
+
// top-level .md files: the curated governance set first, then anything else at the root
|
|
254
|
+
function looseDocs(root) {
|
|
255
|
+
const seen = new Set();
|
|
256
|
+
const out = [];
|
|
257
|
+
for (const f of LIB_FILES) {
|
|
258
|
+
if (fs.existsSync(path.join(root, f))) { out.push({ name: f, path: f }); seen.add(f); }
|
|
259
|
+
}
|
|
260
|
+
let ents = [];
|
|
261
|
+
try { ents = fs.readdirSync(root, { withFileTypes: true }); } catch {}
|
|
262
|
+
for (const e of ents.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
263
|
+
if (e.isFile() && e.name.endsWith('.md') && !seen.has(e.name)) out.push({ name: e.name, path: e.name });
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
// Serve a TEXTUAL document — markdown (incl. .skill), plain-text/config source, or HTML source —
|
|
268
|
+
// per-workspace-guarded. Images are NOT served here (they stream through /api/asset); anything
|
|
269
|
+
// non-textual returns null so the reader shows it inert. A NUL byte ⇒ binary masquerading as text
|
|
270
|
+
// ⇒ refused. Returns { kind, text } or null. The traversal + cross-workspace guard is unchanged
|
|
271
|
+
// (insideRoot); what widened is the file KIND, never the reachable path set.
|
|
272
|
+
function readTextDoc(root, rel) {
|
|
273
|
+
const full = path.resolve(root, rel || '');
|
|
274
|
+
if (!insideRoot(root, full)) return null; // per-workspace guard (traversal + cross-ws)
|
|
275
|
+
const kind = preview.previewKind(path.basename(full));
|
|
276
|
+
if (kind !== 'markdown' && kind !== 'text' && kind !== 'html') return null;
|
|
277
|
+
let text;
|
|
278
|
+
try { text = fs.readFileSync(full, 'utf8'); } catch { return null; }
|
|
279
|
+
if (text.indexOf(String.fromCharCode(0)) !== -1) return null; // NUL byte => binary masquerading as text
|
|
280
|
+
return { kind, text };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── The initiative lens: the library grouped the way the PM thinks. ───────────────────
|
|
284
|
+
// planning/ splits one initiative across prd/ evals/ design/ research/ — correct for the
|
|
285
|
+
// filesystem, useless for "show me everything for pmos-jira-sync". This projection joins
|
|
286
|
+
// them on the initiative_id the artifacts already declare in their own identity block,
|
|
287
|
+
// falling back to the filename convention when an artifact declares nothing.
|
|
288
|
+
const ROLE_SUFFIXES = [
|
|
289
|
+
['-eval-rubric', 'rubric'], ['-design-brief', 'design brief'], ['-design-reference', 'design'],
|
|
290
|
+
['-dogfood', 'dogfood'], ['-reconcile', 'reconcile'], ['-findings', 'findings'],
|
|
291
|
+
['-scoping', 'scoping'], ['-intake', 'intake'], ['-brief', 'brief'], ['-prd', 'PRD'],
|
|
292
|
+
];
|
|
293
|
+
const ROLE_BY_DIR = {
|
|
294
|
+
prd: 'PRD', evals: 'eval', design: 'design', research: 'research', briefs: 'brief',
|
|
295
|
+
okrs: 'OKR', radar: 'radar', gtm: 'gtm', 'out-of-scope': 'rejected', 'harness-refine': 'harness',
|
|
296
|
+
'design-export': 'design', explainers: 'explainer', fixtures: 'fixture',
|
|
297
|
+
};
|
|
298
|
+
/** full path -> {mtimeMs, initiative, role, title} — re-read only what actually changed */
|
|
299
|
+
const metaCache = new Map();
|
|
300
|
+
|
|
301
|
+
function parseHead(full) {
|
|
302
|
+
// first 4 KB is enough for the identity block + the H1 every PMOS artifact opens with
|
|
303
|
+
let head = '';
|
|
304
|
+
try {
|
|
305
|
+
const fd = fs.openSync(full, 'r');
|
|
306
|
+
const buf = Buffer.alloc(4096);
|
|
307
|
+
const n = fs.readSync(fd, buf, 0, 4096, 0);
|
|
308
|
+
fs.closeSync(fd);
|
|
309
|
+
head = buf.slice(0, n).toString('utf8');
|
|
310
|
+
} catch { return {}; }
|
|
311
|
+
const id = head.match(/^\s*[-*]?\s*initiative_id\s*:\s*["']?([A-Za-z0-9][\w.-]*)["']?\s*$/m);
|
|
312
|
+
const h1 = head.match(/^#\s+(.+)$/m);
|
|
313
|
+
// status/stage/anchor ride the PRD's identity block — the only initiative signal that lives on
|
|
314
|
+
// disk today. RICE/effort/milestones/progress do NOT (net-new state) and are never invented.
|
|
315
|
+
const clean = (m) => m ? m[1].replace(/\*\*/g, '').split(/\s+[—–-]\s|\(/)[0].trim().slice(0, 48) : null;
|
|
316
|
+
const status = head.match(/^\s*[-*]?\s*status\s*:\s*(.+?)\s*$/mi);
|
|
317
|
+
const stage = head.match(/^\s*[-*]?\s*stage\s*:\s*(.+?)\s*$/mi);
|
|
318
|
+
const kr = head.match(/^\s*[-*]?\s*parent[_-]?kr\s*:\s*(.+?)\s*$/mi);
|
|
319
|
+
return {
|
|
320
|
+
initiative: id ? id[1] : null,
|
|
321
|
+
title: h1 ? h1[1].replace(/^(PRD|Eval Rubric|Design Brief)\s*:\s*/i, '').trim() : null,
|
|
322
|
+
status: clean(status), stage: clean(stage), kr: clean(kr),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
function roleAndStem(rel, base) {
|
|
326
|
+
const stem = base.replace(/\.md$/, '');
|
|
327
|
+
for (const [suf, role] of ROLE_SUFFIXES) {
|
|
328
|
+
if (stem.endsWith(suf)) return { role, stem: stem.slice(0, -suf.length) };
|
|
329
|
+
}
|
|
330
|
+
const dir = path.dirname(rel).split(path.sep).pop();
|
|
331
|
+
return { role: ROLE_BY_DIR[dir] || 'doc', stem };
|
|
332
|
+
}
|
|
333
|
+
function docMeta(root, full) {
|
|
334
|
+
let st;
|
|
335
|
+
try { st = fs.statSync(full); } catch { return null; }
|
|
336
|
+
const hit = metaCache.get(full);
|
|
337
|
+
if (hit && hit.mtimeMs === st.mtimeMs) return hit;
|
|
338
|
+
const rel = path.relative(root, full);
|
|
339
|
+
const base = path.basename(full);
|
|
340
|
+
const { role, stem } = roleAndStem(rel, base);
|
|
341
|
+
const head = parseHead(full);
|
|
342
|
+
const meta = {
|
|
343
|
+
mtimeMs: st.mtimeMs, path: rel, name: base, role,
|
|
344
|
+
// frontmatter wins; the filename convention is the fallback, never the override
|
|
345
|
+
initiative: head.initiative || null, stem, title: head.title || null,
|
|
346
|
+
status: head.status || null, stage: head.stage || null, kr: head.kr || null,
|
|
347
|
+
};
|
|
348
|
+
metaCache.set(full, meta);
|
|
349
|
+
return meta;
|
|
350
|
+
}
|
|
351
|
+
function walkMd(dir, acc) {
|
|
352
|
+
let ents = [];
|
|
353
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return acc; }
|
|
354
|
+
for (const e of ents) {
|
|
355
|
+
if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue;
|
|
356
|
+
const full = path.join(dir, e.name);
|
|
357
|
+
if (e.isDirectory()) walkMd(full, acc);
|
|
358
|
+
else if (e.isFile() && e.name.endsWith('.md')) acc.push(full);
|
|
359
|
+
}
|
|
360
|
+
return acc;
|
|
361
|
+
}
|
|
362
|
+
function initiativeIndex(root) {
|
|
363
|
+
const proots = planningRoots(root); // planning/ at the root OR nested (e.g. IDPF/pmos/planning)
|
|
364
|
+
if (!proots.length) return { initiatives: [], ungrouped: [] };
|
|
365
|
+
const metas = proots.flatMap((p) => walkMd(p, [])).map((f) => docMeta(root, f)).filter(Boolean);
|
|
366
|
+
// Pass 1: every id an artifact explicitly declares. Only these are real initiatives —
|
|
367
|
+
// a filename stem is promoted to a group only when it matches a declared id or when
|
|
368
|
+
// several artifacts share it, so a lone research note never invents an initiative.
|
|
369
|
+
const declared = new Set(metas.map((m) => m.initiative).filter(Boolean));
|
|
370
|
+
const byStem = new Map();
|
|
371
|
+
for (const m of metas) if (!m.initiative) byStem.set(m.stem, (byStem.get(m.stem) || 0) + 1);
|
|
372
|
+
const groups = new Map();
|
|
373
|
+
const ungrouped = [];
|
|
374
|
+
const keyFor = (m) => {
|
|
375
|
+
if (m.initiative) return m.initiative;
|
|
376
|
+
if (declared.has(m.stem)) return m.stem;
|
|
377
|
+
// the convention join: `jira-sync-prd.md` + `jira-sync-eval-rubric.md` are one unit
|
|
378
|
+
if ((byStem.get(m.stem) || 0) > 1) return m.stem;
|
|
379
|
+
return null;
|
|
380
|
+
};
|
|
381
|
+
for (const m of metas) {
|
|
382
|
+
const k = keyFor(m);
|
|
383
|
+
const art = { name: m.name, path: m.path, role: m.role, title: m.title };
|
|
384
|
+
if (!k) { ungrouped.push(art); continue; }
|
|
385
|
+
if (!groups.has(k)) groups.set(k, { id: k, title: null, status: null, stage: null, kr: null, artifacts: [] });
|
|
386
|
+
const g = groups.get(k);
|
|
387
|
+
g.artifacts.push(art);
|
|
388
|
+
if (m.role === 'PRD' && m.title) g.title = m.title; // the PRD names the initiative
|
|
389
|
+
else if (!g.title && m.title) g.title = m.title;
|
|
390
|
+
// status/stage/anchor: the PRD is authoritative; any artifact fills a gap, never overrides it
|
|
391
|
+
if (m.status && (m.role === 'PRD' || !g.status)) g.status = m.status;
|
|
392
|
+
if (m.stage && (m.role === 'PRD' || !g.stage)) g.stage = m.stage;
|
|
393
|
+
if (m.kr && (m.role === 'PRD' || !g.kr)) g.kr = m.kr;
|
|
394
|
+
}
|
|
395
|
+
const ROLE_ORDER = ['PRD', 'rubric', 'eval', 'design brief', 'design', 'reconcile', 'dogfood'];
|
|
396
|
+
const initiatives = [...groups.values()].map((g) => {
|
|
397
|
+
g.artifacts.sort((a, b) => {
|
|
398
|
+
const ia = ROLE_ORDER.indexOf(a.role), ib = ROLE_ORDER.indexOf(b.role);
|
|
399
|
+
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) || a.name.localeCompare(b.name);
|
|
400
|
+
});
|
|
401
|
+
return g;
|
|
402
|
+
}).sort((a, b) => a.id.localeCompare(b.id));
|
|
403
|
+
ungrouped.sort((a, b) => a.path.localeCompare(b.path));
|
|
404
|
+
return { initiatives, ungrouped };
|
|
405
|
+
}
|
|
406
|
+
// The folder lens (`roots`) is the complete repo map. The initiative lens is a PROJECTION: it
|
|
407
|
+
// groups planning/ into initiatives and carries the PMOS knowledge (okf/skills/build-skills,
|
|
408
|
+
// wherever they live) + governance. Its guarantee is that it never DROPS a planning document —
|
|
409
|
+
// not that it mirrors every file in the repo (the folder lens is for that).
|
|
410
|
+
function library(root) {
|
|
411
|
+
const shape = wsShape(root);
|
|
412
|
+
const roots = libraryTree(root);
|
|
413
|
+
const { initiatives, ungrouped } = shape === 'pmos' ? initiativeIndex(root) : { initiatives: [], ungrouped: [] };
|
|
414
|
+
const knowledge = shape === 'pmos'
|
|
415
|
+
? ['okf', 'skills', 'build-skills'].flatMap((d) => findDirsNamed(root, d, BUNDLE_MAX_DEPTH))
|
|
416
|
+
.map((d) => tree(root, d, {}, 0, { n: TREE_MAX_NODES }))
|
|
417
|
+
.filter((t) => t.dirs.length || t.docs.length)
|
|
418
|
+
: [];
|
|
419
|
+
return { shape, roots, initiatives, ungrouped, knowledge, governance: looseDocs(root) };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ── On-demand surfaces (D60 / the workspace-ondemand-surfaces PRD). ────────────────────────────
|
|
423
|
+
// Plan/Deliver/Measure/Report OUTPUTS, produced when the PM asks — never a resident dashboard.
|
|
424
|
+
// Every surface derives ONLY through local files (the D54 state adapter: planning/ frontmatter,
|
|
425
|
+
// state/needs.json, state/acceptances.jsonl) — no Supabase, no network — so it runs identically in
|
|
426
|
+
// pmos-self and a create-pmos file-mode repo. `surface-contract.js` validates the shape before the
|
|
427
|
+
// client renders it; a surface that can't be produced returns ok:false and the client shows an
|
|
428
|
+
// honest can't-produce state. RICE/effort/milestones/progress are NET-NEW state → emitted as null
|
|
429
|
+
// ("— not tracked"), never invented (D14/D40 honesty).
|
|
430
|
+
const PHASE_OF = { planning: 'plan', building: 'deliver', blocked: 'deliver', landed: 'report' };
|
|
431
|
+
function statusKey(raw) {
|
|
432
|
+
const s = String(raw || '').toLowerCase();
|
|
433
|
+
if (/block/.test(s)) return 'blocked';
|
|
434
|
+
if (/ship|merg|accept|closed|done|live/.test(s)) return 'landed';
|
|
435
|
+
if (/build|progress|in build|dogfood/.test(s)) return 'building';
|
|
436
|
+
if (/intake|spec|plan|draft|propos/.test(s)) return 'planning';
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
function statusFrom(g) {
|
|
440
|
+
return statusKey(g.status) || statusKey(g.stage) || 'planning';
|
|
441
|
+
}
|
|
442
|
+
function readAcceptances(w) {
|
|
443
|
+
const acc = new Map(); // initiative_id -> last action (accept | approve | return)
|
|
444
|
+
const f = acceptancesFileFor(w);
|
|
445
|
+
if (!f) return acc;
|
|
446
|
+
try {
|
|
447
|
+
for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
|
|
448
|
+
if (!line.trim()) continue;
|
|
449
|
+
// Tolerate both shapes: this server's {action} and the kit's {accepted} (D54 adapter).
|
|
450
|
+
try {
|
|
451
|
+
const r = JSON.parse(line);
|
|
452
|
+
if (!r.initiative_id) continue;
|
|
453
|
+
acc.set(r.initiative_id, r.action || (r.accepted === true ? 'accept' : r.accepted === false ? 'return' : null));
|
|
454
|
+
} catch {}
|
|
455
|
+
}
|
|
456
|
+
} catch {}
|
|
457
|
+
return acc;
|
|
458
|
+
}
|
|
459
|
+
function surfaceInitiatives(root, w) {
|
|
460
|
+
const { initiatives } = initiativeIndex(root);
|
|
461
|
+
const acc = readAcceptances(w);
|
|
462
|
+
const items = initiatives.map((g) => {
|
|
463
|
+
const sk = statusFrom(g);
|
|
464
|
+
return {
|
|
465
|
+
id: g.id, title: g.title || null,
|
|
466
|
+
status: sk, statusText: g.status || g.stage || sk, phase: PHASE_OF[sk] || 'plan',
|
|
467
|
+
stage: g.stage || null, kr: g.kr || null,
|
|
468
|
+
accepted: acc.get(g.id) === 'accept',
|
|
469
|
+
artifacts: g.artifacts.map((a) => ({ role: a.role, name: a.name, path: a.path })),
|
|
470
|
+
// NET-NEW state — not on disk yet, so null (the surface renders "— not tracked", never a guess)
|
|
471
|
+
rice: null, effort: null, milestones: null, progress: null, tracked: false,
|
|
472
|
+
};
|
|
473
|
+
});
|
|
474
|
+
const by = (k) => items.filter((i) => i.status === k).length;
|
|
475
|
+
const needs = (readNeeds(w) || []).map((n) => ({
|
|
476
|
+
t: n.question || '(unstated)', kind: n.kind || 'question', initiative_id: n.initiative_id || null,
|
|
477
|
+
}));
|
|
478
|
+
return {
|
|
479
|
+
initiatives: items,
|
|
480
|
+
pulse: { total: items.length, planning: by('planning'), building: by('building'), blocked: by('blocked'), landed: by('landed') },
|
|
481
|
+
needs,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
function surfaceRoadmap(root, w) {
|
|
485
|
+
const items = surfaceInitiatives(root, w).initiatives;
|
|
486
|
+
const bucket = (label, keys) => ({
|
|
487
|
+
label, dates_tracked: false,
|
|
488
|
+
items: items.filter((i) => keys.includes(i.status)).map((i) => ({ id: i.id, status: i.status, phase: i.phase, progress: i.progress })),
|
|
489
|
+
});
|
|
490
|
+
const groups = [bucket('In flight', ['building', 'blocked']), bucket('Planned', ['planning']), bucket('Landed', ['landed'])]
|
|
491
|
+
.filter((g) => g.items.length);
|
|
492
|
+
return { groups, note: 'dates are net-new state — grouped by status until a timeline is tracked' };
|
|
493
|
+
}
|
|
494
|
+
function surfaceOkr(root) {
|
|
495
|
+
const objectives = [];
|
|
496
|
+
for (const dir of findDirsNamed(root, 'okrs', BUNDLE_MAX_DEPTH)) {
|
|
497
|
+
for (const f of walkMd(dir, [])) {
|
|
498
|
+
let text = '';
|
|
499
|
+
try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
|
|
500
|
+
let cur = null;
|
|
501
|
+
for (const line of text.split('\n')) {
|
|
502
|
+
const o = line.match(/^#{1,3}\s+(O\d+)\s*[—–:-]?\s*(.+)$/);
|
|
503
|
+
if (o) { cur = { id: o[1], name: o[2].replace(/\*\*/g, '').trim().slice(0, 120), krs: [] }; objectives.push(cur); continue; }
|
|
504
|
+
const k = line.match(/^\s*[-*]\s*\*\*\s*(KR\d+)\s*[—–:-]?\s*(.+?)\*\*/);
|
|
505
|
+
if (k && cur) cur.krs.push({ id: k[1], name: k[2].trim().slice(0, 140), current: null, target: null, levers: null });
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return { objectives, note: 'KR current/target and the lever tree are net-new state — shown as "— not tracked"' };
|
|
510
|
+
}
|
|
511
|
+
function surfaceReport(root, w) {
|
|
512
|
+
const s = surfaceInitiatives(root, w);
|
|
513
|
+
const line = (i) => `${i.id}${i.title ? ' — ' + i.title : ''}`;
|
|
514
|
+
const inflight = s.initiatives.filter((i) => i.status === 'building' || i.status === 'blocked');
|
|
515
|
+
const landed = s.initiatives.filter((i) => i.status === 'landed');
|
|
516
|
+
const sections = [
|
|
517
|
+
{ heading: 'In flight', items: inflight.map(line) },
|
|
518
|
+
{ heading: 'Landed', items: landed.map(line) },
|
|
519
|
+
{ heading: 'Needs you', items: s.needs.map((n) => n.t) },
|
|
520
|
+
{ heading: 'Metrics pulse', items: [`${s.pulse.total} initiatives · ${s.pulse.building} building · ${s.pulse.blocked} blocked · ${s.pulse.landed} landed`] },
|
|
521
|
+
];
|
|
522
|
+
return { cadence: 'weekly', sections };
|
|
523
|
+
}
|
|
524
|
+
function buildSurface(name, root, w) {
|
|
525
|
+
switch (name) {
|
|
526
|
+
case 'initiatives': return surfaceInitiatives(root, w);
|
|
527
|
+
case 'roadmap': return surfaceRoadmap(root, w);
|
|
528
|
+
case 'okr': return surfaceOkr(root);
|
|
529
|
+
case 'report': return surfaceReport(root, w);
|
|
530
|
+
default: return null;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ── Scheduled tasks (workspace-scheduled-tasks PRD). ────────────────────────────────────────────
|
|
535
|
+
// A local scheduler: schedules live in state/schedules.json; a tick fires due ones; each fire runs
|
|
536
|
+
// a SKILL-backed task and produces a contract-checked deliverable, filed to needs-you. Backend-free
|
|
537
|
+
// (D62). v1 tasks are DETERMINISTIC (generated from a surface + the contract = maximally consistent,
|
|
538
|
+
// no agent needed); agent-backed tasks (reasoning work) that spawn a PTY session are the documented
|
|
539
|
+
// extension, not v1. Cadence math is the pure shared module — the tick and the pane's next-run agree.
|
|
540
|
+
const SCHED_FILE = path.join(STATE, 'schedules.json');
|
|
541
|
+
const DELIV_DIR = path.join(STATE, 'deliverables');
|
|
542
|
+
// Skills are file-driven (workspace-skill-authoring): built-ins ship committed under workspace/skills/,
|
|
543
|
+
// user-authored ones are scaffolded into state/skills/ by the New Skill flow. There is ONE user-facing
|
|
544
|
+
// concept — a skill — so the picker never exposes the internal `run`/`kind`; how a skill runs (server
|
|
545
|
+
// derives it instantly, or a claude PTY session reasons through it) is decided by the file, not the PM.
|
|
546
|
+
const BUILTIN_SKILLS_DIR = path.join(__dirname, 'skills');
|
|
547
|
+
const USER_SKILLS_DIR = path.join(STATE, 'skills');
|
|
548
|
+
// Read the skill files fresh each call so a just-created user skill appears with no restart. User dirs
|
|
549
|
+
// load FIRST and built-ins LAST, so a built-in's load-bearing deterministic fast-path can't be shadowed.
|
|
550
|
+
function loadTasks() {
|
|
551
|
+
return skillStore.loadSkills([
|
|
552
|
+
{ dir: USER_SKILLS_DIR, source: 'user' },
|
|
553
|
+
{ dir: BUILTIN_SKILLS_DIR, source: 'builtin' },
|
|
554
|
+
]).map(toTask);
|
|
555
|
+
}
|
|
556
|
+
// Map a loaded skill to the internal task shape fireSchedule/the picker use. `kind` is derived from the
|
|
557
|
+
// file's `run` and stays INTERNAL — it is never a field the PM selects.
|
|
558
|
+
function toTask(s) {
|
|
559
|
+
const t = { id: s.id, label: s.label, skill: s.id, kind: s.run, source: s.source,
|
|
560
|
+
description: s.description, output: s.output };
|
|
561
|
+
if (s.run === 'deterministic') t.surface = s.surface;
|
|
562
|
+
else { t.agent = s.agent || 'claude'; t.prompt = s.body || null; }
|
|
563
|
+
return t;
|
|
564
|
+
}
|
|
565
|
+
function taskById(id) { return loadTasks().find((t) => t.id === id || t.skill === id) || null; }
|
|
566
|
+
function readSchedules() {
|
|
567
|
+
try { const a = JSON.parse(fs.readFileSync(SCHED_FILE, 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; }
|
|
568
|
+
}
|
|
569
|
+
function writeSchedules(list) {
|
|
570
|
+
try { fs.writeFileSync(SCHED_FILE, JSON.stringify(list)); }
|
|
571
|
+
catch (e) { warnWriteFail('schedules.json (scheduled tasks)', e); }
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function reportToMarkdown(surface) {
|
|
575
|
+
const out = ['# Weekly report', '', `_${surface.cadence || 'weekly'} · generated ${nowISO()}_`, ''];
|
|
576
|
+
for (const sec of (surface.sections || [])) {
|
|
577
|
+
out.push(`## ${sec.heading}`, '');
|
|
578
|
+
for (const it of (sec.items && sec.items.length ? sec.items : ['—'])) out.push(`- ${it}`);
|
|
579
|
+
out.push('');
|
|
580
|
+
}
|
|
581
|
+
return out.join('\n');
|
|
582
|
+
}
|
|
583
|
+
// Fire one schedule: run its skill → contract → deliverable → stamp → file to needs-you. Same path
|
|
584
|
+
// for a scheduled tick and for run-now (R7). Pure of PTYs in v1 (deterministic report skill).
|
|
585
|
+
function fireSchedule(sched) {
|
|
586
|
+
const w = workspaces.get(sched.ws) || homeWorkspace();
|
|
587
|
+
const root = w ? w.root : SELF_ROOT;
|
|
588
|
+
const task = taskById(sched.task) || taskById(sched.skill);
|
|
589
|
+
// AGENT task — fire as a real PTY session running its skill (async; the skill files its own
|
|
590
|
+
// deliverable). The workspace starts it + stamps 'started'; run-now and the tick share this path.
|
|
591
|
+
if (task && task.kind === 'agent') {
|
|
592
|
+
const s = spawnSession({ name: sched.name, agent: task.agent || 'claude', ws: sched.ws,
|
|
593
|
+
origin: 'schedule:' + sched.id, initialInput: task.prompt ? task.prompt + '\r' : null });
|
|
594
|
+
const list = readSchedules(); const cur = list.find((x) => x.id === sched.id);
|
|
595
|
+
const started = s.state !== 'exited';
|
|
596
|
+
if (cur) { cur.lastRun = nowISO(); cur.lastStatus = started ? 'started' : 'fail'; cur.nextRun = scheduleLogic.nextRun(cur); writeSchedules(list); }
|
|
597
|
+
return { ok: started, kind: 'agent', sessionId: s.id, reason: started ? '' : (s.note || 'could not start the session') };
|
|
598
|
+
}
|
|
599
|
+
const runId = `sched-${sched.id}-${Date.now()}`;
|
|
600
|
+
let ok = false, deliverable = null, reason = '';
|
|
601
|
+
try {
|
|
602
|
+
if (task && task.kind === 'deterministic') {
|
|
603
|
+
const surface = buildSurface(task.surface, root, w);
|
|
604
|
+
const contract = checkSurface(task.surface, surface || {});
|
|
605
|
+
if (contract.pass) {
|
|
606
|
+
try { fs.mkdirSync(DELIV_DIR, { recursive: true }); } catch {}
|
|
607
|
+
fs.writeFileSync(path.join(DELIV_DIR, runId + '.md'), reportToMarkdown(surface));
|
|
608
|
+
deliverable = runId; ok = true;
|
|
609
|
+
} else { reason = 'contract: missing ' + (contract.missing || []).join(', '); }
|
|
610
|
+
} else { reason = 'no skill-backed task'; }
|
|
611
|
+
} catch (e) { reason = 'error: ' + String(e && e.message || 'failed').slice(0, 120); }
|
|
612
|
+
// stamp lastRun/lastStatus/nextRun via the pure module
|
|
613
|
+
const list = readSchedules();
|
|
614
|
+
const cur = list.find((s) => s.id === sched.id);
|
|
615
|
+
if (cur) { cur.lastRun = nowISO(); cur.lastStatus = ok ? 'ok' : 'fail'; cur.nextRun = scheduleLogic.nextRun(cur); writeSchedules(list); }
|
|
616
|
+
// surface it — the finished run reaches the PM through needs-you (R5), honest on failure
|
|
617
|
+
try {
|
|
618
|
+
const nf = needsFileFor(w);
|
|
619
|
+
if (nf) {
|
|
620
|
+
let needs = []; try { needs = JSON.parse(fs.readFileSync(nf, 'utf8')); } catch {}
|
|
621
|
+
needs.push(ok
|
|
622
|
+
? { run_id: runId, kind: 'report', initiative_id: sched.name, question: `${sched.name} — report ready`, deliverable }
|
|
623
|
+
: { run_id: runId, kind: 'report', initiative_id: sched.name, question: `${sched.name} could not produce its report — ${reason}` });
|
|
624
|
+
fs.writeFileSync(nf, JSON.stringify(needs));
|
|
625
|
+
}
|
|
626
|
+
} catch (e) { warnWriteFail('needs.json (needs-you queue, schedule fire)', e); }
|
|
627
|
+
return { ok, runId, deliverable, reason };
|
|
628
|
+
}
|
|
629
|
+
function checkDueSchedules() {
|
|
630
|
+
let fired = 0;
|
|
631
|
+
for (const s of readSchedules()) { try { if (scheduleLogic.isDue(s)) { fireSchedule(s); fired++; } } catch {} }
|
|
632
|
+
return fired;
|
|
633
|
+
}
|
|
634
|
+
// the tick — the persistent server checks on an interval; unref'd so it never blocks exit
|
|
635
|
+
try { const _tick = setInterval(checkDueSchedules, 60000); if (_tick.unref) _tick.unref(); } catch {}
|
|
636
|
+
|
|
637
|
+
// ── Sessions: real PTYs, owned by the server, with disk-backed scrollback + metadata. ──
|
|
638
|
+
/** @type {Map<string, {id,ws,name,agent,cwd,state,note,created,pty,buf,cols,rows,clients:Set}>} */
|
|
639
|
+
const sessions = new Map();
|
|
640
|
+
/** every connected ws — broadcasts must reach clients that haven't attached to anything yet */
|
|
641
|
+
const allClients = new Set();
|
|
642
|
+
let seq = 0;
|
|
643
|
+
|
|
644
|
+
function persist(s) {
|
|
645
|
+
// note IS persisted — a restored session must still say why it exited. `root` is persisted
|
|
646
|
+
// so a session survives even if the workspace registry is lost (it re-registers its root).
|
|
647
|
+
const meta = { id: s.id, ws: s.ws, root: s.root, name: s.name, agent: s.agent, cwd: s.cwd,
|
|
648
|
+
state: s.state, note: s.note || '', created: s.created, tmux: s.tmux || null, origin: s.origin || null };
|
|
649
|
+
try {
|
|
650
|
+
fs.writeFileSync(path.join(SESS_DIR, s.id + '.json'), JSON.stringify(meta));
|
|
651
|
+
fs.writeFileSync(path.join(SESS_DIR, s.id + '.scrollback'), s.buf);
|
|
652
|
+
} catch { /* best-effort; never crash a live session on a disk blip */ }
|
|
653
|
+
}
|
|
654
|
+
function unpersist(id) {
|
|
655
|
+
for (const ext of ['.json', '.scrollback']) {
|
|
656
|
+
try { fs.unlinkSync(path.join(SESS_DIR, id + ext)); } catch {}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
function restorePersisted() {
|
|
660
|
+
// On server start, load prior sessions' metadata + scrollback. A tmux-backed session whose
|
|
661
|
+
// process is STILL ALIVE (it outlived this server via the tmux daemon) is RE-ATTACHED live —
|
|
662
|
+
// the PM's `claude` keeps its in-flight state across `pmos stop`/relaunch, no respawn (R7).
|
|
663
|
+
// A session whose process is truly gone (tmux absent, or the daemon/process ended) loads as
|
|
664
|
+
// 'exited' with replayable scrollback and a one-click respawn — honest, never a fake-live pane.
|
|
665
|
+
let files = [];
|
|
666
|
+
try { files = fs.readdirSync(SESS_DIR).filter((f) => f.endsWith('.json')); } catch { return; }
|
|
667
|
+
for (const f of files) {
|
|
668
|
+
try {
|
|
669
|
+
const meta = JSON.parse(fs.readFileSync(path.join(SESS_DIR, f), 'utf8'));
|
|
670
|
+
if (!meta.id || sessions.has(meta.id)) continue;
|
|
671
|
+
let buf = '';
|
|
672
|
+
try { buf = fs.readFileSync(path.join(SESS_DIR, meta.id + '.scrollback'), 'utf8'); } catch {}
|
|
673
|
+
// re-home the session: its workspace must exist for it to be reachable in the UI
|
|
674
|
+
const root = meta.root || SELF_ROOT;
|
|
675
|
+
const w = workspaces.get(meta.ws) || registerWorkspace(root) || homeWorkspace();
|
|
676
|
+
const s = {
|
|
677
|
+
...meta, ws: w ? w.id : wsId(SELF_ROOT), root: w ? w.root : SELF_ROOT,
|
|
678
|
+
pty: null, buf, tmux: meta.tmux || null, cols: 80, rows: 24, clients: new Set(),
|
|
679
|
+
};
|
|
680
|
+
sessions.set(meta.id, s);
|
|
681
|
+
if (TMUX && meta.tmux && tmuxHasSession(meta.tmux)) {
|
|
682
|
+
s.state = 'working'; s.note = '';
|
|
683
|
+
attachPty(s); // the process is alive — reconnect to it, live
|
|
684
|
+
} else {
|
|
685
|
+
s.state = 'exited';
|
|
686
|
+
s.note = meta.note || 'restored after server restart — scrollback preserved; ↻ respawn to start a new process';
|
|
687
|
+
}
|
|
688
|
+
const n = Number(String(meta.id).split('-')[1]); if (Number.isFinite(n) && n >= seq) seq = n + 1;
|
|
689
|
+
} catch {}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Broadcast to EVERY connected client exactly once (not per-session — a fresh client that
|
|
694
|
+
// hasn't attached must still see session-list updates; an N-session client must not get N copies).
|
|
695
|
+
function broadcast(obj) {
|
|
696
|
+
const msg = JSON.stringify(obj);
|
|
697
|
+
for (const c of allClients) if (c.readyState === 1) c.send(msg);
|
|
698
|
+
}
|
|
699
|
+
// The agent process behind a session. tmux spawns the command directly (see ensureTmuxSession's
|
|
700
|
+
// `-- cmd`, no shell), so pane_pid IS the agent — one hop, no guessing. Cached because a pid
|
|
701
|
+
// cannot change for the life of a session.
|
|
702
|
+
function agentPid(s) {
|
|
703
|
+
if (s.agentPid !== undefined) return s.agentPid;
|
|
704
|
+
let pid = null;
|
|
705
|
+
if (s.tmux && TMUX) {
|
|
706
|
+
const r = tmuxCtl(['display-message', '-p', '-t', s.tmux, '#{pane_pid}']);
|
|
707
|
+
const n = r && r.status === 0 ? parseInt(String(r.stdout).trim(), 10) : NaN;
|
|
708
|
+
if (Number.isFinite(n) && n > 0) pid = n;
|
|
709
|
+
} else if (s.pty && s.pty.pid) {
|
|
710
|
+
pid = s.pty.pid;
|
|
711
|
+
}
|
|
712
|
+
s.agentPid = pid;
|
|
713
|
+
return pid;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// The harness itself comes from the CLI's own STRUCTURED transcript, not from rendered bytes:
|
|
717
|
+
// the CLI never PRINTS its model, effort, token usage or skill set to the terminal, so parsing
|
|
718
|
+
// scrollback for them returned four empty rows. Scrollback still owns the one thing it is
|
|
719
|
+
// actually the source of — the agent's last words. Re-read on transcript mtime/size change.
|
|
720
|
+
function transcriptHarness(s) {
|
|
721
|
+
if (s.state === 'exited') return { missing: { all: 'the session has exited' } };
|
|
722
|
+
// A `shell` session is included: the PM launches `claude` INSIDE the default shell pane, so the
|
|
723
|
+
// agent is a descendant of the pane pid — findTranscript's child-walk resolves it. A non-claude,
|
|
724
|
+
// non-shell agent (e.g. codex) publishes no claude transcript, so it keeps the honest message.
|
|
725
|
+
if (s.agent !== 'claude' && s.agent !== 'shell') {
|
|
726
|
+
return { missing: { all: 'only claude sessions publish a transcript' } };
|
|
727
|
+
}
|
|
728
|
+
// findTranscript may shell out to `ps` for the child-walk; throttle so a broadcast storm cannot
|
|
729
|
+
// spawn a ps per session per push. Re-resolving every ~2s also picks up a claude the PM restarts
|
|
730
|
+
// inside the same shell (a new child pid) without a stale cache pinning the old transcript.
|
|
731
|
+
const now = Date.now();
|
|
732
|
+
if (!s.txFound || (now - (s.txFoundAt || 0)) > 2000) {
|
|
733
|
+
s.txFound = transcript.findTranscript(agentPid(s), s.root);
|
|
734
|
+
s.txFoundAt = now;
|
|
735
|
+
}
|
|
736
|
+
const found = s.txFound;
|
|
737
|
+
if (found.missing) return { missing: { all: found.missing } };
|
|
738
|
+
let stamp = null;
|
|
739
|
+
try { const st = fs.statSync(found.file); stamp = `${st.size}:${st.mtimeMs}`; } catch {}
|
|
740
|
+
if (s.tx && s.txStamp === stamp) return s.tx;
|
|
741
|
+
const h = transcript.readHarness(found.file, scrub);
|
|
742
|
+
h.cliVersion = found.version || null;
|
|
743
|
+
s.tx = h; s.txStamp = stamp;
|
|
744
|
+
return h;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Parse lazily and cache per buffer length: sessionList() runs on every broadcast, and
|
|
748
|
+
// re-reading a 32 KB tail per session per push would be waste. `appendBuf` invalidates.
|
|
749
|
+
function harnessOf(s) {
|
|
750
|
+
if (s.parsed && s.parsedAt === s.buf.length) return s.parsed;
|
|
751
|
+
let out;
|
|
752
|
+
try {
|
|
753
|
+
// scrub() FIRST, always (D08): the tail holds whatever the agent printed, so an unscrubbed
|
|
754
|
+
// "last words" line could put a credential in the sidebar. Reuses the session-search path.
|
|
755
|
+
out = harness.parseHarness(scrub(s.buf || ''), s.agent);
|
|
756
|
+
} catch (e) {
|
|
757
|
+
// A throwing parser must still say it could not read — never fall through to a blank row.
|
|
758
|
+
const why = 'the parser failed on this output';
|
|
759
|
+
out = { lastWords: null, tools: null, model: null, context: null,
|
|
760
|
+
missing: { lastWords: `can't read its last words — ${why}`, tools: why, model: why, context: why } };
|
|
761
|
+
}
|
|
762
|
+
s.parsed = out; s.parsedAt = s.buf.length;
|
|
763
|
+
return out;
|
|
764
|
+
}
|
|
765
|
+
function sessionList() {
|
|
766
|
+
const now = Date.now();
|
|
767
|
+
return [...sessions.values()].map((s) => {
|
|
768
|
+
const d = harness.deriveState(s.state, s.lastOutputAt, now);
|
|
769
|
+
const h = harnessOf(s);
|
|
770
|
+
return {
|
|
771
|
+
id: s.id, ws: s.ws, name: s.name, agent: s.agent, state: s.state, created: s.created,
|
|
772
|
+
cwd: path.relative(s.root, s.cwd) || '.', note: s.note || '', origin: s.origin || null,
|
|
773
|
+
// derived presentation state: working | quiet | exited. `shell`-ness stays on `agent`,
|
|
774
|
+
// because it is orthogonal to state; `failed` does not exist (no observable for it).
|
|
775
|
+
shown: d.state, quietMs: d.quietMs, lastOutputAt: s.lastOutputAt || null,
|
|
776
|
+
lastWords: h.lastWords,
|
|
777
|
+
// the harness proper — structured record, not rendered bytes
|
|
778
|
+
hx: transcriptHarness(s),
|
|
779
|
+
missing: h.missing,
|
|
780
|
+
};
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
function pushSessions() { broadcast({ t: 'sessions', sessions: sessionList() }); }
|
|
784
|
+
function pushWorkspaces() { broadcast({ t: 'workspaces', workspaces: wsList() }); }
|
|
785
|
+
|
|
786
|
+
function appendBuf(s, data) {
|
|
787
|
+
s.buf += data;
|
|
788
|
+
if (s.buf.length > SCROLLBACK) s.buf = s.buf.slice(s.buf.length - SCROLLBACK);
|
|
789
|
+
// Parser 2's whole input: the moment output last arrived. `quiet` is derived from this, never
|
|
790
|
+
// from reading the bytes — which is why it is the only one of the five that cannot misread.
|
|
791
|
+
s.lastOutputAt = Date.now();
|
|
792
|
+
s.parsed = null; // buffer moved; the cached parse is stale
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Platform-correct guidance when a spawn fails. `posix_spawnp failed` almost always means the
|
|
796
|
+
// node-pty NATIVE BINARY is broken/wrong-arch (e.g. x64 Node on Apple Silicon) — a shell change
|
|
797
|
+
// won't fix that; a rebuild will.
|
|
798
|
+
function spawnFailureAdvice(err, cmd, isShell) {
|
|
799
|
+
const msg = String(err && err.message || err);
|
|
800
|
+
if (/posix_spawnp|EFAULT|ENOEXEC|wrong architecture|not valid/i.test(msg)) {
|
|
801
|
+
return `could not spawn '${cmd}' (${msg}). This is usually a broken node-pty native build ` +
|
|
802
|
+
`(arch mismatch). Fix: stop the server, run \`npm run doctor\` in workspace/, ` +
|
|
803
|
+
`then \`npm rebuild node-pty --build-from-source\`.`;
|
|
804
|
+
}
|
|
805
|
+
if (process.platform === 'win32') {
|
|
806
|
+
return `could not start '${cmd}': ${msg}. Native Windows needs WSL for a real PTY shell — ` +
|
|
807
|
+
`run the workspace inside WSL.`;
|
|
808
|
+
}
|
|
809
|
+
return isShell
|
|
810
|
+
? `could not start a shell ('${cmd}'): ${msg}. Set SHELL to a valid shell (e.g. /bin/zsh) and restart.`
|
|
811
|
+
: `could not launch '${cmd}': ${msg} — is it installed and on PATH?`;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Does `cmd` exist as an executable? (On Linux node-pty does NOT throw for a missing
|
|
815
|
+
// command — the exec fails inside the fork — so a pre-spawn PATH check is the only way
|
|
816
|
+
// to give a deterministic "not installed" answer on every platform.)
|
|
817
|
+
function commandExists(cmd) {
|
|
818
|
+
const X = fs.constants.X_OK;
|
|
819
|
+
try {
|
|
820
|
+
if (cmd.includes('/') || cmd.includes('\\')) { fs.accessSync(cmd, X); return true; }
|
|
821
|
+
const exts = process.platform === 'win32' ? (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';') : [''];
|
|
822
|
+
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
|
|
823
|
+
if (!dir) continue;
|
|
824
|
+
for (const ext of exts) {
|
|
825
|
+
try { fs.accessSync(path.join(dir, cmd + ext), X); return true; } catch {}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
} catch {}
|
|
829
|
+
return false;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// ── tmux-backed sessions: real persistence across a SERVER restart (R7). ───────────────
|
|
833
|
+
// Each session's process runs inside a DETACHED tmux session on a private socket, so stopping
|
|
834
|
+
// or restarting THIS server (`pmos stop`, a crash, a code deploy) leaves the process alive in
|
|
835
|
+
// the tmux daemon; on relaunch we re-attach to the LIVE process — no respawn, no lost agent
|
|
836
|
+
// state (the reported defect). tmux is wrapped TRANSPARENTLY — prefix disabled, no status bar,
|
|
837
|
+
// no key/mouse bindings, RGB passed through — so the harness inside still runs untouched (D62).
|
|
838
|
+
// Where tmux is absent, spawnSession falls back to a direct PTY (today's behavior): honest,
|
|
839
|
+
// just not restart-proof.
|
|
840
|
+
const TMUX = commandExists('tmux') ? 'tmux' : null;
|
|
841
|
+
const TMUX_SOCK = process.env.PMOS_TMUX_SOCK || 'pmos'; // private socket; never the user's own
|
|
842
|
+
const TMUX_CONF = path.join(STATE, 'tmux.conf');
|
|
843
|
+
let shuttingDown = false; // set on SIGTERM/SIGINT — suppress reattach
|
|
844
|
+
function tmuxName(id) { return 'pmos-' + id; }
|
|
845
|
+
function writeTmuxConf() {
|
|
846
|
+
if (!TMUX) return;
|
|
847
|
+
const conf = [
|
|
848
|
+
// Full transparency: no prefix, no bindings, no status line, no mouse capture — every
|
|
849
|
+
// keystroke and mouse event reaches the app inside, unaltered (the harness runs untouched).
|
|
850
|
+
'set -g prefix None', 'set -g prefix2 None', 'unbind -a',
|
|
851
|
+
'set -g status off', 'set -g mouse off', 'set -g escape-time 0',
|
|
852
|
+
'set -g history-limit 100000', 'set -g destroy-unattached off',
|
|
853
|
+
'set -g window-size latest', 'setw -g aggressive-resize on',
|
|
854
|
+
// a (re)attaching client must not push a stale or CLAUDE_CODE-marked env into new panes
|
|
855
|
+
'set -g update-environment ""',
|
|
856
|
+
'set -g default-terminal "screen-256color"', 'set -as terminal-features ",*:RGB"',
|
|
857
|
+
].join('\n') + '\n';
|
|
858
|
+
try { fs.writeFileSync(TMUX_CONF, conf); } catch {}
|
|
859
|
+
}
|
|
860
|
+
// A tmux CONTROL command (not the attach PTY). Uses the CLEANED session env so that the cold
|
|
861
|
+
// start this may trigger seeds the tmux server's own environment marker-free (D62 / sessionEnv).
|
|
862
|
+
function tmuxCtl(args) { return spawnSync(TMUX, ['-L', TMUX_SOCK, ...args], { env: sessionEnv(), encoding: 'utf8' }); }
|
|
863
|
+
function tmuxHasSession(name) { return !!TMUX && tmuxCtl(['has-session', '-t', name]).status === 0; }
|
|
864
|
+
function tmuxKillSession(name) { if (TMUX && name) tmuxCtl(['kill-session', '-t', name]); }
|
|
865
|
+
function ensureTmuxSession(name, cmd, workdir, cols, rows) {
|
|
866
|
+
if (tmuxHasSession(name)) return true; // already alive (a reattach) — leave it
|
|
867
|
+
const r = tmuxCtl(['-f', TMUX_CONF, 'new-session', '-d', '-s', name,
|
|
868
|
+
'-x', String(cols || 80), '-y', String(rows || 24), '-c', workdir, '--', cmd]);
|
|
869
|
+
return r.status === 0;
|
|
870
|
+
}
|
|
871
|
+
// Spawn the server-side PTY that is a tmux client attached to session s's LIVE tmux session,
|
|
872
|
+
// wiring streaming + exit. Reused by spawnSession (fresh) and restorePersisted (reattach).
|
|
873
|
+
function attachPty(s) {
|
|
874
|
+
const name = tmuxName(s.id);
|
|
875
|
+
const startedMs = Date.now();
|
|
876
|
+
const p = pty.spawn(TMUX, ['-L', TMUX_SOCK, 'attach-session', '-t', name],
|
|
877
|
+
{ name: 'xterm-256color', cols: s.cols || 80, rows: s.rows || 24, cwd: s.cwd, env: sessionEnv() });
|
|
878
|
+
s.pty = p; s.tmux = name; s.state = 'working';
|
|
879
|
+
// Start the quiet clock at ATTACH, not at first byte: a session that spawns and immediately
|
|
880
|
+
// hangs should go quiet on schedule rather than read 'working' forever. On a restore this
|
|
881
|
+
// means "no output since we re-attached", which is the only thing we can honestly claim.
|
|
882
|
+
s.lastOutputAt = Date.now(); s.parsed = null;
|
|
883
|
+
p.onData((d) => {
|
|
884
|
+
if (s._reattempts) s._reattempts = 0; // a working attach clears the retry budget
|
|
885
|
+
appendBuf(s, d);
|
|
886
|
+
for (const c of s.clients) if (c.readyState === 1) c.send(JSON.stringify({ t: 'data', id: s.id, data: d }));
|
|
887
|
+
});
|
|
888
|
+
p.onExit(({ exitCode }) => {
|
|
889
|
+
s.pty = null;
|
|
890
|
+
// Our PTY is the tmux CLIENT: it exits when the pane process ends AND on a mere detach. If
|
|
891
|
+
// the tmux session is still alive, the process lives on — reconnect (bounded, to never spin).
|
|
892
|
+
// If it's gone, the process truly exited.
|
|
893
|
+
if (!shuttingDown && tmuxHasSession(name)) {
|
|
894
|
+
s._reattempts = (s._reattempts || 0) + 1;
|
|
895
|
+
if (s._reattempts <= 5) {
|
|
896
|
+
setTimeout(() => { if (!s.pty && !shuttingDown && tmuxHasSession(name)) attachPty(s); }, 200);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
tmuxKillSession(name); // give up cleanly rather than loop
|
|
900
|
+
}
|
|
901
|
+
s.state = 'exited';
|
|
902
|
+
const fast = Date.now() - startedMs < 800;
|
|
903
|
+
s.note = fast
|
|
904
|
+
? `'${s.agent}' exited immediately. If this repeats, run \`npm run doctor\` — usually a broken node-pty build or a shell rc that exits.`
|
|
905
|
+
: `exited (code ${exitCode})`;
|
|
906
|
+
appendBuf(s, `\r\n\x1b[38;5;244m[${s.note}]\x1b[0m\r\n`);
|
|
907
|
+
persist(s); pushSessions();
|
|
908
|
+
});
|
|
909
|
+
return p;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Try a command, falling back to `sh` on posix if the preferred shell can't spawn.
|
|
913
|
+
function spawnPty(cmd, workdir) {
|
|
914
|
+
const opts = { name: 'xterm-256color', cols: 80, rows: 24, cwd: workdir, env: sessionEnv() };
|
|
915
|
+
const chain = process.platform === 'win32' ? [cmd] : [...new Set([cmd, 'sh', '/bin/sh'])];
|
|
916
|
+
let lastErr;
|
|
917
|
+
for (const c of chain) {
|
|
918
|
+
try { return { pty: pty.spawn(c, [], opts), cmd: c }; } catch (e) { lastErr = e; }
|
|
919
|
+
}
|
|
920
|
+
throw lastErr;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// An honest exited pane: say WHY in the pane and the session note. Never a fake-live pane.
|
|
924
|
+
function exitedSession(base, workdir, why) {
|
|
925
|
+
const s = { ...base, cwd: workdir, state: 'exited', pty: null, tmux: null, note: why,
|
|
926
|
+
buf: `\x1b[38;5;203m✗ ${why}\x1b[0m\r\n\x1b[38;5;244mFix the above, then ↻ respawn.\x1b[0m\r\n`,
|
|
927
|
+
cols: 80, rows: 24, clients: new Set(), created: nowISO() };
|
|
928
|
+
sessions.set(s.id, s); persist(s); pushSessions(); pushWorkspaces(); return s;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
|
|
932
|
+
const id = 'sess-' + (seq++);
|
|
933
|
+
const w = getWs(ws);
|
|
934
|
+
const root = w ? w.root : SELF_ROOT;
|
|
935
|
+
const isShell = agent === 'shell';
|
|
936
|
+
// a scheduled run tags the session with its origin, and feeds the skill invocation as first input
|
|
937
|
+
const tag = origin || null;
|
|
938
|
+
const feed = (s) => { if (initialInput && s && s.pty) { try { setTimeout(() => { try { s.pty.write(initialInput); } catch {} }, 700); } catch {} } };
|
|
939
|
+
// shell gets a real fallback to `sh`; agent CLIs run as-is (harness untouched), NO fallback —
|
|
940
|
+
// falling back from `claude` to `sh` would be a lie.
|
|
941
|
+
const cmd = isShell ? (commandExists(SHELL) ? SHELL : 'sh') : agent;
|
|
942
|
+
// a session's working directory is confined to its OWN workspace root (R3)
|
|
943
|
+
const workdir = cwd && insideRoot(root, path.resolve(root, cwd)) ? path.resolve(root, cwd) : root;
|
|
944
|
+
const base = { id, ws: w ? w.id : wsId(SELF_ROOT), root, name: (name && String(name).trim()) || agent, agent, origin: tag };
|
|
945
|
+
|
|
946
|
+
// A missing agent CLI is detected BEFORE any spawn (on Linux the exec-in-fork failure is
|
|
947
|
+
// otherwise silent) — the same honest exited pane on both the tmux and the direct path.
|
|
948
|
+
if (!isShell && !commandExists(agent)) {
|
|
949
|
+
return exitedSession(base, workdir,
|
|
950
|
+
spawnFailureAdvice(Object.assign(new Error(`'${agent}' not found on PATH`), { code: 'ENOENT' }), agent, false));
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// ── the tmux path: the process outlives this server, and we re-attach to it live (R7) ──
|
|
954
|
+
if (TMUX) {
|
|
955
|
+
const tname = tmuxName(id);
|
|
956
|
+
const s = { ...base, cwd: workdir, state: 'working', pty: null, tmux: tname,
|
|
957
|
+
note: '', buf: '', cols: 80, rows: 24, clients: new Set(), created: nowISO() };
|
|
958
|
+
sessions.set(id, s);
|
|
959
|
+
if (!ensureTmuxSession(tname, cmd, workdir, 80, 24)) {
|
|
960
|
+
const why = `could not start a tmux session for '${cmd}'. Run \`npm run doctor\` in workspace/.`;
|
|
961
|
+
s.state = 'exited'; s.tmux = null; s.note = why;
|
|
962
|
+
s.buf = `\x1b[38;5;203m✗ ${why}\x1b[0m\r\n\x1b[38;5;244mFix the above, then ↻ respawn.\x1b[0m\r\n`;
|
|
963
|
+
persist(s); pushSessions(); pushWorkspaces(); return s;
|
|
964
|
+
}
|
|
965
|
+
attachPty(s);
|
|
966
|
+
feed(s);
|
|
967
|
+
persist(s); pushSessions(); pushWorkspaces(); return s;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// ── fallback: a direct PTY (no tmux on this machine) — honest, just not restart-proof ──
|
|
971
|
+
const startedMs = Date.now();
|
|
972
|
+
let p, usedCmd = cmd;
|
|
973
|
+
try {
|
|
974
|
+
if (isShell) { const r = spawnPty(cmd, workdir); p = r.pty; usedCmd = r.cmd; }
|
|
975
|
+
else p = pty.spawn(cmd, [], { name: 'xterm-256color', cols: 80, rows: 24, cwd: workdir, env: sessionEnv() });
|
|
976
|
+
} catch (e) {
|
|
977
|
+
return exitedSession(base, workdir, spawnFailureAdvice(e, cmd, isShell));
|
|
978
|
+
}
|
|
979
|
+
const s = { ...base, cwd: workdir, state: 'working',
|
|
980
|
+
pty: p, tmux: null, note: '', buf: '', cols: 80, rows: 24, clients: new Set(), created: nowISO(),
|
|
981
|
+
lastOutputAt: Date.now() };
|
|
982
|
+
sessions.set(id, s);
|
|
983
|
+
p.onData((d) => {
|
|
984
|
+
appendBuf(s, d);
|
|
985
|
+
for (const c of s.clients) if (c.readyState === 1) c.send(JSON.stringify({ t: 'data', id, data: d }));
|
|
986
|
+
});
|
|
987
|
+
p.onExit(({ exitCode }) => {
|
|
988
|
+
s.state = 'exited'; s.pty = null;
|
|
989
|
+
const fast = Date.now() - startedMs < 800; // exited almost immediately → surface a hint
|
|
990
|
+
s.note = fast
|
|
991
|
+
? `'${usedCmd}' exited immediately (code ${exitCode}). If this repeats, run \`npm run doctor\` — usually a broken node-pty build or a shell rc that exits.`
|
|
992
|
+
: `exited (code ${exitCode})`;
|
|
993
|
+
appendBuf(s, `\r\n\x1b[38;5;244m[${s.note}]\x1b[0m\r\n`);
|
|
994
|
+
persist(s); pushSessions();
|
|
995
|
+
});
|
|
996
|
+
feed(s);
|
|
997
|
+
persist(s); pushSessions(); pushWorkspaces();
|
|
998
|
+
return s;
|
|
999
|
+
}
|
|
1000
|
+
// Remove a session from the registry + disk. Only non-live sessions can be removed;
|
|
1001
|
+
// a live one must be killed first (its onExit flips it to exited).
|
|
1002
|
+
function removeSession(id) {
|
|
1003
|
+
const s = sessions.get(id);
|
|
1004
|
+
if (!s || s.pty) return false;
|
|
1005
|
+
if (s.tmux) tmuxKillSession(s.tmux); // best-effort: never orphan a detached tmux session
|
|
1006
|
+
sessions.delete(id); unpersist(id); pushSessions(); pushWorkspaces();
|
|
1007
|
+
return true;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Rename a session. A name chosen at creation is a guess — the PM learns what the run actually
|
|
1011
|
+
// is minutes later, so renaming must work at ANY point, live or exited (R11). The name is the
|
|
1012
|
+
// only thing that makes a list of sessions readable, so it is persisted like the rest of the meta.
|
|
1013
|
+
const NAME_MAX = 60;
|
|
1014
|
+
function renameSession(id, name) {
|
|
1015
|
+
const s = sessions.get(id);
|
|
1016
|
+
if (!s) return false;
|
|
1017
|
+
const next = String(name == null ? '' : name).replace(/[\r\n\t]/g, ' ').trim().slice(0, NAME_MAX);
|
|
1018
|
+
if (!next || next === s.name) return false; // empty is a cancel, not a rename
|
|
1019
|
+
s.name = next;
|
|
1020
|
+
persist(s); pushSessions();
|
|
1021
|
+
return true;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// periodic scrollback persistence so a crash loses at most a few seconds of history
|
|
1025
|
+
setInterval(() => { for (const s of sessions.values()) if (s.pty) persist(s); }, 5000).unref?.();
|
|
1026
|
+
|
|
1027
|
+
// ── File-mode state, per workspace. ───────────────────────────────────────────────────
|
|
1028
|
+
// A repo that carries PMOS state OWNS its records. The kit (D54) keeps them under `pmos/state/`
|
|
1029
|
+
// and `scripts/metrics.py` reads exactly that path for the north star and κ. Reads and writes
|
|
1030
|
+
// MUST resolve through this one function: a queue read per-repo but recorded into the server's
|
|
1031
|
+
// own state/ is how a tenant's acceptance disappears from the repo that earned it — the click
|
|
1032
|
+
// goes green and the record it was supposed to leave never exists.
|
|
1033
|
+
function stateDirFor(w, { create = false } = {}) {
|
|
1034
|
+
if (!w) return null;
|
|
1035
|
+
for (const rel of [['pmos', 'state'], ['state'], ['.pmos']]) {
|
|
1036
|
+
const d = path.join(w.root, ...rel);
|
|
1037
|
+
if (isDir(d)) return d;
|
|
1038
|
+
}
|
|
1039
|
+
if (w.root === SELF_ROOT) return STATE; // pmos-self: the server's own state/ IS its file-mode mirror
|
|
1040
|
+
// A PMOS-shaped repo with no state dir yet gets the kit's own layout on first write. Never
|
|
1041
|
+
// invented for a plain repo — there we fall back and SAY SO rather than write somewhere wrong.
|
|
1042
|
+
if (create && wsShape(w.root) === 'pmos') {
|
|
1043
|
+
const d = path.join(w.root, 'pmos', 'state');
|
|
1044
|
+
try { fs.mkdirSync(d, { recursive: true }); return d; } catch {}
|
|
1045
|
+
}
|
|
1046
|
+
return null;
|
|
1047
|
+
}
|
|
1048
|
+
function acceptancesFileFor(w, opts) {
|
|
1049
|
+
const d = stateDirFor(w, opts);
|
|
1050
|
+
return d ? path.join(d, 'acceptances.jsonl') : null;
|
|
1051
|
+
}
|
|
1052
|
+
// needs-you: an explicit queue file the harness writes wins; otherwise the workspace's state dir.
|
|
1053
|
+
function needsFileFor(w) {
|
|
1054
|
+
if (!w) return null;
|
|
1055
|
+
const local = path.join(w.root, '.pmos', 'needs.json');
|
|
1056
|
+
if (fs.existsSync(local)) return local;
|
|
1057
|
+
const d = stateDirFor(w);
|
|
1058
|
+
return d ? path.join(d, 'needs.json') : null;
|
|
1059
|
+
}
|
|
1060
|
+
function readJsonl(dir, name) {
|
|
1061
|
+
const out = [];
|
|
1062
|
+
try {
|
|
1063
|
+
for (const line of fs.readFileSync(path.join(dir, name), 'utf8').split('\n')) {
|
|
1064
|
+
if (!line.trim()) continue;
|
|
1065
|
+
try { out.push(JSON.parse(line)); } catch {}
|
|
1066
|
+
}
|
|
1067
|
+
} catch {}
|
|
1068
|
+
return out;
|
|
1069
|
+
}
|
|
1070
|
+
// The LEADING frontmatter block only — body text must never be able to fake a declaration.
|
|
1071
|
+
// The implementation now lives in lib/file-mode-parse.js and is MIRRORED by kit/scripts/metrics.py,
|
|
1072
|
+
// with kit/fixtures/test_parsers.py enforcing that the JS and Python parsers agree (D64 Track D1).
|
|
1073
|
+
function leadingFrontmatter(file) { return fmParse.leadingFrontmatter(file); }
|
|
1074
|
+
// ── Derived needs-you: what the PM owes a decision on, computed from the repo's own
|
|
1075
|
+
// append-only state rather than waiting for something to seed a queue file. Two sources, both
|
|
1076
|
+
// decisions only the human can close:
|
|
1077
|
+
// 1. an eval with no acceptance for its run_id → the Acceptance Gate is open (D12)
|
|
1078
|
+
// 2. a declared outcome past its horizon, never disposed → the D57 check is due
|
|
1079
|
+
// The outcome rule mirrors kit/scripts/metrics.py EXACTLY, including "a missing horizon counts
|
|
1080
|
+
// as due" — a bet with no judgment date can never come due by itself (horizon-gaming-by-omission).
|
|
1081
|
+
// Derivation only ever reports; it never disposes anything (D57).
|
|
1082
|
+
function deriveNeeds(w) {
|
|
1083
|
+
const dir = stateDirFor(w);
|
|
1084
|
+
if (!dir) return [];
|
|
1085
|
+
const needs = [];
|
|
1086
|
+
const settled = new Set();
|
|
1087
|
+
for (const a of readJsonl(dir, 'acceptances.jsonl')) if (a.run_id) settled.add(a.run_id);
|
|
1088
|
+
for (const e of readJsonl(dir, 'evals.jsonl')) {
|
|
1089
|
+
if (!e.run_id || settled.has(e.run_id)) continue;
|
|
1090
|
+
const verdict = e.verdict ? String(e.verdict) : 'no verdict';
|
|
1091
|
+
needs.push({
|
|
1092
|
+
run_id: e.run_id, initiative_id: e.initiative_id || null, kind: 'accept', derived: true,
|
|
1093
|
+
question: `Acceptance Gate open — evaluator says ${verdict}${e.score != null ? ` (${e.score})` : ''}`,
|
|
1094
|
+
doc: e.rubric_path || null,
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
const disposed = new Set();
|
|
1098
|
+
for (const c of readJsonl(dir, 'outcome_checks.jsonl')) if (c.initiative_id) disposed.add(c.initiative_id);
|
|
1099
|
+
const today = nowISO().slice(0, 10);
|
|
1100
|
+
let files = [];
|
|
1101
|
+
try { files = fs.readdirSync(path.join(dir, 'initiatives')).filter((f) => f.endsWith('.md')).sort(); } catch {}
|
|
1102
|
+
for (const f of files) {
|
|
1103
|
+
const fm = leadingFrontmatter(path.join(dir, 'initiatives', f));
|
|
1104
|
+
if (!fm.id || !fm.outcome_metric || disposed.has(fm.id)) continue;
|
|
1105
|
+
if (String(fm.outcome_horizon || '0000-00-00') >= today) continue;
|
|
1106
|
+
needs.push({
|
|
1107
|
+
run_id: `outcome:${fm.id}`, initiative_id: fm.id, kind: 'outcome', derived: true,
|
|
1108
|
+
question: `Outcome check due — "${fm.outcome_metric}" (horizon ${fm.outcome_horizon || 'none declared'})`,
|
|
1109
|
+
doc: null,
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
return needs;
|
|
1113
|
+
}
|
|
1114
|
+
// The queue the harness writes is authoritative for the entries it names; everything the repo's
|
|
1115
|
+
// own state implies is added beneath it. A repo nobody has seeded still shows its real work.
|
|
1116
|
+
function readNeeds(w) {
|
|
1117
|
+
const f = needsFileFor(w);
|
|
1118
|
+
let queued = [];
|
|
1119
|
+
if (f) {
|
|
1120
|
+
try {
|
|
1121
|
+
const parsed = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
1122
|
+
if (Array.isArray(parsed)) queued = parsed;
|
|
1123
|
+
} catch {}
|
|
1124
|
+
}
|
|
1125
|
+
const seen = new Set(queued.map((n) => n && n.run_id).filter(Boolean));
|
|
1126
|
+
return queued.concat(deriveNeeds(w).filter((n) => !seen.has(n.run_id)));
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// ── Read-only console surfaces over file-mode state (D64 Track B). ──────────────────────
|
|
1130
|
+
// The committed hosted snapshot is the seed/demo source when no LIVE file-mode state exists; the
|
|
1131
|
+
// readers in lib/console-state.js always prefer live state. pmos-self reads its own snapshot; a
|
|
1132
|
+
// tenant workspace only ever reads ITS OWN snapshot (never pmos-self's), so a tenant with no data
|
|
1133
|
+
// gets an honest "no source" error rather than someone else's numbers.
|
|
1134
|
+
function latestSnapshotDir(root) {
|
|
1135
|
+
const base = path.join(root, 'planning', 'data-export');
|
|
1136
|
+
let ents = [];
|
|
1137
|
+
try { ents = fs.readdirSync(base, { withFileTypes: true }); } catch { return null; }
|
|
1138
|
+
const dirs = ents.filter((e) => e.isDirectory() && /hosted-snapshot/.test(e.name)).map((e) => e.name).sort();
|
|
1139
|
+
return dirs.length ? path.join(base, dirs[dirs.length - 1]) : null; // dates sort → newest last
|
|
1140
|
+
}
|
|
1141
|
+
// The needs-you console read, honest by construction: a corrupt/unreadable queue file is an ERROR
|
|
1142
|
+
// (red on the client), NEVER a green "nothing awaits". A successfully-computed empty queue is empty.
|
|
1143
|
+
function needsResult(w) {
|
|
1144
|
+
if (!w) return { ok: false, error: 'no workspace resolved for the needs-you queue' };
|
|
1145
|
+
const f = needsFileFor(w);
|
|
1146
|
+
let queued = [];
|
|
1147
|
+
if (f && fs.existsSync(f)) {
|
|
1148
|
+
let text;
|
|
1149
|
+
try { text = fs.readFileSync(f, 'utf8'); } catch { return { ok: false, error: 'the needs-you queue file is unreadable — file-mode state error, not empty' }; }
|
|
1150
|
+
try {
|
|
1151
|
+
const parsed = JSON.parse(text);
|
|
1152
|
+
if (!Array.isArray(parsed)) return { ok: false, error: 'the needs-you queue file is not a JSON array — corrupt state, not empty' };
|
|
1153
|
+
queued = parsed;
|
|
1154
|
+
} catch { return { ok: false, error: 'the needs-you queue file is not valid JSON — corrupt state, not empty' }; }
|
|
1155
|
+
}
|
|
1156
|
+
let derived = [];
|
|
1157
|
+
try { derived = deriveNeeds(w); } catch { return { ok: false, error: 'could not derive needs from file-mode state' }; }
|
|
1158
|
+
const seen = new Set(queued.map((n) => n && n.run_id).filter(Boolean));
|
|
1159
|
+
const all = queued.concat(derived.filter((n) => !seen.has(n.run_id)));
|
|
1160
|
+
const items = all.map((n) => ({
|
|
1161
|
+
run_id: n.run_id || null, kind: n.kind || 'question', initiative_id: n.initiative_id || null,
|
|
1162
|
+
question: n.question || n.t || '(unstated)', derived: !!n.derived, doc: n.doc || null,
|
|
1163
|
+
deliverable: n.deliverable || null,
|
|
1164
|
+
}));
|
|
1165
|
+
if (!items.length) return { ok: true, empty: true };
|
|
1166
|
+
return { ok: true, empty: false, data: { items } };
|
|
1167
|
+
}
|
|
1168
|
+
function buildConsole(name, w, root) {
|
|
1169
|
+
const stateDir = stateDirFor(w);
|
|
1170
|
+
const snapshotDir = latestSnapshotDir(root);
|
|
1171
|
+
if (name === 'needs') return needsResult(w);
|
|
1172
|
+
if (name === 'metrics') return consoleState.metrics({ stateDir, snapshotDir });
|
|
1173
|
+
if (name === 'radar') return consoleState.radar({ stateDir, snapshotDir });
|
|
1174
|
+
return { ok: false, error: 'unknown console surface: ' + name };
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// ── Session search: episodic recall over this workspace's own scrollback. ─────────────
|
|
1178
|
+
// Every session's scrollback is already mirrored to disk (a 200 KB ring per session), which
|
|
1179
|
+
// makes it a searchable record of what past runs actually did — the gap `get_agent_runs`
|
|
1180
|
+
// leaves open, since it returns structured rows and no run text. An agent that cannot recall
|
|
1181
|
+
// what it tried repeats it, and repeated corrections are exactly the north star (D10).
|
|
1182
|
+
//
|
|
1183
|
+
// Two invariants:
|
|
1184
|
+
// - Scoped to ONE workspace by root. Workspace A never reads workspace B's history (R1/R3).
|
|
1185
|
+
// - Secrets are scrubbed from every emitted line. Scrollback holds whatever the agent printed,
|
|
1186
|
+
// so D08's never-log-headers discipline applies to what this surface hands back. Matching
|
|
1187
|
+
// runs against the SCRUBBED text, so this endpoint cannot be used to grep a token out.
|
|
1188
|
+
const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|[\x00-\x08\x0b\x0c\x0e-\x1f]/g;
|
|
1189
|
+
const SECRETS = [
|
|
1190
|
+
[/\b(authorization|x-api-key|api-key|cookie|set-cookie)(\s*:\s*)\S+/gi, '$1$2«redacted»'],
|
|
1191
|
+
[/\bBearer\s+[A-Za-z0-9._~+/-]{8,}=*/gi, 'Bearer «redacted»'],
|
|
1192
|
+
[/\beyJ[A-Za-z0-9._-]{20,}/g, '«redacted-jwt»'],
|
|
1193
|
+
[/\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}/g, '«redacted-key»'],
|
|
1194
|
+
[/\b(?:ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{16,}/g, '«redacted-token»'],
|
|
1195
|
+
[/\bservice_role[A-Za-z0-9_-]*\s*[:=]\s*\S+/gi, 'service_role «redacted»'],
|
|
1196
|
+
];
|
|
1197
|
+
function scrub(line) {
|
|
1198
|
+
let out = String(line).replace(ANSI, '');
|
|
1199
|
+
for (const [re, to] of SECRETS) out = out.replace(re, to);
|
|
1200
|
+
return out;
|
|
1201
|
+
}
|
|
1202
|
+
const SEARCH_MAX = 40;
|
|
1203
|
+
function searchSessions(w, q, limit = SEARCH_MAX) {
|
|
1204
|
+
const needle = String(q || '').toLowerCase();
|
|
1205
|
+
if (!w || needle.length < 2) return [];
|
|
1206
|
+
let files = [];
|
|
1207
|
+
try { files = fs.readdirSync(SESS_DIR).filter((f) => f.endsWith('.json')); } catch {}
|
|
1208
|
+
const metas = [];
|
|
1209
|
+
for (const f of files) {
|
|
1210
|
+
try {
|
|
1211
|
+
const meta = JSON.parse(fs.readFileSync(path.join(SESS_DIR, f), 'utf8'));
|
|
1212
|
+
if (!meta || !meta.id || meta.root !== w.root) continue; // isolation: this workspace only
|
|
1213
|
+
let mtime = 0;
|
|
1214
|
+
try { mtime = fs.statSync(path.join(SESS_DIR, meta.id + '.scrollback')).mtimeMs; } catch {}
|
|
1215
|
+
metas.push({ meta, mtime });
|
|
1216
|
+
} catch {}
|
|
1217
|
+
}
|
|
1218
|
+
metas.sort((a, b) => b.mtime - a.mtime); // most recent run first
|
|
1219
|
+
const hits = [];
|
|
1220
|
+
for (const { meta, mtime } of metas) {
|
|
1221
|
+
if (hits.length >= limit) break;
|
|
1222
|
+
let text = '';
|
|
1223
|
+
try { text = fs.readFileSync(path.join(SESS_DIR, meta.id + '.scrollback'), 'utf8'); } catch { continue; }
|
|
1224
|
+
const lines = text.split(/\r?\n/);
|
|
1225
|
+
for (let i = 0; i < lines.length && hits.length < limit; i++) {
|
|
1226
|
+
const clean = scrub(lines[i]);
|
|
1227
|
+
if (!clean.toLowerCase().includes(needle)) continue;
|
|
1228
|
+
hits.push({
|
|
1229
|
+
session_id: meta.id, session: meta.name, agent: meta.agent, state: meta.state,
|
|
1230
|
+
at: mtime ? new Date(mtime).toISOString() : null,
|
|
1231
|
+
before: lines[i - 1] ? scrub(lines[i - 1]).trim().slice(0, 200) : '',
|
|
1232
|
+
line: clean.trim().slice(0, 400),
|
|
1233
|
+
after: lines[i + 1] ? scrub(lines[i + 1]).trim().slice(0, 200) : '',
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
return hits;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// ── Choosing a folder. ────────────────────────────────────────────────────────────────
|
|
1241
|
+
// A browser cannot hand back an absolute path (its own directory picker yields a name and a
|
|
1242
|
+
// handle, never a path), so "add a repository" would otherwise stay a typed-path field. The
|
|
1243
|
+
// server — which runs on the PM's own machine, bound to 127.0.0.1 (D62) — opens the machine's
|
|
1244
|
+
// native folder chooser instead and returns only the chosen path. It registers nothing on its
|
|
1245
|
+
// own: the client posts the result back through /api/workspaces so every registration keeps
|
|
1246
|
+
// going through registerWorkspace's guards.
|
|
1247
|
+
let pickerBusy = false; // one dialog at a time — a second click must not stack
|
|
1248
|
+
const PICK_TIMEOUT = 120_000;
|
|
1249
|
+
function runPicker(cmd, args) {
|
|
1250
|
+
return new Promise((resolve) => {
|
|
1251
|
+
execFile(cmd, args, { timeout: PICK_TIMEOUT, maxBuffer: 1 << 20 }, (err, stdout) => {
|
|
1252
|
+
const out = String(stdout || '').trim();
|
|
1253
|
+
if (err && !out) return resolve({ cancelled: true }); // cancel exits non-zero on every OS
|
|
1254
|
+
resolve({ path: out });
|
|
1255
|
+
});
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
async function pickFolder(startDir) {
|
|
1259
|
+
const start = isDir(startDir || '') ? path.resolve(startDir) : os.homedir();
|
|
1260
|
+
if (process.platform === 'darwin') {
|
|
1261
|
+
if (!commandExists('osascript')) return { unsupported: true };
|
|
1262
|
+
const script = `set d to choose folder with prompt "Choose a repository to open as a workspace" default location POSIX file ${JSON.stringify(start)}\nPOSIX path of d`;
|
|
1263
|
+
return runPicker('osascript', ['-e', script]);
|
|
1264
|
+
}
|
|
1265
|
+
if (process.platform === 'win32') {
|
|
1266
|
+
const ps = commandExists('powershell.exe') ? 'powershell.exe' : (commandExists('pwsh') ? 'pwsh' : null);
|
|
1267
|
+
if (!ps) return { unsupported: true };
|
|
1268
|
+
const script = 'Add-Type -AssemblyName System.Windows.Forms;' +
|
|
1269
|
+
'$d = New-Object System.Windows.Forms.FolderBrowserDialog;' +
|
|
1270
|
+
`$d.SelectedPath = ${JSON.stringify(start)};` +
|
|
1271
|
+
'if ($d.ShowDialog() -eq "OK") { Write-Output $d.SelectedPath }';
|
|
1272
|
+
return runPicker(ps, ['-NoProfile', '-STA', '-Command', script]);
|
|
1273
|
+
}
|
|
1274
|
+
if (commandExists('zenity')) {
|
|
1275
|
+
return runPicker('zenity', ['--file-selection', '--directory', `--filename=${start}${path.sep}`,
|
|
1276
|
+
'--title=Choose a repository to open as a workspace']);
|
|
1277
|
+
}
|
|
1278
|
+
if (commandExists('kdialog')) return runPicker('kdialog', ['--getexistingdirectory', start]);
|
|
1279
|
+
return { unsupported: true }; // headless Linux: the in-app browser stands in
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// The in-app folder browser's data source, used when there is no native chooser (and by anyone
|
|
1283
|
+
// who prefers it). It widens what the server DISCLOSES beyond the registered workspace roots —
|
|
1284
|
+
// consciously: it returns the NAMES of directories only, never file contents, never file names,
|
|
1285
|
+
// and the server binds 127.0.0.1 only (D62). readTextDoc's per-workspace guard is untouched;
|
|
1286
|
+
// nothing here can read a byte of anything.
|
|
1287
|
+
const FS_LIST_MAX = 400;
|
|
1288
|
+
function fsList(dir) {
|
|
1289
|
+
const abs = path.resolve(dir && String(dir).trim() ? dir : os.homedir());
|
|
1290
|
+
if (!isDir(abs)) return null;
|
|
1291
|
+
const parent = path.dirname(abs);
|
|
1292
|
+
const entries = [];
|
|
1293
|
+
let ents = [];
|
|
1294
|
+
try { ents = fs.readdirSync(abs, { withFileTypes: true }); }
|
|
1295
|
+
catch { return { path: abs, parent: parent === abs ? null : parent, home: os.homedir(), entries: [], denied: true }; }
|
|
1296
|
+
for (const e of ents.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1297
|
+
if (!e.isDirectory() || e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue;
|
|
1298
|
+
const full = path.join(abs, e.name);
|
|
1299
|
+
entries.push({ name: e.name, path: full, isRepo: fs.existsSync(path.join(full, '.git')) });
|
|
1300
|
+
if (entries.length >= FS_LIST_MAX) break;
|
|
1301
|
+
}
|
|
1302
|
+
return { path: abs, parent: parent === abs ? null : parent, home: os.homedir(), entries, denied: false };
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// ── HTTP: static assets + the file/gate APIs. Headers never logged (D08). ─────────────
|
|
1306
|
+
const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
|
|
1307
|
+
'.css': 'text/css; charset=utf-8', '.json': 'application/json', '.map': 'application/json',
|
|
1308
|
+
'.svg': 'image/svg+xml', '.woff2': 'font/woff2' };
|
|
1309
|
+
function serveFile(res, file, type, noCache) {
|
|
1310
|
+
fs.readFile(file, (err, buf) => {
|
|
1311
|
+
if (err) { res.writeHead(404); res.end('not found'); return; }
|
|
1312
|
+
const headers = { 'Content-Type': type || TYPES[path.extname(file)] || 'application/octet-stream' };
|
|
1313
|
+
// The app SHELL (index.html + app.js) must revalidate every load: without this a tab left open
|
|
1314
|
+
// across a `git merge` + server restart keeps running the pre-merge JS, so a fix that IS being
|
|
1315
|
+
// served never reaches the screen. `no-cache` = "always revalidate", not "never store".
|
|
1316
|
+
if (noCache) headers['Cache-Control'] = 'no-cache';
|
|
1317
|
+
res.writeHead(200, headers);
|
|
1318
|
+
res.end(buf);
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
function readBody(req) {
|
|
1322
|
+
return new Promise((resolve) => { let b = ''; req.on('data', (c) => (b += c)); req.on('end', () => resolve(b)); });
|
|
1323
|
+
}
|
|
1324
|
+
function json(res, obj, code = 200) {
|
|
1325
|
+
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
1326
|
+
res.end(JSON.stringify(obj));
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
async function handleRequest(req, res) {
|
|
1330
|
+
const url = new URL(req.url, 'http://localhost');
|
|
1331
|
+
const p = url.pathname;
|
|
1332
|
+
|
|
1333
|
+
if (p === '/' || p === '/index.html') return serveFile(res, path.join(PUB, 'index.html'), null, true);
|
|
1334
|
+
if (p === '/app.js') return serveFile(res, path.join(PUB, 'app.js'), null, true);
|
|
1335
|
+
if (p === '/lib/preview-kind.js') return serveFile(res, path.join(__dirname, 'lib', 'preview-kind.js'));
|
|
1336
|
+
if (p === '/styles.css') return serveFile(res, path.join(PUB, 'styles.css'));
|
|
1337
|
+
// xterm assets straight from node_modules (offline; no CDN)
|
|
1338
|
+
if (p === '/vendor/xterm.js') return serveFile(res, path.join(XROOT, 'xterm', 'lib', 'xterm.js'));
|
|
1339
|
+
if (p === '/vendor/xterm.css') return serveFile(res, path.join(XROOT, 'xterm', 'css', 'xterm.css'));
|
|
1340
|
+
if (p === '/vendor/addon-fit.js') return serveFile(res, path.join(XROOT, 'addon-fit', 'lib', 'addon-fit.js'));
|
|
1341
|
+
if (p === '/vendor/addon-web-links.js') return serveFile(res, path.join(XROOT, 'addon-web-links', 'lib', 'addon-web-links.js'));
|
|
1342
|
+
|
|
1343
|
+
if (p === '/api/workspaces' && req.method === 'GET') return json(res, { workspaces: wsList() });
|
|
1344
|
+
if (p === '/api/workspaces' && req.method === 'POST') {
|
|
1345
|
+
// How `pmos` in another repo becomes another workspace (R1). Local-only, like every
|
|
1346
|
+
// route here — the server binds 127.0.0.1 and this only records a path.
|
|
1347
|
+
let body = {};
|
|
1348
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
1349
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
1350
|
+
if (!body.root) return json(res, { ok: false, error: 'root is required' }, 400);
|
|
1351
|
+
const w = registerWorkspace(body.root, body.name);
|
|
1352
|
+
if (!w) return json(res, { ok: false, error: `not a directory: ${body.root}` }, 400);
|
|
1353
|
+
// tell any open client to switch — otherwise the browser refocuses the old tab and the
|
|
1354
|
+
// PM sees the previous repo, which is precisely the reported defect
|
|
1355
|
+
if (body.focus !== false) broadcast({ t: 'focus', ws: w.id });
|
|
1356
|
+
return json(res, { ok: true, workspace: { ...w, home: w.root === SELF_ROOT, shape: wsShape(w.root) } });
|
|
1357
|
+
}
|
|
1358
|
+
if (p === '/api/workspaces/forget' && req.method === 'POST') {
|
|
1359
|
+
let body = {};
|
|
1360
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); } catch {}
|
|
1361
|
+
const ok = forgetWorkspace(body.id);
|
|
1362
|
+
return json(res, { ok, error: ok ? undefined : 'unknown workspace, the home workspace, or it still owns sessions' }, ok ? 200 : 409);
|
|
1363
|
+
}
|
|
1364
|
+
if (p === '/api/pick-folder' && req.method === 'POST') {
|
|
1365
|
+
let body = {};
|
|
1366
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); } catch {}
|
|
1367
|
+
if (pickerBusy) return json(res, { ok: false, busy: true, error: 'a folder chooser is already open' }, 409);
|
|
1368
|
+
pickerBusy = true;
|
|
1369
|
+
let r;
|
|
1370
|
+
try { r = await pickFolder(body.start); } catch { r = { unsupported: true }; }
|
|
1371
|
+
finally { pickerBusy = false; }
|
|
1372
|
+
if (r.unsupported) return json(res, { ok: false, unsupported: true, error: 'no native folder chooser on this system' });
|
|
1373
|
+
if (r.cancelled || !r.path) return json(res, { ok: false, cancelled: true });
|
|
1374
|
+
const root = path.resolve(r.path);
|
|
1375
|
+
if (!isDir(root)) return json(res, { ok: false, error: `not a directory: ${root}` }, 400);
|
|
1376
|
+
return json(res, { ok: true, root });
|
|
1377
|
+
}
|
|
1378
|
+
if (p === '/api/fs/list') {
|
|
1379
|
+
const listing = fsList(url.searchParams.get('path'));
|
|
1380
|
+
if (!listing) return json(res, { ok: false, error: 'not a directory' }, 404);
|
|
1381
|
+
return json(res, { ok: true, ...listing });
|
|
1382
|
+
}
|
|
1383
|
+
if (p === '/api/tree') {
|
|
1384
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
1385
|
+
return json(res, { ws: w ? w.id : null, roots: w ? libraryTree(w.root) : [] });
|
|
1386
|
+
}
|
|
1387
|
+
if (p === '/api/library') {
|
|
1388
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
1389
|
+
if (!w) return json(res, { ws: null, shape: 'plain', roots: [], initiatives: [], ungrouped: [], knowledge: [], governance: [] });
|
|
1390
|
+
return json(res, { ws: w.id, ...library(w.root) });
|
|
1391
|
+
}
|
|
1392
|
+
if (p === '/api/surface') {
|
|
1393
|
+
// On-demand P/D/M/R surface, derived through the local state adapter only (no network).
|
|
1394
|
+
// Always 200: ok=false + the contract's missing[] is how the client renders can't-produce.
|
|
1395
|
+
const name = url.searchParams.get('name') || 'initiatives';
|
|
1396
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
1397
|
+
const root = w ? w.root : SELF_ROOT;
|
|
1398
|
+
let surface = null;
|
|
1399
|
+
try { surface = buildSurface(name, root, w); } catch { surface = null; }
|
|
1400
|
+
const contract = checkSurface(name, surface || {});
|
|
1401
|
+
return json(res, { ok: contract.pass, name, surface: contract.pass ? surface : null, contract });
|
|
1402
|
+
}
|
|
1403
|
+
// ── scheduled tasks ──
|
|
1404
|
+
// The picker's source: file-driven skills. `kind` is carried for internal/back-compat consumers,
|
|
1405
|
+
// but the New Skill flow + the scheduled lens never render it — one concept, no user-facing kind.
|
|
1406
|
+
if (p === '/api/tasks') return json(res, { tasks: loadTasks().map((t) => ({ id: t.id, label: t.label, skill: t.skill, kind: t.kind })) });
|
|
1407
|
+
// ── skills (workspace-skill-authoring): the ONE-concept surface + the New Skill flow ──
|
|
1408
|
+
// GET lists every skill with NO kind field — from the user's view there is just a skill.
|
|
1409
|
+
if (p === '/api/skills' && req.method === 'GET') {
|
|
1410
|
+
const skills = loadTasks().map((t) => ({ id: t.id, label: t.label, skill: t.skill,
|
|
1411
|
+
description: t.description || '', output: t.output || [], source: t.source || 'builtin' }));
|
|
1412
|
+
return json(res, { skills });
|
|
1413
|
+
}
|
|
1414
|
+
// POST scaffolds a well-formed .skill file from name + "what it should do" + the output shape.
|
|
1415
|
+
// A GUI replaces editing server code; user skills default to running as a claude PTY session.
|
|
1416
|
+
if (p === '/api/skills' && req.method === 'POST') {
|
|
1417
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
1418
|
+
const built = skillStore.scaffoldSkill(body);
|
|
1419
|
+
if (!built.ok) return json(res, { ok: false, error: built.error }, 400);
|
|
1420
|
+
if (loadTasks().some((t) => t.id === built.slug)) return json(res, { ok: false, error: `a skill named "${built.slug}" already exists` }, 409);
|
|
1421
|
+
try { fs.mkdirSync(USER_SKILLS_DIR, { recursive: true }); fs.writeFileSync(path.join(USER_SKILLS_DIR, built.slug + '.skill'), built.content); }
|
|
1422
|
+
catch (e) { return json(res, { ok: false, error: 'could not write the skill file' }, 500); }
|
|
1423
|
+
return json(res, { ok: true, skill: { id: built.slug, label: built.label, skill: built.slug,
|
|
1424
|
+
description: built.description, output: built.output, source: 'user' } });
|
|
1425
|
+
}
|
|
1426
|
+
// POST /draft — the POWER-UP: scaffold the file, then spawn a LOCAL claude session (on the PM's
|
|
1427
|
+
// subscription, D54) that refines the skill file in place, grounded in the description + local data.
|
|
1428
|
+
// Always leaves a valid file even where claude is absent; the session is honest about not starting.
|
|
1429
|
+
if (p === '/api/skills/draft' && req.method === 'POST') {
|
|
1430
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
1431
|
+
const built = skillStore.scaffoldSkill(body);
|
|
1432
|
+
if (!built.ok) return json(res, { ok: false, error: built.error }, 400);
|
|
1433
|
+
if (loadTasks().some((t) => t.id === built.slug)) return json(res, { ok: false, error: `a skill named "${built.slug}" already exists` }, 409);
|
|
1434
|
+
try { fs.mkdirSync(USER_SKILLS_DIR, { recursive: true }); fs.writeFileSync(path.join(USER_SKILLS_DIR, built.slug + '.skill'), built.content); }
|
|
1435
|
+
catch { return json(res, { ok: false, error: 'could not write the skill file' }, 500); }
|
|
1436
|
+
const w = getWs(body.ws);
|
|
1437
|
+
const rel = path.relative(w ? w.root : SELF_ROOT, path.join(USER_SKILLS_DIR, built.slug + '.skill'));
|
|
1438
|
+
const prompt = `Refine the PMOS workspace skill file at ${rel}. It was scaffolded from this ask: ` +
|
|
1439
|
+
`"${built.description || built.label}". Keep the frontmatter shape (name, label, description, run: agent, ` +
|
|
1440
|
+
`agent: claude, and an output list = the sections it must ALWAYS produce). Sharpen the body into a concrete, ` +
|
|
1441
|
+
`repeatable procedure grounded in THIS repository's local files and tools only (no network). Then stop.`;
|
|
1442
|
+
const s = spawnSession({ name: 'draft: ' + built.slug, agent: 'claude', ws: body.ws,
|
|
1443
|
+
origin: 'skill-draft:' + built.slug, initialInput: prompt + '\r' });
|
|
1444
|
+
const started = s.state !== 'exited';
|
|
1445
|
+
return json(res, { ok: true, started, sessionId: s.id,
|
|
1446
|
+
reason: started ? '' : (s.note || 'could not start a claude session'),
|
|
1447
|
+
skill: { id: built.slug, label: built.label, skill: built.slug,
|
|
1448
|
+
description: built.description, output: built.output, source: 'user' } });
|
|
1449
|
+
}
|
|
1450
|
+
if (p === '/api/schedules' && req.method === 'GET') {
|
|
1451
|
+
const wsFilter = getWs(url.searchParams.get('ws'));
|
|
1452
|
+
const id = wsFilter ? wsFilter.id : null;
|
|
1453
|
+
const list = readSchedules().filter((s) => !id || s.ws === id).map((s) => ({ ...s, next: scheduleLogic.nextRun(s) }));
|
|
1454
|
+
return json(res, { schedules: list, now: nowISO() });
|
|
1455
|
+
}
|
|
1456
|
+
if (p === '/api/schedules' && req.method === 'POST') {
|
|
1457
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
1458
|
+
if (!taskById(body.task || body.skill)) return json(res, { ok: false, error: 'that task has no backing skill' }, 400);
|
|
1459
|
+
const norm = scheduleLogic.normalizeSchedule(body);
|
|
1460
|
+
if (!norm.ok) return json(res, { ok: false, error: norm.error }, 400);
|
|
1461
|
+
const w = getWs(body.ws);
|
|
1462
|
+
const sched = { id: 'sc-' + Date.now().toString(36), ws: w ? w.id : wsId(SELF_ROOT), ...norm.schedule, lastRun: null, lastStatus: null };
|
|
1463
|
+
sched.nextRun = scheduleLogic.nextRun(sched);
|
|
1464
|
+
const list = readSchedules(); list.unshift(sched); writeSchedules(list);
|
|
1465
|
+
return json(res, { ok: true, schedule: sched });
|
|
1466
|
+
}
|
|
1467
|
+
if (p === '/api/schedules/update' && req.method === 'POST') {
|
|
1468
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
1469
|
+
const list = readSchedules(); const s = list.find((x) => x.id === body.id);
|
|
1470
|
+
if (!s) return json(res, { ok: false, error: 'no such schedule' }, 404);
|
|
1471
|
+
if (typeof body.enabled === 'boolean') s.enabled = body.enabled;
|
|
1472
|
+
for (const k of ['name', 'cadence', 'day', 'dayOfMonth', 'time', 'task', 'skill']) if (body[k] != null) s[k] = body[k];
|
|
1473
|
+
s.nextRun = scheduleLogic.nextRun(s); writeSchedules(list);
|
|
1474
|
+
return json(res, { ok: true, schedule: s });
|
|
1475
|
+
}
|
|
1476
|
+
if (p === '/api/schedules/delete' && req.method === 'POST') {
|
|
1477
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
1478
|
+
writeSchedules(readSchedules().filter((s) => s.id !== body.id));
|
|
1479
|
+
return json(res, { ok: true });
|
|
1480
|
+
}
|
|
1481
|
+
if (p === '/api/schedules/run' && req.method === 'POST') {
|
|
1482
|
+
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON' }, 400); }
|
|
1483
|
+
const s = readSchedules().find((x) => x.id === body.id);
|
|
1484
|
+
if (!s) return json(res, { ok: false, error: 'no such schedule' }, 404);
|
|
1485
|
+
return json(res, { ok: true, result: fireSchedule(s) }); // run-now = the exact fire path
|
|
1486
|
+
}
|
|
1487
|
+
if (p === '/api/schedules/check' && req.method === 'POST') return json(res, { ok: true, fired: checkDueSchedules() });
|
|
1488
|
+
if (p === '/api/deliverable') {
|
|
1489
|
+
const id = String(url.searchParams.get('id') || '');
|
|
1490
|
+
if (!/^[\w.-]+$/.test(id) || id.includes('..')) { res.writeHead(400); return res.end('bad id'); }
|
|
1491
|
+
let md; try { md = fs.readFileSync(path.join(DELIV_DIR, id + '.md'), 'utf8'); } catch { res.writeHead(404); return res.end('not found'); }
|
|
1492
|
+
res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' }); return res.end(md);
|
|
1493
|
+
}
|
|
1494
|
+
if (p === '/api/sessions') return json(res, { sessions: sessionList() });
|
|
1495
|
+
if (p === '/api/doc') {
|
|
1496
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
1497
|
+
const doc = w ? readTextDoc(w.root, url.searchParams.get('path') || '') : null;
|
|
1498
|
+
if (doc == null) { res.writeHead(404); return res.end('not a readable text document in this workspace'); }
|
|
1499
|
+
// HTML is served as text/plain — the client renders it sandboxed on demand, never as live markup.
|
|
1500
|
+
const ctype = doc.kind === 'markdown' ? 'text/markdown; charset=utf-8' : 'text/plain; charset=utf-8';
|
|
1501
|
+
res.writeHead(200, { 'Content-Type': ctype, 'X-Doc-Kind': doc.kind }); return res.end(doc.text);
|
|
1502
|
+
}
|
|
1503
|
+
if (p === '/api/asset') {
|
|
1504
|
+
// Stream a repo-relative IMAGE from the workspace, guarded (R3). Image MIME allowlist only — no
|
|
1505
|
+
// arbitrary file leaves through here; traversal / cross-workspace paths 404 (insideRoot). nosniff +
|
|
1506
|
+
// a script-blocking CSP neutralise a scripted SVG opened directly. This endpoint is the ONLY way an
|
|
1507
|
+
// image reaches the reader, and it never touches a remote URL — the no-beacon invariant (R4).
|
|
1508
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
1509
|
+
if (!w) { res.writeHead(404); return res.end('no workspace'); }
|
|
1510
|
+
const full = path.resolve(w.root, url.searchParams.get('path') || '');
|
|
1511
|
+
if (!insideRoot(w.root, full)) { res.writeHead(404); return res.end('outside workspace'); }
|
|
1512
|
+
const mime = preview.imageMime(path.basename(full));
|
|
1513
|
+
if (!mime) { res.writeHead(404); return res.end('not a servable asset'); }
|
|
1514
|
+
return fs.readFile(full, (err, buf) => {
|
|
1515
|
+
if (err) { res.writeHead(404); return res.end('not found'); }
|
|
1516
|
+
res.writeHead(200, {
|
|
1517
|
+
'Content-Type': mime,
|
|
1518
|
+
'X-Content-Type-Options': 'nosniff',
|
|
1519
|
+
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
|
1520
|
+
});
|
|
1521
|
+
res.end(buf);
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
if (p === '/api/needs') return json(res, { needs: readNeeds(getWs(url.searchParams.get('ws'))) });
|
|
1525
|
+
if (p === '/api/console') {
|
|
1526
|
+
// Read-only console surfaces (needs-you · radar · metrics) over file-mode state (D64 Track B).
|
|
1527
|
+
// Discriminated result: {ok:true,empty} genuinely nothing · {ok:false,error} a broken read the
|
|
1528
|
+
// client MUST render red — never a green empty on a failed read (honesty by construction).
|
|
1529
|
+
const name = url.searchParams.get('name') || 'radar';
|
|
1530
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
1531
|
+
const root = w ? w.root : SELF_ROOT;
|
|
1532
|
+
let out;
|
|
1533
|
+
try { out = buildConsole(name, w, root); }
|
|
1534
|
+
catch (e) { out = { ok: false, error: 'console read failed: ' + String((e && e.message) || 'error').slice(0, 120) }; }
|
|
1535
|
+
return json(res, { name, ...out });
|
|
1536
|
+
}
|
|
1537
|
+
if (p === '/api/search') {
|
|
1538
|
+
// Episodic recall over this workspace's own past sessions. Localhost only, like everything
|
|
1539
|
+
// else here — an agent running in a pane can curl it to check what an earlier run tried.
|
|
1540
|
+
// An unknown ws resolves to the home workspace, like every other endpoint here — so an
|
|
1541
|
+
// unrecognised id can only ever see LESS history, never another repo's.
|
|
1542
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
1543
|
+
const q = String(url.searchParams.get('q') || '').trim();
|
|
1544
|
+
if (q.length < 2) return json(res, { ok: false, error: 'a query of at least 2 characters is required', hits: [] }, 400);
|
|
1545
|
+
const hits = searchSessions(w, q);
|
|
1546
|
+
return json(res, { ok: true, q, workspace: w.name, hits, truncated: hits.length >= SEARCH_MAX });
|
|
1547
|
+
}
|
|
1548
|
+
if (p === '/api/gate' && req.method === 'POST') {
|
|
1549
|
+
// The PMOS Acceptance/Review vocabulary in file mode (R5). NEVER automatic — this is
|
|
1550
|
+
// only reached by an explicit PM click, and it just records the decision.
|
|
1551
|
+
let body = {};
|
|
1552
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
1553
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
1554
|
+
if (!body.action || !body.run_id) return json(res, { ok: false, error: 'action and run_id are required' }, 400);
|
|
1555
|
+
const w = getWs(body.ws);
|
|
1556
|
+
const rec = { at: nowISO(), date: nowISO().slice(0, 10),
|
|
1557
|
+
action: body.action, run_id: body.run_id, initiative_id: body.initiative_id,
|
|
1558
|
+
// `accepted` is the kit's field (D54) — what metrics.py counts for the north star and
|
|
1559
|
+
// throughput. Only the explicit Acceptance action sets it; `approve` is a Review-gate
|
|
1560
|
+
// disposition and leaves it unset. `pm_label` is deliberately NOT derived here: κ labels
|
|
1561
|
+
// are the PM's own word (D40 — never manufacture calibration data from a UI click).
|
|
1562
|
+
accepted: body.action === 'accept' ? true : body.action === 'return' ? false : undefined,
|
|
1563
|
+
note: body.note || '', workspace: w ? w.name : null, root: w ? w.root : null, pm: true };
|
|
1564
|
+
// Record into the workspace's OWN state, creating the kit layout for a PMOS-shaped repo.
|
|
1565
|
+
// If it can't be resolved, still record — but in the server's state/, and say so in the
|
|
1566
|
+
// response so the client can be honest about where the decision landed.
|
|
1567
|
+
let sink = acceptancesFileFor(w, { create: true });
|
|
1568
|
+
const fallback = !sink;
|
|
1569
|
+
if (fallback) sink = path.join(STATE, 'acceptances.jsonl');
|
|
1570
|
+
// FAIL LOUD (D64 Track A2): under D64 the file-mode acceptances log is the SYSTEM OF RECORD.
|
|
1571
|
+
// A swallowed append + {ok:true} would light the gate green over a record that never landed —
|
|
1572
|
+
// exactly the false-green class D64 exists to delete. On write failure return HTTP 500 and
|
|
1573
|
+
// {ok:false}, and do NOT report success. The error text carries no header/secret content (D08).
|
|
1574
|
+
try {
|
|
1575
|
+
fs.appendFileSync(sink, JSON.stringify(rec) + '\n');
|
|
1576
|
+
} catch (e) {
|
|
1577
|
+
warnWriteFail('acceptances.jsonl (Acceptance Gate — system of record)', e);
|
|
1578
|
+
return json(res, { ok: false, error: 'could not record the acceptance — the file-mode system of record is unwritable; the decision was NOT saved', sink, fallback }, 500);
|
|
1579
|
+
}
|
|
1580
|
+
// clear the matching needs entry from the file it actually came from (secondary — best effort,
|
|
1581
|
+
// but never silent: a failure here is surfaced as a visible server error).
|
|
1582
|
+
try {
|
|
1583
|
+
const nf = needsFileFor(w);
|
|
1584
|
+
if (nf && fs.existsSync(nf)) {
|
|
1585
|
+
const needs = JSON.parse(fs.readFileSync(nf, 'utf8'));
|
|
1586
|
+
fs.writeFileSync(nf, JSON.stringify(needs.filter((n) => n.run_id !== body.run_id)));
|
|
1587
|
+
}
|
|
1588
|
+
} catch (e) { warnWriteFail('needs.json (clearing the accepted need)', e); }
|
|
1589
|
+
return json(res, { ok: true, recorded: rec, sink, fallback });
|
|
1590
|
+
}
|
|
1591
|
+
res.writeHead(404); res.end('not found');
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// ── Access guard (D62): loopback is token-free; every non-loopback peer needs the bearer token. ──
|
|
1595
|
+
// Peer-based: what matters is where the request came FROM, not which listener accepted it. With the
|
|
1596
|
+
// remote flag off there is no non-loopback listener, so this is transparent for local dev. Headers
|
|
1597
|
+
// and the token are never logged (D08).
|
|
1598
|
+
function httpGuard(req, res) {
|
|
1599
|
+
// Origin/Host allowlist FIRST (D64 A1): reject a cross-site / DNS-rebinding request before the
|
|
1600
|
+
// token exemption for loopback can be reached. Header values are never logged (D08).
|
|
1601
|
+
if (!remoteAccess.checkHostOrigin({ origin: req.headers.origin, host: req.headers.host, binds: BIND.binds })) {
|
|
1602
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1603
|
+
res.end(JSON.stringify({ ok: false, error: 'forbidden origin/host' }));
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
if (!remoteAccess.authorize({ remoteAddress: req.socket.remoteAddress,
|
|
1607
|
+
presentedToken: remoteAccess.extractHttpToken(req.headers), expectedToken: TOKEN })) {
|
|
1608
|
+
res.writeHead(401, { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Bearer' });
|
|
1609
|
+
res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
return handleRequest(req, res);
|
|
1613
|
+
}
|
|
1614
|
+
const server = http.createServer(httpGuard);
|
|
1615
|
+
|
|
1616
|
+
// ── WebSocket: the attach protocol. ───────────────────────────────────────────────────
|
|
1617
|
+
// noServer mode so the SAME peer-based token guard runs on the upgrade handshake BEFORE the socket
|
|
1618
|
+
// is accepted, and so a second (tailnet) listener can share this exact WS handling.
|
|
1619
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
1620
|
+
function handleUpgrade(req, socket, head) {
|
|
1621
|
+
// Same origin/host allowlist on the WS upgrade handshake (D64 A1): a cross-origin page must not be
|
|
1622
|
+
// able to open ws://127.0.0.1:<port> and spawn/type into a shell. Rejected BEFORE the socket is
|
|
1623
|
+
// accepted, and before loopback's token exemption applies. Header values never logged (D08).
|
|
1624
|
+
if (!remoteAccess.checkHostOrigin({ origin: req.headers.origin, host: req.headers.host, binds: BIND.binds })) {
|
|
1625
|
+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
1626
|
+
socket.destroy();
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
if (!remoteAccess.authorize({ remoteAddress: socket.remoteAddress,
|
|
1630
|
+
presentedToken: remoteAccess.extractWsToken(req), expectedToken: TOKEN })) {
|
|
1631
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
|
1632
|
+
socket.destroy(); // reject the upgrade; nothing about the request is logged (D08)
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
|
|
1636
|
+
}
|
|
1637
|
+
server.on('upgrade', handleUpgrade);
|
|
1638
|
+
wss.on('connection', (ws) => {
|
|
1639
|
+
ws.attached = new Set();
|
|
1640
|
+
allClients.add(ws);
|
|
1641
|
+
// Per-connection rate limits (D64 A3) to bound abuse if this socket is ever reached by something
|
|
1642
|
+
// hostile. Sized to be invisible to a human at an interactive terminal: SPAWN allows a burst of 8
|
|
1643
|
+
// then ~1 every 2s; INPUT allows a big burst (pastes) then a high steady rate. Excess input is
|
|
1644
|
+
// dropped silently; excess spawns get an honest error rather than piling up shells.
|
|
1645
|
+
ws._rlSpawn = rateLimit.createBucket(8, 0.5);
|
|
1646
|
+
ws._rlInput = rateLimit.createBucket(400, 200);
|
|
1647
|
+
ws.send(JSON.stringify({ t: 'hello', workspaces: wsList(), home: wsId(SELF_ROOT), sessions: sessionList() }));
|
|
1648
|
+
|
|
1649
|
+
ws.on('message', (raw) => {
|
|
1650
|
+
let m; try { m = JSON.parse(raw); } catch { return; }
|
|
1651
|
+
// Gate the shell-spawning / PTY-feeding messages through the buckets before they do any work.
|
|
1652
|
+
if ((m.t === 'new' || m.t === 'respawn') && !rateLimit.take(ws._rlSpawn)) {
|
|
1653
|
+
ws.send(JSON.stringify({ t: 'error', error: 'rate limited: too many session spawns — slow down' }));
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
if (m.t === 'input' && !rateLimit.take(ws._rlInput)) return; // drop excess input quietly
|
|
1657
|
+
if (m.t === 'attach') {
|
|
1658
|
+
const s = sessions.get(m.id); if (!s) return;
|
|
1659
|
+
s.clients.add(ws); ws.attached.add(s.id);
|
|
1660
|
+
ws.send(JSON.stringify({ t: 'attached', id: s.id, buf: s.buf, state: s.state, cols: s.cols, rows: s.rows }));
|
|
1661
|
+
} else if (m.t === 'detach') {
|
|
1662
|
+
const s = sessions.get(m.id); if (s) s.clients.delete(ws); ws.attached.delete(m.id);
|
|
1663
|
+
} else if (m.t === 'input') {
|
|
1664
|
+
const s = sessions.get(m.id); if (s && s.pty) s.pty.write(m.data);
|
|
1665
|
+
} else if (m.t === 'resize') {
|
|
1666
|
+
const s = sessions.get(m.id); if (s && s.pty) { try { s.pty.resize(m.cols, m.rows); s.cols = m.cols; s.rows = m.rows; } catch {} }
|
|
1667
|
+
} else if (m.t === 'new') {
|
|
1668
|
+
const s = spawnSession({ name: m.name, agent: m.agent || 'shell', cwd: m.cwd, ws: m.ws });
|
|
1669
|
+
ws.send(JSON.stringify({ t: 'created', id: s.id }));
|
|
1670
|
+
} else if (m.t === 'respawn') {
|
|
1671
|
+
const old = sessions.get(m.id); if (!old || old.pty) return;
|
|
1672
|
+
const s = spawnSession({ name: old.name, agent: old.agent, cwd: path.relative(old.root, old.cwd), ws: old.ws });
|
|
1673
|
+
removeSession(old.id); // the corpse doesn't linger — respawn replaces, not accumulates
|
|
1674
|
+
ws.send(JSON.stringify({ t: 'created', id: s.id }));
|
|
1675
|
+
} else if (m.t === 'kill') {
|
|
1676
|
+
const s = sessions.get(m.id); if (!s) return;
|
|
1677
|
+
if (s.pty) {
|
|
1678
|
+
// A tmux client's .kill() only DETACHES — the process would live on and we'd reattach.
|
|
1679
|
+
// To actually end a live session, kill the tmux session itself (onExit then flips state).
|
|
1680
|
+
if (s.tmux) tmuxKillSession(s.tmux);
|
|
1681
|
+
try { s.pty.kill(); } catch {} // live → terminate (onExit flips state)
|
|
1682
|
+
} else removeSession(m.id); // exited → remove from registry + disk
|
|
1683
|
+
} else if (m.t === 'rename') {
|
|
1684
|
+
renameSession(m.id, m.name);
|
|
1685
|
+
} else if (m.t === 'remove') {
|
|
1686
|
+
removeSession(m.id);
|
|
1687
|
+
}
|
|
1688
|
+
});
|
|
1689
|
+
ws.on('close', () => {
|
|
1690
|
+
allClients.delete(ws);
|
|
1691
|
+
for (const id of ws.attached) { const s = sessions.get(id); if (s) s.clients.delete(ws); }
|
|
1692
|
+
});
|
|
1693
|
+
});
|
|
1694
|
+
|
|
1695
|
+
loadWorkspaces();
|
|
1696
|
+
registerWorkspace(SELF_ROOT); // the home workspace is always hosted
|
|
1697
|
+
writeTmuxConf(); // must exist before restore may reattach or spawn creates
|
|
1698
|
+
restorePersisted();
|
|
1699
|
+
|
|
1700
|
+
// A first shell so the workspace is never empty on first run (honest real PTY).
|
|
1701
|
+
if ([...sessions.values()].filter((s) => s.pty).length === 0) {
|
|
1702
|
+
spawnSession({ name: 'shell', agent: 'shell' });
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
if (require.main === module) {
|
|
1706
|
+
// Refuse-to-start is the D62 spine: if the requested remote bind isn't loopback or a tailnet
|
|
1707
|
+
// address, resolveBinds returned {refused} and we exit rather than expose a PTY server publicly.
|
|
1708
|
+
if (BIND.refused) { console.error('✗ ' + BIND.refused); process.exit(1); }
|
|
1709
|
+
|
|
1710
|
+
// First bind is always loopback (the exported `server`); any extra bind is the tailnet address,
|
|
1711
|
+
// served by an additional listener sharing the EXACT same guarded handler + WS upgrade guard.
|
|
1712
|
+
const extraListeners = BIND.binds.slice(1).map(() => {
|
|
1713
|
+
const s = http.createServer(httpGuard); s.on('upgrade', handleUpgrade); return s;
|
|
1714
|
+
});
|
|
1715
|
+
const listeners = [server, ...extraListeners];
|
|
1716
|
+
|
|
1717
|
+
// On `pmos stop` (SIGTERM) or Ctrl-C, flush scrollback and exit cleanly. The tmux sessions
|
|
1718
|
+
// are a SEPARATE daemon and keep running — that is the whole point: relaunch re-attaches live.
|
|
1719
|
+
const shutdown = () => {
|
|
1720
|
+
if (shuttingDown) return; shuttingDown = true;
|
|
1721
|
+
for (const s of sessions.values()) if (s.pty) { try { persist(s); } catch {} }
|
|
1722
|
+
for (const l of listeners) { try { l.close(); } catch {} }
|
|
1723
|
+
process.exit(0);
|
|
1724
|
+
};
|
|
1725
|
+
process.on('SIGTERM', shutdown);
|
|
1726
|
+
process.on('SIGINT', shutdown);
|
|
1727
|
+
const onError = (e) => {
|
|
1728
|
+
if (e.code === 'EADDRINUSE') {
|
|
1729
|
+
console.error(`✗ port ${PORT} is already in use (another workspace server running?).`);
|
|
1730
|
+
console.error(` Stop it, or start on another port: PMOS_WS_PORT=7500 npm start`);
|
|
1731
|
+
process.exit(1);
|
|
1732
|
+
}
|
|
1733
|
+
if (e.code === 'EADDRNOTAVAIL') {
|
|
1734
|
+
console.error(`✗ cannot bind ${e.address || 'the tailnet address'}:${PORT} — that address is not present on this machine. Is Tailscale up?`);
|
|
1735
|
+
process.exit(1);
|
|
1736
|
+
}
|
|
1737
|
+
throw e;
|
|
1738
|
+
};
|
|
1739
|
+
let up = 0;
|
|
1740
|
+
BIND.binds.forEach((addr, i) => {
|
|
1741
|
+
const l = listeners[i];
|
|
1742
|
+
l.on('error', onError);
|
|
1743
|
+
l.listen(PORT, addr, () => {
|
|
1744
|
+
// stdout only — no request/header logging (D08). Banner (incl. any generated token) printed once.
|
|
1745
|
+
if (++up === BIND.binds.length) for (const line of remoteAccess.startupLines(BIND, PORT)) console.log(line);
|
|
1746
|
+
});
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
module.exports = { server, spawnSession, removeSession, renameSession, sessions, libraryTree, readTextDoc,
|
|
1750
|
+
registerWorkspace, forgetWorkspace, workspaces, library, initiativeIndex, fsList,
|
|
1751
|
+
// exported so the suite can assert the D08 invariant end-to-end: the harness parsers consume
|
|
1752
|
+
// scrubbed text, so a secret printed into scrollback must not survive into a rendered value.
|
|
1753
|
+
scrub, harnessOf };
|