create-pmos 0.14.0 → 0.15.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 +19 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/workspace/focus.test.js +650 -0
- package/workspace/handoff.test.js +443 -0
- package/workspace/lib/console-state.js +48 -1
- package/workspace/lib/engine/workflow-state.js +13 -3
- package/workspace/lib/focus.js +967 -0
- package/workspace/lib/handoff.js +280 -0
- package/workspace/lib/layout.js +68 -46
- package/workspace/lib/mobile-nav.js +127 -0
- package/workspace/lib/profiles.js +285 -0
- package/workspace/lib/repo-scan.js +469 -0
- package/workspace/package.json +4 -2
- package/workspace/profiles.test.js +362 -0
- package/workspace/public/app.js +2318 -208
- package/workspace/public/index.html +158 -28
- package/workspace/public/styles.css +865 -51
- package/workspace/repo-scan.test.js +327 -0
- package/workspace/server.js +914 -23
package/workspace/server.js
CHANGED
|
@@ -25,7 +25,7 @@ const fs = require('fs');
|
|
|
25
25
|
const os = require('os');
|
|
26
26
|
const path = require('path');
|
|
27
27
|
const crypto = require('crypto');
|
|
28
|
-
const { execFile, spawnSync } = require('child_process');
|
|
28
|
+
const { execFile, spawnSync, spawn } = require('child_process');
|
|
29
29
|
const { WebSocketServer } = require('ws');
|
|
30
30
|
|
|
31
31
|
// node-pty is a NATIVE module; a broken/mismatched build is the #1 field failure
|
|
@@ -75,6 +75,14 @@ const tmuxControl = require('./lib/tmux-control'); // tmux -CC protocol: the
|
|
|
75
75
|
const relatedDocs = require('./lib/related-docs'); // which documents a change puts out of step (R5/R6)
|
|
76
76
|
const applyChange = require('./lib/apply-change'); // anchored + whole-document saves, refuse-on-stale
|
|
77
77
|
const sessionState = require('./lib/session-state'); // the cadence×turn join — validated on BOTH sides
|
|
78
|
+
// Bundle 2 (planning/design/bundle-2-wiring-contract.md). Four already-built pure modules. The
|
|
79
|
+
// server's whole job for these four features is to READ the filesystem and hand the result over —
|
|
80
|
+
// none of their logic is re-derived here, because a second implementation of "what does this
|
|
81
|
+
// record declare" is how two surfaces come to disagree about the same file.
|
|
82
|
+
const repoScan = require('./lib/repo-scan'); // scanRepo(root) — pure, cannot write (ingest R3)
|
|
83
|
+
const profilesLib = require('./lib/profiles'); // readProfiles(homeDir) / profileLine(profile, read)
|
|
84
|
+
const focus = require('./lib/focus'); // buildFocus(input) → FocusView (cross-product)
|
|
85
|
+
const handoff = require('./lib/handoff'); // readLinks(parsedFrontmatterObject) — never a path
|
|
78
86
|
// The "home" repo this server manages. In PMOS-self the app ships INSIDE its repo
|
|
79
87
|
// (pmos/workspace) so the parent dir IS the repo. But a kit/tenant copy is installed standalone
|
|
80
88
|
// — `npm i -g` puts it under node_modules/pmos-workspace, or it runs from an npx bundle — and
|
|
@@ -104,6 +112,13 @@ const SESS_DIR = path.join(STATE, 'sessions');
|
|
|
104
112
|
const SCRATCH_DIR = path.join(STATE, 'scratchpad'); // per-workspace staging text (D62; local-only, gitignored)
|
|
105
113
|
const LAYOUT_DIR = path.join(STATE, 'layouts'); // per-workspace arrangements (R7; local-only, gitignored)
|
|
106
114
|
const WS_FILE = path.join(STATE, 'workspaces.json');
|
|
115
|
+
// Ingest proposals live BESIDE workspaces.json, never inside the repo being scanned (ingest R3):
|
|
116
|
+
// a proposal that is never accepted must leave the clone byte-identical.
|
|
117
|
+
const INGEST_DIR = path.join(STATE, 'ingest');
|
|
118
|
+
const CLONE_DIR = path.join(STATE, 'clones');
|
|
119
|
+
// PMOS's OWN memory of which profile to preselect per agent binary (profiles R8.2). It is a
|
|
120
|
+
// workspace preference, not an agent attribute — the file lives here for exactly that reason.
|
|
121
|
+
const PREFS_FILE = path.join(STATE, 'prefs.json');
|
|
107
122
|
const PORT = process.env.PMOS_WS_PORT ? Number(process.env.PMOS_WS_PORT) : 7433;
|
|
108
123
|
const preview = require('./lib/preview-kind'); // the single reader classifier (md|text|html|image|inert)
|
|
109
124
|
const layoutModel = require('./lib/layout'); // the arrangement model — validated on BOTH sides
|
|
@@ -139,6 +154,67 @@ function sessionEnv() {
|
|
|
139
154
|
for (const k of Object.keys(env)) if (/^CLAUDE_CODE_/.test(k)) delete env[k];
|
|
140
155
|
return env;
|
|
141
156
|
}
|
|
157
|
+
|
|
158
|
+
// ── Agent profiles (workspace-profiles). ──────────────────────────────────────────────────────
|
|
159
|
+
// A profile is a DESCRIPTOR the PM already keeps at ~/.claude/profiles/*.json. PMOS reads it and
|
|
160
|
+
// applies its `env` overlay when it creates a process — that is the whole mechanism. It runs no
|
|
161
|
+
// MCP client, checks no reachability and holds no credential, so nothing here may say "connected".
|
|
162
|
+
//
|
|
163
|
+
// The read is not cached. A descriptor added while the workspace is open must appear on the next
|
|
164
|
+
// look, and a stale copy of a file whose whole purpose is provenance is worse than a re-read.
|
|
165
|
+
function readProfilesNow() { return profilesLib.readProfiles(os.homedir()); }
|
|
166
|
+
/** One descriptor by id, or null. Returns the READ too, because profileLine(null, read) needs it
|
|
167
|
+
* to tell "no profiles on this machine" from "the read failed and we cannot say". */
|
|
168
|
+
function profileById(id) {
|
|
169
|
+
const read = readProfilesNow();
|
|
170
|
+
if (id == null || id === '') return { read, profile: null };
|
|
171
|
+
const profile = (read.profiles || []).find((p) => p.id === id) || null;
|
|
172
|
+
return { read, profile };
|
|
173
|
+
}
|
|
174
|
+
// `env` VALUES NEVER CROSS THE WIRE (contract §1.3). An environment overlay is exactly where a
|
|
175
|
+
// credential lives, and D08's never-log-headers discipline covers anything the browser receives.
|
|
176
|
+
// The names are shipped so the pane can say WHAT was applied; the values are not.
|
|
177
|
+
function profileWire(p, read) {
|
|
178
|
+
const env = p && p.env && typeof p.env === 'object' ? p.env : {};
|
|
179
|
+
const keys = Object.keys(env);
|
|
180
|
+
return {
|
|
181
|
+
id: p.id, label: p.label == null ? null : p.label, account: p.account == null ? null : p.account,
|
|
182
|
+
mcp: Array.isArray(p.mcp) ? p.mcp.slice() : [],
|
|
183
|
+
env_count: keys.length, env_keys: keys,
|
|
184
|
+
// Computed HERE, once, so no surface can re-word R6's five sentences.
|
|
185
|
+
line: profilesLib.profileLine(p, read),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
// PMOS's own memory: agent binary name → preferred profile id. It preselects the composer and
|
|
189
|
+
// starts nothing (R8.2). Read fresh; a corrupt file is treated as no memory rather than crashing
|
|
190
|
+
// a spawn — the preference is a convenience, and losing it costs one click.
|
|
191
|
+
function readPrefs() {
|
|
192
|
+
try { const o = JSON.parse(fs.readFileSync(PREFS_FILE, 'utf8')); return o && typeof o === 'object' && !Array.isArray(o) ? o : {}; }
|
|
193
|
+
catch { return {}; }
|
|
194
|
+
}
|
|
195
|
+
function writePrefs(prefs) {
|
|
196
|
+
try { fs.writeFileSync(PREFS_FILE, JSON.stringify(prefs)); return null; }
|
|
197
|
+
catch (e) { warnWriteFail('prefs.json (profile preselection)', e); return `the preference could not be saved — ${String((e && e.code) || 'write failed')}`; }
|
|
198
|
+
}
|
|
199
|
+
// The merged environment a session actually runs under. The overlay goes ON TOP of sessionEnv(),
|
|
200
|
+
// AFTER the marker-stripping (R3) — never instead of it, or a profile would silently re-enable the
|
|
201
|
+
// nested-session markers sessionEnv exists to remove.
|
|
202
|
+
function sessionEnvWithProfile(profile) {
|
|
203
|
+
const env = sessionEnv();
|
|
204
|
+
for (const [k, v] of Object.entries(profileOverlay(profile))) env[k] = v;
|
|
205
|
+
return env;
|
|
206
|
+
}
|
|
207
|
+
// Just the DELTA a profile introduces, stringified — never merged with the base. The tmux path
|
|
208
|
+
// needs these separately from sessionEnv() because a pane's environment does not come from the
|
|
209
|
+
// process that ran `new-session`: see createTmuxSession.
|
|
210
|
+
function profileOverlay(profile) {
|
|
211
|
+
const out = {};
|
|
212
|
+
if (profile && profile.env && typeof profile.env === 'object') {
|
|
213
|
+
for (const k of Object.keys(profile.env)) out[k] = String(profile.env[k]);
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
142
218
|
const SCROLLBACK = 200_000; // bytes of ring buffer per session
|
|
143
219
|
// How often the server re-broadcasts the session list while anything is alive (see heartbeat()).
|
|
144
220
|
// 2s is chosen against what it drives: the state dot and the quiet clock. QUIET_AFTER_MS is 30s, so
|
|
@@ -717,6 +793,97 @@ function surfaceOkr(root) {
|
|
|
717
793
|
note: 'KR current/target and the lever tree are net-new state — shown as "— not tracked"',
|
|
718
794
|
};
|
|
719
795
|
}
|
|
796
|
+
// ── The PM spine (workspace-pm-spine): Objective → Key Result → Initiative. ────────────────────
|
|
797
|
+
// surfaceOkr() above reads the SAME objectives and KRs and deliberately stops short — its comment
|
|
798
|
+
// calls the initiative↔KR join "an explicit non-goal". That left the workspace able to say an
|
|
799
|
+
// anchor is VALID while unable to say what a KR is MADE OF, and left the UI rooted on the delivery
|
|
800
|
+
// substrate (repo → tree → session) with the initiative reduced to a string parsed out of a branch
|
|
801
|
+
// name. This function is that join, and nothing more: it re-uses anchor-resolve.js rather than
|
|
802
|
+
// re-parsing anything, so there is exactly one definition of what an anchor means (R1).
|
|
803
|
+
//
|
|
804
|
+
// It deliberately returns NO runtime state. Which working trees and sessions are on an initiative
|
|
805
|
+
// is a client-side attachment (the client already holds both, live over the socket) — keeping the
|
|
806
|
+
// server's answer purely the PM structure means a spine that is correct whether or not anything is
|
|
807
|
+
// currently checked out, which is the case R2 exists to make possible.
|
|
808
|
+
function surfaceSpine(root) {
|
|
809
|
+
const A = anchorLib();
|
|
810
|
+
if (!A) {
|
|
811
|
+
return { objectives: [], budgets: [], unanchored: [], unreadable: 'the anchor resolver (scripts/anchor-resolve.js) is not present in this install — the spine cannot be derived here' };
|
|
812
|
+
}
|
|
813
|
+
const index = A.parseOkrIndex(root);
|
|
814
|
+
// R5 — a read that FAILED is not a read that found nothing. Unreadable renders RED; the calm
|
|
815
|
+
// empty state is reserved for a successful read of genuinely nothing.
|
|
816
|
+
if (!index.ok) {
|
|
817
|
+
return { objectives: [], budgets: [], unanchored: [], unreadable: index.error || 'the OKR documents could not be read' };
|
|
818
|
+
}
|
|
819
|
+
const budgetsDoc = A.readBudgets(root);
|
|
820
|
+
|
|
821
|
+
// the empty spine, keyed for O(1) attachment below
|
|
822
|
+
const objectives = index.objectives.map((o) => ({
|
|
823
|
+
id: o.token, key: o.id, name: (o.name || '').slice(0, 140), file: o.file,
|
|
824
|
+
krs: o.krs.map((k) => ({ id: k.token, key: k.id, name: (k.name || '').slice(0, 160), initiatives: [] })),
|
|
825
|
+
}));
|
|
826
|
+
const krByKey = new Map();
|
|
827
|
+
for (const o of objectives) for (const k of o.krs) krByKey.set(`${o.key}::${k.key}`, k);
|
|
828
|
+
const objByKey = new Map(objectives.map((o) => [o.key, o]));
|
|
829
|
+
|
|
830
|
+
const budgets = new Map(); // health:<id> groups — peers of objectives (D59), not a footnote
|
|
831
|
+
const unanchored = []; // R6 — never dropped to make the tree tidy
|
|
832
|
+
|
|
833
|
+
for (const dir of findDirsNamed(root, 'initiatives', BUNDLE_MAX_DEPTH)) {
|
|
834
|
+
let files = [];
|
|
835
|
+
try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort(); } catch { continue; }
|
|
836
|
+
for (const f of files) {
|
|
837
|
+
if (f === 'README.md' || f === 'index.md') continue;
|
|
838
|
+
const fm = leadingFrontmatter(path.join(dir, f)) || {};
|
|
839
|
+
const id = String(fm.id || f.replace(/\.md$/, ''));
|
|
840
|
+
const rec = {
|
|
841
|
+
id,
|
|
842
|
+
title: String(fm.title || '').slice(0, 160),
|
|
843
|
+
lane: String(fm.lane || 'feature'),
|
|
844
|
+
file: path.relative(root, path.join(dir, f)),
|
|
845
|
+
// R7 — position is DERIVED by the workflow engine. No `stage:`/`status:` is read for it;
|
|
846
|
+
// `status` is carried only because the chore lane's intent lives there as prose.
|
|
847
|
+
has_spec: !!String(fm.prd_path || '').trim(),
|
|
848
|
+
has_rubric: !!String(fm.rubric_path || '').trim(),
|
|
849
|
+
};
|
|
850
|
+
const raw = fm.parent_kr == null ? '' : String(fm.parent_kr);
|
|
851
|
+
const r = A.resolveAnchor(raw, index, budgetsDoc);
|
|
852
|
+
|
|
853
|
+
if (r.kind === 'health' && r.status === 'resolved') {
|
|
854
|
+
const key = `health:${r.budget}`;
|
|
855
|
+
if (!budgets.has(key)) budgets.set(key, { id: key, name: r.budget, initiatives: [] });
|
|
856
|
+
budgets.get(key).initiatives.push(rec);
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
if (r.kind === 'okr' && r.status === 'resolved') {
|
|
860
|
+
const kr = r.kr ? krByKey.get(`${A.objectiveId(r.objective)}::${A.krId(r.kr)}`) : null;
|
|
861
|
+
if (kr) { kr.initiatives.push(rec); continue; }
|
|
862
|
+
// resolved to an OBJECTIVE with no KR named — real and legitimate, but it has no KR bucket
|
|
863
|
+
const obj = objByKey.get(A.objectiveId(r.objective));
|
|
864
|
+
if (obj) {
|
|
865
|
+
if (!obj.direct) obj.direct = [];
|
|
866
|
+
obj.direct.push(rec);
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
// R6 — anything that did not resolve is LISTED, with the resolver's own reason. This is the
|
|
871
|
+
// set surfaceOkr() only ever counted.
|
|
872
|
+
unanchored.push(Object.assign({}, rec, {
|
|
873
|
+
raw: raw.trim().slice(0, 100),
|
|
874
|
+
status: r.status,
|
|
875
|
+
reason: r.reason || 'this anchor does not resolve against the OKR documents or the health-budget registry',
|
|
876
|
+
}));
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
return {
|
|
880
|
+
objectives,
|
|
881
|
+
budgets: [...budgets.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
|
882
|
+
unanchored,
|
|
883
|
+
unreadable: null,
|
|
884
|
+
budgets_readable: !!(budgetsDoc && budgetsDoc.ok),
|
|
885
|
+
};
|
|
886
|
+
}
|
|
720
887
|
function surfaceReport(root, w) {
|
|
721
888
|
const s = surfaceInitiatives(root, w);
|
|
722
889
|
const line = (i) => `${i.id}${i.title ? ' — ' + i.title : ''}`;
|
|
@@ -798,12 +965,19 @@ function fireSchedule(sched) {
|
|
|
798
965
|
// AGENT task — fire as a real PTY session running its skill (async; the skill files its own
|
|
799
966
|
// deliverable). The workspace starts it + stamps 'started'; run-now and the tick share this path.
|
|
800
967
|
if (task && task.kind === 'agent') {
|
|
968
|
+
// The schedule's recorded profile travels with the process it creates (profiles R8.3). Only an
|
|
969
|
+
// AGENT-kind task has a PTY, which is why `profile_applies` is false for the skill-kind rows
|
|
970
|
+
// below rather than the control simply being hidden.
|
|
801
971
|
const s = spawnSession({ name: sched.name, agent: task.agent || 'claude', ws: sched.ws,
|
|
802
|
-
origin: 'schedule:' + sched.id, initialInput: task.prompt ? task.prompt + '\r' : null
|
|
972
|
+
origin: 'schedule:' + sched.id, initialInput: task.prompt ? task.prompt + '\r' : null,
|
|
973
|
+
profile: sched.profile == null ? null : sched.profile });
|
|
803
974
|
const list = readSchedules(); const cur = list.find((x) => x.id === sched.id);
|
|
804
975
|
const started = s.state !== 'exited';
|
|
805
976
|
if (cur) { cur.lastRun = nowISO(); cur.lastStatus = started ? 'started' : 'fail'; cur.nextRun = scheduleLogic.nextRun(cur); writeSchedules(list); }
|
|
806
|
-
|
|
977
|
+
// A run that started WITHOUT the profile it recorded is reported as such rather than as a
|
|
978
|
+
// clean start: the session is real, the environment is not the one the schedule declared.
|
|
979
|
+
return { ok: started, kind: 'agent', sessionId: s.id,
|
|
980
|
+
reason: started ? (s.profileRefusal || '') : (s.note || 'could not start the session') };
|
|
807
981
|
}
|
|
808
982
|
const runId = `sched-${sched.id}-${Date.now()}`;
|
|
809
983
|
let ok = false, deliverable = null, reason = '';
|
|
@@ -855,6 +1029,11 @@ function persist(s) {
|
|
|
855
1029
|
// so a session survives even if the workspace registry is lost (it re-registers its root).
|
|
856
1030
|
const meta = { id: s.id, ws: s.ws, root: s.root, name: s.name, agent: s.agent, cwd: s.cwd,
|
|
857
1031
|
state: s.state, note: s.note || '', created: s.created, tmux: s.tmux || null, origin: s.origin || null };
|
|
1032
|
+
// The profile ID ONLY — never the descriptor and never its env, which is where a credential
|
|
1033
|
+
// would live (D08). The key is written only for a session PMOS created, so its ABSENCE stays a
|
|
1034
|
+
// fact: a record persisted before profiles existed restores as "not recorded", and inventing a
|
|
1035
|
+
// key for the past would be a lie about it.
|
|
1036
|
+
if ('profile' in s) meta.profile = s.profile == null ? null : s.profile;
|
|
858
1037
|
try {
|
|
859
1038
|
fs.writeFileSync(path.join(SESS_DIR, s.id + '.json'), JSON.stringify(meta));
|
|
860
1039
|
fs.writeFileSync(path.join(SESS_DIR, s.id + '.scrollback'), s.buf);
|
|
@@ -1078,6 +1257,12 @@ function harnessOf(s) {
|
|
|
1078
1257
|
}
|
|
1079
1258
|
function sessionList() {
|
|
1080
1259
|
const now = Date.now();
|
|
1260
|
+
// ONE profiles read per broadcast, not one per session: the line is a formatter over the
|
|
1261
|
+
// descriptor, and re-reading the directory per row would turn a 2s heartbeat into a directory
|
|
1262
|
+
// scan per pane. A read that FAILED is passed through as-is — profileLine() needs to see the
|
|
1263
|
+
// failure to avoid claiming "no profiles are configured on this machine" when PMOS never looked.
|
|
1264
|
+
const pread = readProfilesNow();
|
|
1265
|
+
const byId = new Map((pread.profiles || []).map((p) => [p.id, p]));
|
|
1081
1266
|
return [...sessions.values()].map((s) => {
|
|
1082
1267
|
const d = harness.deriveState(s.state, s.lastOutputAt, now);
|
|
1083
1268
|
const h = harnessOf(s);
|
|
@@ -1098,6 +1283,24 @@ function sessionList() {
|
|
|
1098
1283
|
// double the transcript work the TX_REREAD_MS floor exists to bound.
|
|
1099
1284
|
hx,
|
|
1100
1285
|
missing: h.missing,
|
|
1286
|
+
// ── profiles (workspace-profiles R6). Two fields, and the second is a SENTENCE. ──
|
|
1287
|
+
// `profile_line` is always a string, computed here rather than on the client, because the
|
|
1288
|
+
// three no-descriptor cases are distinguished by the ARGUMENT'S OWN TYPE and `undefined`
|
|
1289
|
+
// cannot survive JSON.stringify: a session PMOS never started (no `profile` key at all →
|
|
1290
|
+
// "not recorded") and one PMOS started with nothing picked (`null` → "none was chosen") would
|
|
1291
|
+
// arrive on the wire identical. Computing the sentence server-side is what preserves them.
|
|
1292
|
+
// A recorded id whose descriptor has since been deleted resolves to no descriptor, and the
|
|
1293
|
+
// line says so rather than substituting another profile (R9).
|
|
1294
|
+
// A recorded id whose descriptor has since been deleted is handed to profileLine() AS THE
|
|
1295
|
+
// BARE ID — the module rejects a bare string on purpose ("the recorded value is not a
|
|
1296
|
+
// profile descriptor"), which is the honest answer. Resolving it to `null` instead would
|
|
1297
|
+
// print "none was chosen" over a session that did choose one (R9).
|
|
1298
|
+
profile_id: 'profile' in s ? (s.profile == null ? null : s.profile) : null,
|
|
1299
|
+
profile_line: profilesLib.profileLine(
|
|
1300
|
+
!('profile' in s) ? undefined
|
|
1301
|
+
: s.profile == null ? null
|
|
1302
|
+
: (byId.get(s.profile) || s.profile),
|
|
1303
|
+
pread),
|
|
1101
1304
|
};
|
|
1102
1305
|
});
|
|
1103
1306
|
}
|
|
@@ -1241,7 +1444,10 @@ function writeTmuxConf() {
|
|
|
1241
1444
|
}
|
|
1242
1445
|
// A tmux CONTROL command (not the attach PTY). Uses the CLEANED session env so that the cold
|
|
1243
1446
|
// start this may trigger seeds the tmux server's own environment marker-free (D62 / sessionEnv).
|
|
1244
|
-
|
|
1447
|
+
// `env` is an OPT-IN override and only `new-session` ever passes one (profiles R3). has-session /
|
|
1448
|
+
// kill-session / list-sessions / display-message have no session context, so handing them a
|
|
1449
|
+
// profile's overlay would apply it to whichever pane the command happened to touch — a silent bug.
|
|
1450
|
+
function tmuxCtl(args, env) { return spawnSync(TMUX, ['-L', TMUX_SOCK, ...args], { env: env || sessionEnv(), encoding: 'utf8' }); }
|
|
1245
1451
|
function tmuxHasSession(name) { return !!TMUX && tmuxCtl(['has-session', '-t', name]).status === 0; }
|
|
1246
1452
|
function tmuxKillSession(name) { if (TMUX && name) tmuxCtl(['kill-session', '-t', name]); }
|
|
1247
1453
|
// Create the tmux session a FRESH spawn runs in. It REFUSES a name that already exists rather
|
|
@@ -1249,10 +1455,25 @@ function tmuxKillSession(name) { if (TMUX && name) tmuxCtl(['kill-session', '-t'
|
|
|
1249
1455
|
// name is someone else's process, in someone else's directory. Silently reusing it is how a new
|
|
1250
1456
|
// session came up in the wrong repo. freshTmuxName makes the collision practically impossible;
|
|
1251
1457
|
// this is the floor that keeps it impossible even if it were not.
|
|
1252
|
-
|
|
1458
|
+
//
|
|
1459
|
+
// THE PROFILE OVERLAY TRAVELS AS `-e`, NEVER AS THIS PROCESS'S ENV. A pane does not inherit the
|
|
1460
|
+
// environment of the process that ran `new-session` — it inherits the tmux SERVER's, which was
|
|
1461
|
+
// fixed at whatever cold-started the daemon. Handing the overlay to the client therefore did two
|
|
1462
|
+
// wrong things at once, both measured on tmux 3.4 before this line changed:
|
|
1463
|
+
// · the FIRST profiled session cold-started the server and seeded ITS overlay into the daemon,
|
|
1464
|
+
// so every later pane on the socket inherited it — a `personal` session and a session with no
|
|
1465
|
+
// profile at all both came up carrying the `work` overlay;
|
|
1466
|
+
// · while the pane's own profile line went on naming the profile that session had asked for.
|
|
1467
|
+
// A workspace that runs a personal session under work credentials and then TELLS the PM it did
|
|
1468
|
+
// not is the honest-state contract inverted, so this is a correctness fix and not a tidy-up.
|
|
1469
|
+
// `new-session -e K=V` (tmux ≥3.2) sets the environment of THAT session only; a session that
|
|
1470
|
+
// declares no profile is left genuinely unset rather than inheriting a neighbour's.
|
|
1471
|
+
function createTmuxSession(name, cmd, workdir, cols, rows, overlay) {
|
|
1253
1472
|
if (tmuxHasSession(name)) return false;
|
|
1473
|
+
const eArgs = [];
|
|
1474
|
+
for (const [k, v] of Object.entries(overlay || {})) eArgs.push('-e', `${k}=${v}`);
|
|
1254
1475
|
const r = tmuxCtl(['-f', TMUX_CONF, 'new-session', '-d', '-s', name,
|
|
1255
|
-
'-x', String(cols || 80), '-y', String(rows || 24), '-c', workdir, '--', cmd]);
|
|
1476
|
+
...eArgs, '-x', String(cols || 80), '-y', String(rows || 24), '-c', workdir, '--', cmd]);
|
|
1256
1477
|
return r.status === 0;
|
|
1257
1478
|
}
|
|
1258
1479
|
// Every session alive on our private socket, with the directory its pane is actually sitting in.
|
|
@@ -1396,8 +1617,8 @@ function attachPty(s, { seed = false } = {}) {
|
|
|
1396
1617
|
}
|
|
1397
1618
|
|
|
1398
1619
|
// Try a command, falling back to `sh` on posix if the preferred shell can't spawn.
|
|
1399
|
-
function spawnPty(cmd, workdir) {
|
|
1400
|
-
const opts = { name: 'xterm-256color', cols: 80, rows: 24, cwd: workdir, env: sessionEnv() };
|
|
1620
|
+
function spawnPty(cmd, workdir, env) {
|
|
1621
|
+
const opts = { name: 'xterm-256color', cols: 80, rows: 24, cwd: workdir, env: env || sessionEnv() };
|
|
1401
1622
|
const chain = process.platform === 'win32' ? [cmd] : [...new Set([cmd, 'sh', '/bin/sh'])];
|
|
1402
1623
|
let lastErr;
|
|
1403
1624
|
for (const c of chain) {
|
|
@@ -1432,7 +1653,24 @@ function nextSessionId() {
|
|
|
1432
1653
|
return id;
|
|
1433
1654
|
}
|
|
1434
1655
|
|
|
1435
|
-
|
|
1656
|
+
// Resolve a requested profile id BEFORE anything is spawned (profiles R3/R9). The rule the whole
|
|
1657
|
+
// feature turns on: a session must never claim a profile whose overlay was not applied. So the
|
|
1658
|
+
// three outcomes are kept apart — nothing asked for, asked for and readable, asked for and gone —
|
|
1659
|
+
// and callers that can refuse (the composer) refuse rather than start something mislabelled.
|
|
1660
|
+
function resolveProfileForSpawn(id) {
|
|
1661
|
+
if (id == null || id === '') return { ok: true, id: null, profile: null };
|
|
1662
|
+
const { read, profile } = profileById(String(id));
|
|
1663
|
+
if (profile) return { ok: true, id: profile.id, profile };
|
|
1664
|
+
if (!read.ok) return { ok: false, id: null, profile: null, error: `profile "${id}" could not be checked — ${read.error}` };
|
|
1665
|
+
const bad = (read.broken || []).find((b) => b.file === `${id}.json`);
|
|
1666
|
+
if (bad) return { ok: false, id: null, profile: null, error: `profile "${id}" is not usable — ${bad.file} ${bad.error}` };
|
|
1667
|
+
return { ok: false, id: null, profile: null,
|
|
1668
|
+
error: read.absent
|
|
1669
|
+
? `profile "${id}" was asked for, but ${read.dir} does not exist — no descriptor was read and nothing would be applied`
|
|
1670
|
+
: `profile "${id}" is not among the descriptors in ${read.dir} — PMOS will not substitute another` };
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
|
|
1436
1674
|
const id = nextSessionId();
|
|
1437
1675
|
const w = getWs(ws);
|
|
1438
1676
|
const root = w ? w.root : SELF_ROOT;
|
|
@@ -1445,7 +1683,14 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
|
|
|
1445
1683
|
const cmd = isShell ? (commandExists(SHELL) ? SHELL : 'sh') : agent;
|
|
1446
1684
|
// a session's working directory is confined to its OWN workspace root (R3)
|
|
1447
1685
|
const workdir = cwd && insideRoot(root, path.resolve(root, cwd)) ? path.resolve(root, cwd) : root;
|
|
1448
|
-
|
|
1686
|
+
// Resolved once, here, so every path below spawns under the SAME environment the record claims.
|
|
1687
|
+
// `pres.id` is null whenever the overlay was not applied — including when a profile was asked for
|
|
1688
|
+
// and could not be read — so the pane line can never name a profile that did nothing (R3/R9).
|
|
1689
|
+
// `profileRefusal` is server-internal: callers that can refuse read it and say why.
|
|
1690
|
+
const pres = resolveProfileForSpawn(profile);
|
|
1691
|
+
const penv = sessionEnvWithProfile(pres.profile);
|
|
1692
|
+
const base = { id, ws: w ? w.id : wsId(SELF_ROOT), root, name: (name && String(name).trim()) || agent, agent, origin: tag,
|
|
1693
|
+
profile: pres.id, profileRefusal: pres.ok ? null : pres.error };
|
|
1449
1694
|
|
|
1450
1695
|
// A missing agent CLI is detected BEFORE any spawn (on Linux the exec-in-fork failure is
|
|
1451
1696
|
// otherwise silent) — the same honest exited pane on both the tmux and the direct path.
|
|
@@ -1460,7 +1705,9 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
|
|
|
1460
1705
|
const s = { ...base, cwd: workdir, state: 'working', pty: null, tmux: tname,
|
|
1461
1706
|
note: '', buf: '', cols: 80, rows: 24, clients: new Set(), created: nowISO() };
|
|
1462
1707
|
sessions.set(id, s);
|
|
1463
|
-
|
|
1708
|
+
// the OVERLAY, not the merged env — the merged env is for the direct-PTY path below, which
|
|
1709
|
+
// really does spawn the process itself. See createTmuxSession for why the two differ.
|
|
1710
|
+
if (!createTmuxSession(tname, cmd, workdir, 80, 24, profileOverlay(pres.profile))) {
|
|
1464
1711
|
const why = `could not start a tmux session for '${cmd}'. Run \`npm run doctor\` in workspace/.`;
|
|
1465
1712
|
s.state = 'exited'; s.tmux = null; s.note = why;
|
|
1466
1713
|
s.buf = `\x1b[38;5;203m✗ ${why}\x1b[0m\r\n\x1b[38;5;244mFix the above, then ↻ respawn.\x1b[0m\r\n`;
|
|
@@ -1475,8 +1722,8 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
|
|
|
1475
1722
|
const startedMs = Date.now();
|
|
1476
1723
|
let p, usedCmd = cmd;
|
|
1477
1724
|
try {
|
|
1478
|
-
if (isShell) { const r = spawnPty(cmd, workdir); p = r.pty; usedCmd = r.cmd; }
|
|
1479
|
-
else p = pty.spawn(cmd, [], { name: 'xterm-256color', cols: 80, rows: 24, cwd: workdir, env:
|
|
1725
|
+
if (isShell) { const r = spawnPty(cmd, workdir, penv); p = r.pty; usedCmd = r.cmd; }
|
|
1726
|
+
else p = pty.spawn(cmd, [], { name: 'xterm-256color', cols: 80, rows: 24, cwd: workdir, env: penv });
|
|
1480
1727
|
} catch (e) {
|
|
1481
1728
|
return exitedSession(base, workdir, spawnFailureAdvice(e, cmd, isShell));
|
|
1482
1729
|
}
|
|
@@ -1518,9 +1765,14 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput }) {
|
|
|
1518
1765
|
// A FAILED git operation in a real repo must never spawn a session on the wrong branch (rubric
|
|
1519
1766
|
// hard floor): we return {ok:false} and spawn nothing. Kept as its own function so the
|
|
1520
1767
|
// git-then-maybe-spawn contract is unit-testable without a WS client.
|
|
1521
|
-
function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate }) {
|
|
1768
|
+
function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile }) {
|
|
1522
1769
|
const w = getWs(ws);
|
|
1523
1770
|
if (!w) return { ok: false, error: 'no workspace' };
|
|
1771
|
+
// The profile is checked BEFORE any git operation. A composer that picked a profile PMOS cannot
|
|
1772
|
+
// read must not leave a branch checked out and a session running under the wrong environment —
|
|
1773
|
+
// and a session that claims a profile it never got is the quietest failure this feature has.
|
|
1774
|
+
const pres = resolveProfileForSpawn(profile);
|
|
1775
|
+
if (!pres.ok) return { ok: false, error: pres.error };
|
|
1524
1776
|
let targetWs = w.id;
|
|
1525
1777
|
let notice = null;
|
|
1526
1778
|
// Isolation exists to protect OTHER sessions. Alone in a workspace there are none to protect, so
|
|
@@ -1555,7 +1807,7 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate }
|
|
|
1555
1807
|
broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) }); // let every client refresh the chip
|
|
1556
1808
|
}
|
|
1557
1809
|
}
|
|
1558
|
-
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs });
|
|
1810
|
+
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id });
|
|
1559
1811
|
return { ok: true, session: s, notice };
|
|
1560
1812
|
}
|
|
1561
1813
|
|
|
@@ -1891,7 +2143,299 @@ function needsByWorkspace() {
|
|
|
1891
2143
|
}
|
|
1892
2144
|
// Anything that resolves or files a need makes the memo stale; drop it rather than wait out the TTL.
|
|
1893
2145
|
function invalidateNeedsAgg(id) { if (id) needsAgg.delete(id); else needsAgg.clear(); }
|
|
1894
|
-
|
|
2146
|
+
// ── repo ingest (workspace-repo-ingest): clone → scan → review → accept. ──────────────────────
|
|
2147
|
+
// The whole design is the boundary between stages 2 and 3. `lib/repo-scan.js` is PURE — it opens
|
|
2148
|
+
// files and returns a PROPOSAL; it cannot write, shell, or decide. Every write in this pipeline
|
|
2149
|
+
// lives below, and there is exactly one of them that touches a record: the confirm handler.
|
|
2150
|
+
//
|
|
2151
|
+
// Proposals persist BESIDE state/workspaces.json, never inside the repository being scanned (R3):
|
|
2152
|
+
// a proposal that is never accepted must leave the clone byte-identical.
|
|
2153
|
+
const INGEST_CLONE_TIMEOUT_MS = Number(process.env.PMOS_INGEST_TIMEOUT_MS || 120_000);
|
|
2154
|
+
const INGEST_CREDENTIAL_NOTE = 'PMOS holds no GitHub credential and will not ask for one — the clone used the git credential helper this machine already has.';
|
|
2155
|
+
const GITHUB_REF = /^(?:https?:\/\/(?:www\.)?github\.com\/)?([A-Za-z0-9][\w.-]*)\/([A-Za-z0-9][\w.-]*?)(?:\.git)?\/?$/;
|
|
2156
|
+
|
|
2157
|
+
function ingestPath(id) { return path.join(INGEST_DIR, id + '.json'); }
|
|
2158
|
+
function readProposal(id) {
|
|
2159
|
+
if (!/^ing-\d+$/.test(String(id || ''))) return null;
|
|
2160
|
+
try { return JSON.parse(fs.readFileSync(ingestPath(id), 'utf8')); } catch { return null; }
|
|
2161
|
+
}
|
|
2162
|
+
function writeProposal(pr) {
|
|
2163
|
+
try { fs.mkdirSync(INGEST_DIR, { recursive: true }); fs.writeFileSync(ingestPath(pr.id), JSON.stringify(pr)); return null; }
|
|
2164
|
+
catch (e) { warnWriteFail('state/ingest (the ingest proposal store)', e); return `the proposal could not be saved — ${String((e && e.code) || 'write failed')}`; }
|
|
2165
|
+
}
|
|
2166
|
+
function listProposals() {
|
|
2167
|
+
let names = [];
|
|
2168
|
+
try { names = fs.readdirSync(INGEST_DIR).filter((f) => /^ing-\d+\.json$/.test(f)); } catch { return []; }
|
|
2169
|
+
return names.map((f) => readProposal(f.replace(/\.json$/, ''))).filter(Boolean)
|
|
2170
|
+
.sort((a, b) => Number(a.id.slice(4)) - Number(b.id.slice(4)));
|
|
2171
|
+
}
|
|
2172
|
+
function nextIngestId() {
|
|
2173
|
+
let max = 0;
|
|
2174
|
+
try { for (const f of fs.readdirSync(INGEST_DIR)) { const m = /^ing-(\d+)\.json$/.exec(f); if (m && Number(m[1]) > max) max = Number(m[1]); } } catch {}
|
|
2175
|
+
return 'ing-' + (max + 1);
|
|
2176
|
+
}
|
|
2177
|
+
// The three DERIVED fields the UI prints, computed from the module's own field names. The PRD
|
|
2178
|
+
// sketches `anchor:{source,value,evidence}`; the module returns declaredAnchor/guessedAnchor/why,
|
|
2179
|
+
// and the wire uses the module's names PLUS these — translating between the two shapes in four
|
|
2180
|
+
// places independently is exactly the drift this pipeline cannot afford.
|
|
2181
|
+
function ingestItemWire(row) {
|
|
2182
|
+
return {
|
|
2183
|
+
...row,
|
|
2184
|
+
anchor_state: row.declaredAnchor ? 'declared' : row.guessedAnchor ? 'guessed' : 'none',
|
|
2185
|
+
anchor_value: row.declaredAnchor || row.guessedAnchor || null,
|
|
2186
|
+
evidence: row.why == null ? null : row.why,
|
|
2187
|
+
disposition: row.disposition || 'open',
|
|
2188
|
+
};
|
|
2189
|
+
}
|
|
2190
|
+
function pushIngest(pr) { broadcast({ t: 'ingest', id: pr.id, stage: pr.stage, proposal: pr }); }
|
|
2191
|
+
|
|
2192
|
+
// The clone. Asynchronous and OFF the request path (R1) — the POST returns an id immediately and
|
|
2193
|
+
// the result arrives over the socket. GIT_TERMINAL_PROMPT=0 is what turns a private repository
|
|
2194
|
+
// from a hang into a stated failure: git's own credential helper still runs, but nothing waits on
|
|
2195
|
+
// a username nobody is there to type.
|
|
2196
|
+
function startIngestClone(pr) {
|
|
2197
|
+
const dir = path.join(CLONE_DIR, pr.id);
|
|
2198
|
+
// 'error' and 'close' BOTH fire when the binary is missing, and `close` arrives with code null —
|
|
2199
|
+
// which would overwrite "git is not on PATH" with "git exited with code null" and collapse two
|
|
2200
|
+
// of the three failures R9 requires kept apart. First outcome wins; the rest are dropped.
|
|
2201
|
+
// The guard is on the child's LIFECYCLE, not on fail() itself — the scan below runs after the
|
|
2202
|
+
// clone has settled and must still be able to report its own failure.
|
|
2203
|
+
let settled = false;
|
|
2204
|
+
const fail = (error, extra) => {
|
|
2205
|
+
const cur = readProposal(pr.id) || pr;
|
|
2206
|
+
Object.assign(cur, { stage: 'review', ok: false, error, scanned_at: nowISO() }, extra || {});
|
|
2207
|
+
cur.log.push({ level: 'error', path: null, message: error });
|
|
2208
|
+
writeProposal(cur); pushIngest(cur);
|
|
2209
|
+
};
|
|
2210
|
+
try { fs.mkdirSync(CLONE_DIR, { recursive: true }); }
|
|
2211
|
+
catch (e) { return fail(`the clone directory could not be created — ${String((e && e.code) || 'write failed')}`); }
|
|
2212
|
+
// A re-run of the same id must not clone into a directory that already holds a tree.
|
|
2213
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
2214
|
+
|
|
2215
|
+
const env = { ...sessionEnv(), GIT_TERMINAL_PROMPT: '0' };
|
|
2216
|
+
let child;
|
|
2217
|
+
try {
|
|
2218
|
+
child = spawn('git', ['clone', '--depth', '1', '--', pr.source.url, dir],
|
|
2219
|
+
{ env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
2220
|
+
} catch (e) {
|
|
2221
|
+
// Thrown synchronously on some platforms; the same fact as the ENOENT below.
|
|
2222
|
+
return fail('git is not on PATH — PMOS did not run a clone');
|
|
2223
|
+
}
|
|
2224
|
+
let stderr = '';
|
|
2225
|
+
let timedOut = false;
|
|
2226
|
+
child.stderr.on('data', (d) => { stderr = (stderr + String(d)).slice(-8000); });
|
|
2227
|
+
const timer = setTimeout(() => { timedOut = true; try { child.kill('SIGKILL'); } catch {} }, INGEST_CLONE_TIMEOUT_MS);
|
|
2228
|
+
if (timer.unref) timer.unref();
|
|
2229
|
+
child.on('error', (e) => {
|
|
2230
|
+
clearTimeout(timer);
|
|
2231
|
+
if (settled) return;
|
|
2232
|
+
settled = true;
|
|
2233
|
+
// THREE distinct failures, and they must not collapse (R9): git absent is not a clone that
|
|
2234
|
+
// failed, and neither is a clone that timed out.
|
|
2235
|
+
if (e && e.code === 'ENOENT') return fail('git is not on PATH — PMOS did not run a clone');
|
|
2236
|
+
fail(`git clone failed: ${String((e && e.message) || 'the clone could not be started')}`);
|
|
2237
|
+
});
|
|
2238
|
+
child.on('close', (code) => {
|
|
2239
|
+
clearTimeout(timer);
|
|
2240
|
+
if (settled) return; // 'error' already reported the real reason — do not restate it worse
|
|
2241
|
+
settled = true;
|
|
2242
|
+
if (timedOut) {
|
|
2243
|
+
return fail(`git clone timed out after ${Math.round(INGEST_CLONE_TIMEOUT_MS / 1000)}s — the clone did not complete and nothing was scanned`);
|
|
2244
|
+
}
|
|
2245
|
+
if (code !== 0) {
|
|
2246
|
+
// git's OWN stderr line, verbatim. A rewritten error is a fact the PM cannot check.
|
|
2247
|
+
const line = stderr.split('\n').map((l) => l.trim()).filter(Boolean).pop() || `git exited with code ${code}`;
|
|
2248
|
+
const auth = /authentication|could not read Username|Permission denied|terminal prompts disabled|not found|403/i.test(stderr);
|
|
2249
|
+
return fail(`git clone failed: ${line}`, auth ? { credential_note: INGEST_CREDENTIAL_NOTE } : null);
|
|
2250
|
+
}
|
|
2251
|
+
// The commit is read, never assumed. A proposal that cannot name the commit it scanned says
|
|
2252
|
+
// null rather than a branch name standing in for one.
|
|
2253
|
+
let commit = null;
|
|
2254
|
+
try {
|
|
2255
|
+
const r = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: dir, env, encoding: 'utf8' });
|
|
2256
|
+
if (r.status === 0) commit = String(r.stdout || '').trim() || null;
|
|
2257
|
+
} catch {}
|
|
2258
|
+
|
|
2259
|
+
const cur = readProposal(pr.id) || pr;
|
|
2260
|
+
cur.stage = 'scan'; cur.source.commit = commit; cur.root = dir;
|
|
2261
|
+
writeProposal(cur); pushIngest(cur);
|
|
2262
|
+
|
|
2263
|
+
let scan;
|
|
2264
|
+
try { scan = repoScan.scanRepo(dir); }
|
|
2265
|
+
catch (e) { return fail(`the scan failed — ${String((e && e.message) || 'error').slice(0, 160)}`); }
|
|
2266
|
+
Object.assign(cur, {
|
|
2267
|
+
stage: 'review', ok: scan.ok, error: scan.error, root: scan.root,
|
|
2268
|
+
scanned_at: nowISO(),
|
|
2269
|
+
log: cur.log.concat(scan.log),
|
|
2270
|
+
unreadable: scan.unreadable,
|
|
2271
|
+
initiatives: scan.initiatives.map(ingestItemWire),
|
|
2272
|
+
adrs: scan.adrs.map((a) => ({ ...a, disposition: 'open' })),
|
|
2273
|
+
});
|
|
2274
|
+
writeProposal(cur); pushIngest(cur);
|
|
2275
|
+
});
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
// The ONE record write in this pipeline. `parent_kr` is set into the LEADING frontmatter through
|
|
2279
|
+
// applyChange, whose contract is REFUSE ON MISMATCH — if the record moved between the scan and the
|
|
2280
|
+
// confirm, the write is rejected loudly rather than merged. Grep this file for `parent_kr`: this
|
|
2281
|
+
// function and the confirm handler are the only write site, which is R4 held structurally.
|
|
2282
|
+
function anchorEdit(text, anchor) {
|
|
2283
|
+
const lines = text.split('\n');
|
|
2284
|
+
if (lines[0].trim() !== '---') {
|
|
2285
|
+
return { error: 'this record has no leading frontmatter block — PMOS will not add one during an import' };
|
|
2286
|
+
}
|
|
2287
|
+
let close = -1;
|
|
2288
|
+
for (let i = 1; i < lines.length; i++) { if (lines[i].trim() === '---') { close = i; break; } }
|
|
2289
|
+
if (close === -1) return { error: 'this record\'s leading frontmatter block is never closed — it is malformed, and PMOS will not guess where it ends' };
|
|
2290
|
+
const head = lines.slice(0, close);
|
|
2291
|
+
const at = head.findIndex((l) => /^parent_kr:/.test(l));
|
|
2292
|
+
if (at !== -1) return { proposal: { before: head[at], after: `parent_kr: ${anchor}` } };
|
|
2293
|
+
// No key at all: insert after `type:`, which D63 requires on every tracked record and which is
|
|
2294
|
+
// therefore the one line guaranteed to exist exactly once in this block.
|
|
2295
|
+
const t = head.findIndex((l) => /^type:/.test(l));
|
|
2296
|
+
if (t === -1) return { error: 'this record declares no `type:` — PMOS has no anchor line to insert after and will not guess a position' };
|
|
2297
|
+
return { proposal: { after: `\nparent_kr: ${anchor}` }, opts: { anchor: head[t] } };
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
// ── focus (workspace-focus): the three reads lib/focus.js cannot perform itself. ───────────────
|
|
2301
|
+
// focus.js is pure — it stats nothing and opens nothing. Two of its outputs are therefore SILENTLY
|
|
2302
|
+
// degraded when the caller does not supply an input, and both degradations are correct-looking:
|
|
2303
|
+
// without `skills` no ▶ delegate button ever appears anywhere, and without `budgetRegistry` every
|
|
2304
|
+
// budget cap reads `null`. They are supplied here, and their failures are passed through as
|
|
2305
|
+
// failures rather than as absences.
|
|
2306
|
+
|
|
2307
|
+
// Every `.skill` file that EXISTS ON DISK under this repo's skills/ and build-skills/, as the
|
|
2308
|
+
// repo-relative paths focus.js matches against (`(?:build-)?skills/….skill`). One readdir per
|
|
2309
|
+
// directory per call — an offer PMOS cannot honour is worse than no offer, so the index is the
|
|
2310
|
+
// filesystem's answer and never a list of names we expect to be there.
|
|
2311
|
+
const SKILL_INDEX_MAX = 2000;
|
|
2312
|
+
function skillIndexFor(root) {
|
|
2313
|
+
const out = [];
|
|
2314
|
+
const walk = (dir, rel, depth) => {
|
|
2315
|
+
if (depth > 4 || out.length >= SKILL_INDEX_MAX) return;
|
|
2316
|
+
let ents = [];
|
|
2317
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
2318
|
+
for (const e of ents) {
|
|
2319
|
+
if (out.length >= SKILL_INDEX_MAX) return;
|
|
2320
|
+
if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) walk(path.join(dir, e.name), `${rel}/${e.name}`, depth + 1); }
|
|
2321
|
+
else if (e.name.endsWith('.skill')) out.push(`${rel}/${e.name}`);
|
|
2322
|
+
}
|
|
2323
|
+
};
|
|
2324
|
+
for (const base of ['skills', 'build-skills']) walk(path.join(root, base), base, 0);
|
|
2325
|
+
return out;
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
// The health-budget registry (D59), parsed out of planning/okrs/health-budgets.md's own table.
|
|
2329
|
+
// {ok:false,error} and "not supplied" are DIFFERENT to focus.js — the first renders
|
|
2330
|
+
// budgets.state:'error', the second 'not-read' — so a failed read is never returned as an absence.
|
|
2331
|
+
const BUDGET_ROW = /^\|\s*`?health:([a-z0-9][\w-]*)`?\s*\|([^|]*)\|([^|]*)\|/i;
|
|
2332
|
+
function budgetRegistryFor(root) {
|
|
2333
|
+
const rel = 'planning/okrs/health-budgets.md';
|
|
2334
|
+
let text;
|
|
2335
|
+
try { text = fs.readFileSync(path.join(root, rel), 'utf8'); }
|
|
2336
|
+
catch (e) {
|
|
2337
|
+
if (e && e.code === 'ENOENT') return { ok: false, error: `${rel} does not exist in this repository — no health budget declares a cap here` };
|
|
2338
|
+
return { ok: false, error: `${rel} could not be read — ${String((e && e.code) || 'read failed')}` };
|
|
2339
|
+
}
|
|
2340
|
+
const budgets = [];
|
|
2341
|
+
for (const line of text.split('\n')) {
|
|
2342
|
+
const m = BUDGET_ROW.exec(line.trim());
|
|
2343
|
+
if (!m) continue;
|
|
2344
|
+
const cap = m[3].trim();
|
|
2345
|
+
budgets.push({ id: `health:${m[1].toLowerCase()}`, covers: m[2].trim() || null, cap: cap || null });
|
|
2346
|
+
}
|
|
2347
|
+
// A file that parsed to nothing is reported as read-and-empty, not as an error: a repo may
|
|
2348
|
+
// legitimately declare no budgets, and the caps column is the only thing being claimed here.
|
|
2349
|
+
return { ok: true, budgets, path: rel };
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
// One entry per DISTINCT repo_key — a PRODUCT, not a working tree. Several worktrees of one repo
|
|
2353
|
+
// are one product with one board, and counting them separately is what put the same initiative in
|
|
2354
|
+
// the queue three times. boardCache.board() ONLY (§6.8): consoleState.workflow() is ~30s and
|
|
2355
|
+
// synchronous, and on a request path it freezes every PTY socket for the whole half minute.
|
|
2356
|
+
function focusProducts() {
|
|
2357
|
+
const trees = wsList();
|
|
2358
|
+
const groups = new Map(); // repo_key -> { id, name, trees[] }
|
|
2359
|
+
for (const t of trees) {
|
|
2360
|
+
const key = t.repo_key || t.root;
|
|
2361
|
+
if (!groups.has(key)) groups.set(key, { id: key, name: t.repo_name || t.name, trees: [] });
|
|
2362
|
+
groups.get(key).trees.push(t);
|
|
2363
|
+
}
|
|
2364
|
+
const rows = sessionList();
|
|
2365
|
+
return [...groups.values()].map((g) => {
|
|
2366
|
+
// The tree whose root IS the repo — the main worktree — is the one whose planning/ and okf/ are
|
|
2367
|
+
// canonical. Absent it (only linked worktrees registered), the first registered tree stands in.
|
|
2368
|
+
const lead = g.trees.find((t) => path.resolve(t.root) === path.resolve(g.id)) || g.trees[0];
|
|
2369
|
+
const w = workspaces.get(lead.id) || null;
|
|
2370
|
+
const root = w ? w.root : lead.root;
|
|
2371
|
+
const ids = new Set(g.trees.map((t) => t.id));
|
|
2372
|
+
let needs;
|
|
2373
|
+
try { needs = needsResult(w); }
|
|
2374
|
+
catch (e) { needs = { ok: false, error: 'the needs-you queue could not be read — ' + String((e && e.code) || 'error').slice(0, 60) }; }
|
|
2375
|
+
let spine;
|
|
2376
|
+
try { spine = surfaceSpine(root); }
|
|
2377
|
+
catch (e) { spine = { unreadable: 'the spine could not be derived — ' + String((e && e.message) || 'error').slice(0, 120) }; }
|
|
2378
|
+
return {
|
|
2379
|
+
id: g.id, name: g.name,
|
|
2380
|
+
board: boardCache.board(root),
|
|
2381
|
+
needs,
|
|
2382
|
+
spine,
|
|
2383
|
+
// Partitioned by each row's OWN `ws`, because focus.js matches sessions to products by id and
|
|
2384
|
+
// a session's `ws` is a working-tree id, not a repo key — the join has to happen here.
|
|
2385
|
+
sessions: rows.filter((s) => ids.has(s.ws)),
|
|
2386
|
+
};
|
|
2387
|
+
});
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
// ── workflow templates (jira-handoff R13). ────────────────────────────────────────────────────
|
|
2391
|
+
// A DECLARED template is a file a human wrote. Nothing derives from it — the engine
|
|
2392
|
+
// (scripts/workflow-state.js) builds one fixed ladder per lane and never reads `flow:` — so the
|
|
2393
|
+
// stage list must come out of the document's own body and `declared:true` is shipped so the client
|
|
2394
|
+
// can label the group without inventing the word.
|
|
2395
|
+
const STAGE_CHAIN = /([a-z][a-z0-9_-]{1,24})(?:\s*(?:→|->)\s*([a-z][a-z0-9_-]{1,24}))+/i;
|
|
2396
|
+
function templateStages(text, fm) {
|
|
2397
|
+
// A declared `stages:` list in frontmatter wins — it is the document saying so directly.
|
|
2398
|
+
const declared = fm && typeof fm.stages === 'string' ? fm.stages : null;
|
|
2399
|
+
const line = declared || (STAGE_CHAIN.test(text) ? STAGE_CHAIN.exec(text)[0] : null);
|
|
2400
|
+
if (!line) return []; // never a default six-stage list: that would be this server declaring it
|
|
2401
|
+
return line.split(/\s*(?:→|->|,)\s*/).map((s) => s.trim().replace(/^[`'"]|[`'"]$/g, ''))
|
|
2402
|
+
.filter((s) => /^[a-z][a-z0-9_-]*$/i.test(s));
|
|
2403
|
+
}
|
|
2404
|
+
function workflowTemplates(root) {
|
|
2405
|
+
const rel = 'planning/workflows';
|
|
2406
|
+
const dir = path.join(root, rel);
|
|
2407
|
+
let ents;
|
|
2408
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
2409
|
+
catch (e) {
|
|
2410
|
+
// ABSENT is calm and stated — most repositories have none — and is not the same answer as
|
|
2411
|
+
// "the directory is there and PMOS could not look inside it".
|
|
2412
|
+
if (e && e.code === 'ENOENT') return { ok: true, empty: true, dir: rel, templates: [], absent: true };
|
|
2413
|
+
return { ok: false, error: `${rel} could not be listed — ${String((e && e.code) || 'read failed')}` };
|
|
2414
|
+
}
|
|
2415
|
+
const files = ents.filter((e) => !e.isDirectory() && /\.md$/i.test(e.name)).map((e) => e.name).sort();
|
|
2416
|
+
const templates = [];
|
|
2417
|
+
for (const nm of files) {
|
|
2418
|
+
if (nm === 'index.md' || nm === 'log.md') continue; // OKF-reserved, never a concept document
|
|
2419
|
+
let text;
|
|
2420
|
+
try { text = fs.readFileSync(path.join(dir, nm), 'utf8'); }
|
|
2421
|
+
catch (e) { return { ok: false, error: `${rel}/${nm} could not be read — ${String((e && e.code) || 'read failed')}` }; }
|
|
2422
|
+
const fm = fmParse.parseLeadingFrontmatter(text) || {};
|
|
2423
|
+
// D63 titles are quoted in frontmatter; the quotes are syntax, not part of the title. `null`
|
|
2424
|
+
// rather than the filename when there is none — a filename standing in for a title is a title
|
|
2425
|
+
// the document does not have.
|
|
2426
|
+
const title = String(fm.title == null ? '' : fm.title).trim().replace(/^["']|["']$/g, '').trim();
|
|
2427
|
+
templates.push({
|
|
2428
|
+
id: nm.replace(/\.md$/i, ''), path: `${rel}/${nm}`,
|
|
2429
|
+
title: title || null,
|
|
2430
|
+
stages: templateStages(text, fm),
|
|
2431
|
+
declared: true,
|
|
2432
|
+
});
|
|
2433
|
+
}
|
|
2434
|
+
if (!templates.length) return { ok: true, empty: true, dir: rel, templates: [] };
|
|
2435
|
+
return { ok: true, empty: false, dir: rel, templates };
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
function buildConsole(name, w, root, params) {
|
|
1895
2439
|
const stateDir = stateDirFor(w);
|
|
1896
2440
|
const snapshotDir = latestSnapshotDir(root);
|
|
1897
2441
|
if (name === 'needs') return needsResult(w);
|
|
@@ -1905,6 +2449,75 @@ function buildConsole(name, w, root) {
|
|
|
1905
2449
|
// child; the payload carries `as_of` / `stale` / `pending` so the client can say how old the
|
|
1906
2450
|
// answer is instead of implying it is current.
|
|
1907
2451
|
if (name === 'workflow') return boardCache.board(w ? w.root : root);
|
|
2452
|
+
// ONE initiative's ladder. The board caps itself at what is in flight and reports the rest as
|
|
2453
|
+
// `hidden`; a panel asked about a named initiative must not read that cap as "no record", so it
|
|
2454
|
+
// derives the one it was asked for. Single engine run — safe on the request path, unlike a board.
|
|
2455
|
+
if (name === 'ladder') return consoleState.ladder({ root: w ? w.root : root, id: params && params.get('id') });
|
|
2456
|
+
// The PM spine belongs to the console family, not the deliverable family: it is read-only
|
|
2457
|
+
// file-mode state and it owes the same honesty contract — a failed read renders RED, never a
|
|
2458
|
+
// green empty tree (R5). It reads the OPENED workspace's repo for the same reason the ladder
|
|
2459
|
+
// does — objectives, budgets and initiative records are properties of that repo.
|
|
2460
|
+
if (name === 'spine') {
|
|
2461
|
+
const s = surfaceSpine(w ? w.root : root);
|
|
2462
|
+
if (s.unreadable) return { ok: false, error: s.unreadable };
|
|
2463
|
+
const placed = s.objectives.reduce((n, o) =>
|
|
2464
|
+
n + o.krs.reduce((m, k) => m + k.initiatives.length, 0) + (o.direct ? o.direct.length : 0), 0)
|
|
2465
|
+
+ s.budgets.reduce((n, b) => n + b.initiatives.length, 0);
|
|
2466
|
+
// `empty` is a SUCCESSFUL read of genuinely nothing — a repo with no initiative records at all.
|
|
2467
|
+
// It is a supported thing to point the workspace at, so it renders calm, not red.
|
|
2468
|
+
return { ok: true, empty: placed === 0 && s.unanchored.length === 0, ...s };
|
|
2469
|
+
}
|
|
2470
|
+
// ── focus (workspace-focus). CROSS-PRODUCT: `ws` is accepted and IGNORED. ──
|
|
2471
|
+
// Focus joins every registered workspace, so scoping it to the selected tree would hide the one
|
|
2472
|
+
// item that is actually blocking the PM behind whichever repo they happened to be standing in.
|
|
2473
|
+
if (name === 'focus') {
|
|
2474
|
+
const products = focusProducts();
|
|
2475
|
+
// The skill index is per-product and unioned: focus.js matches a step's `next` against
|
|
2476
|
+
// repo-relative paths, and a skill that exists in one repo is a real offer for that repo's
|
|
2477
|
+
// ladder. Supplying `[]` (rather than nothing) when no repo has any is deliberate — it means
|
|
2478
|
+
// "we looked and found none", which correctly yields no offers, not "we did not look".
|
|
2479
|
+
// `product.id` IS the repo's main-worktree directory (repoIdentity's key), so it is the root to
|
|
2480
|
+
// read — no second lookup, and no chance of indexing a different tree's skills.
|
|
2481
|
+
const skills = new Set();
|
|
2482
|
+
for (const pr of products) for (const s of skillIndexFor(pr.id)) skills.add(s);
|
|
2483
|
+
// ONE registry, read from the home repo when there is one — health budgets are a PMOS-wide
|
|
2484
|
+
// declaration (D59), and every product's spine is matched against the same caps.
|
|
2485
|
+
const regRoot = HOME_ENABLED ? SELF_ROOT : (products.length ? products[0].id : SELF_ROOT);
|
|
2486
|
+
const view = focus.buildFocus({
|
|
2487
|
+
products,
|
|
2488
|
+
sessions: sessionList(),
|
|
2489
|
+
skills: [...skills],
|
|
2490
|
+
budgetRegistry: budgetRegistryFor(regRoot),
|
|
2491
|
+
});
|
|
2492
|
+
return { ok: true, ...view };
|
|
2493
|
+
}
|
|
2494
|
+
// ── handoff (workspace-jira-handoff R1). ──
|
|
2495
|
+
// The record's LEADING frontmatter, which no existing payload carries, parsed by the real
|
|
2496
|
+
// parser — a hand-rolled regex here would be a second opinion about what a record declares.
|
|
2497
|
+
// `links.connected` is false on every code path readLinks has; there is no argument that
|
|
2498
|
+
// changes it and none is passed.
|
|
2499
|
+
if (name === 'handoff') {
|
|
2500
|
+
const id = params && params.get('id');
|
|
2501
|
+
if (!id) return { ok: false, error: 'an initiative id is required', http_status: 400 };
|
|
2502
|
+
// Same path guard as every other read on this surface — an id is a filename, not a path.
|
|
2503
|
+
if (!/^[A-Za-z0-9][\w.-]*$/.test(id)) return { ok: false, error: 'that initiative id is not a record name', http_status: 400 };
|
|
2504
|
+
const rel = `planning/initiatives/${id}.md`;
|
|
2505
|
+
const file = path.join(w ? w.root : root, 'planning', 'initiatives', `${id}.md`);
|
|
2506
|
+
let text;
|
|
2507
|
+
try { text = fs.readFileSync(file, 'utf8'); }
|
|
2508
|
+
catch (e) {
|
|
2509
|
+
// A record that does not exist is NOT a failed read — the panel already has a "no record"
|
|
2510
|
+
// state for it. Anything else (EACCES, EISDIR, EIO) is PMOS failing to look, and is red.
|
|
2511
|
+
if (e && e.code === 'ENOENT') return { ok: true, id, record: rel, links: null, absent: true };
|
|
2512
|
+
return { ok: false, error: `${rel} could not be read — ${String((e && e.code) || 'read failed')}` };
|
|
2513
|
+
}
|
|
2514
|
+
let links;
|
|
2515
|
+
try { links = handoff.readLinks(fmParse.parseLeadingFrontmatter(text)); }
|
|
2516
|
+
catch (e) { return { ok: false, error: `${rel} — ${String((e && e.message) || 'the frontmatter could not be parsed').slice(0, 160)}` }; }
|
|
2517
|
+
return { ok: true, id, record: rel, links };
|
|
2518
|
+
}
|
|
2519
|
+
// ── workflow templates (jira-handoff R13). ──
|
|
2520
|
+
if (name === 'workflows') return workflowTemplates(w ? w.root : root);
|
|
1908
2521
|
return { ok: false, error: 'unknown console surface: ' + name };
|
|
1909
2522
|
}
|
|
1910
2523
|
|
|
@@ -2068,6 +2681,7 @@ async function handleRequest(req, res) {
|
|
|
2068
2681
|
if (p === '/app.js') return serveFile(res, path.join(PUB, 'app.js'), null, true);
|
|
2069
2682
|
if (p === '/lib/preview-kind.js') return serveFile(res, path.join(__dirname, 'lib', 'preview-kind.js'));
|
|
2070
2683
|
if (p === '/lib/layout.js') return serveFile(res, path.join(__dirname, 'lib', 'layout.js'));
|
|
2684
|
+
if (p === '/lib/mobile-nav.js') return serveFile(res, path.join(__dirname, 'lib', 'mobile-nav.js'));
|
|
2071
2685
|
if (p === '/lib/session-state.js') return serveFile(res, path.join(__dirname, 'lib', 'session-state.js'));
|
|
2072
2686
|
// The editor computes its live diff with the SAME module the server saves through, so what the PM
|
|
2073
2687
|
// sees while typing and what gets written cannot disagree. NB: /lib/ is an explicit allowlist, not
|
|
@@ -2076,6 +2690,17 @@ async function handleRequest(req, res) {
|
|
|
2076
2690
|
if (p === '/lib/apply-change.js') return serveFile(res, path.join(__dirname, 'lib', 'apply-change.js'));
|
|
2077
2691
|
if (p === '/lib/related-docs.js') return serveFile(res, path.join(__dirname, 'lib', 'related-docs.js'));
|
|
2078
2692
|
if (p === '/styles.css') return serveFile(res, path.join(PUB, 'styles.css'));
|
|
2693
|
+
// Every browser asks for /favicon.ico unprompted. Without a route it 404s on every load, which
|
|
2694
|
+
// puts a permanent red line in the console and in this suite's `page errors seen` list — noise
|
|
2695
|
+
// that trains you to ignore the one place a real client-side failure would show up. An inline SVG
|
|
2696
|
+
// answers it with no asset to ship: the PMOS mark, sized to work at 16px.
|
|
2697
|
+
if (p === '/favicon.ico' || p === '/favicon.svg') {
|
|
2698
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` +
|
|
2699
|
+
`<rect width="16" height="16" rx="3" fill="#111"/>` +
|
|
2700
|
+
`<path d="M4 12V4h3.2a2.4 2.4 0 010 4.8H6" stroke="#fff" stroke-width="1.6" fill="none"/></svg>`;
|
|
2701
|
+
res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'max-age=86400' });
|
|
2702
|
+
return res.end(svg);
|
|
2703
|
+
}
|
|
2079
2704
|
// xterm assets straight from node_modules (offline; no CDN)
|
|
2080
2705
|
if (p === '/vendor/xterm.js') return serveFile(res, path.join(XROOT, 'xterm', 'lib', 'xterm.js'));
|
|
2081
2706
|
if (p === '/vendor/xterm.css') return serveFile(res, path.join(XROOT, 'xterm', 'css', 'xterm.css'));
|
|
@@ -2145,6 +2770,191 @@ async function handleRequest(req, res) {
|
|
|
2145
2770
|
if (!isDir(root)) return json(res, { ok: false, error: `not a directory: ${root}` }, 400);
|
|
2146
2771
|
return json(res, { ok: true, root });
|
|
2147
2772
|
}
|
|
2773
|
+
// ── repo ingest (workspace-repo-ingest R1/R2/R9). ────────────────────────────────────────────
|
|
2774
|
+
if (p === '/api/ingest' && req.method === 'POST') {
|
|
2775
|
+
let body = {};
|
|
2776
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
2777
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
2778
|
+
// R2 enforced in CODE, not only in copy: a local folder is REGISTERED (POST /api/workspaces)
|
|
2779
|
+
// and nothing is imported from it. Accepting `folder` here and quietly registering it would
|
|
2780
|
+
// make the two paths indistinguishable to anything but the copy under the radio button.
|
|
2781
|
+
if (body.source === 'folder' || body.source === 'local') {
|
|
2782
|
+
return json(res, { ok: false, error: 'a local folder is registered, never ingested — nothing is imported from a bare folder' }, 400);
|
|
2783
|
+
}
|
|
2784
|
+
if (body.source !== 'github') return json(res, { ok: false, error: 'source must be "github"' }, 400);
|
|
2785
|
+
const ref = String(body.ref == null ? '' : body.ref).trim();
|
|
2786
|
+
if (!ref) return json(res, { ok: false, error: 'a repository reference is required' }, 400);
|
|
2787
|
+
const m = GITHUB_REF.exec(ref);
|
|
2788
|
+
if (!m) return json(res, { ok: false, error: `"${ref.slice(0, 80)}" is not a GitHub repository reference — give owner/name or https://github.com/owner/name` }, 400);
|
|
2789
|
+
const slug = `${m[1]}/${m[2]}`;
|
|
2790
|
+
const pr = {
|
|
2791
|
+
id: nextIngestId(), stage: 'acquire',
|
|
2792
|
+
source: { kind: 'github', ref: slug, url: `https://github.com/${slug}.git`, commit: null },
|
|
2793
|
+
clone_dir: `state/clones/`, scanned_at: null,
|
|
2794
|
+
ok: true, error: null, root: null,
|
|
2795
|
+
log: [{ level: 'info', path: null, message: `cloning ${slug} — read-only; nothing is written into this repository or into PMOS until an item is confirmed` }],
|
|
2796
|
+
unreadable: [], initiatives: [], adrs: [],
|
|
2797
|
+
};
|
|
2798
|
+
pr.clone_dir = `state/clones/${pr.id}`;
|
|
2799
|
+
const err = writeProposal(pr);
|
|
2800
|
+
if (err) return json(res, { ok: false, error: err }, 500);
|
|
2801
|
+
pushIngest(pr);
|
|
2802
|
+
// Returns IMMEDIATELY. The clone runs off the request path and reports over the socket.
|
|
2803
|
+
startIngestClone(pr);
|
|
2804
|
+
return json(res, { ok: true, id: pr.id, stage: pr.stage });
|
|
2805
|
+
}
|
|
2806
|
+
if (p === '/api/ingest' && req.method === 'GET') {
|
|
2807
|
+
const id = url.searchParams.get('id');
|
|
2808
|
+
if (id) {
|
|
2809
|
+
const pr = readProposal(id);
|
|
2810
|
+
if (!pr) return json(res, { ok: false, error: 'no such ingest' });
|
|
2811
|
+
return json(res, { ok: true, proposal: pr });
|
|
2812
|
+
}
|
|
2813
|
+
return json(res, { ok: true, proposals: listProposals().filter((x) => x.stage !== 'closed') });
|
|
2814
|
+
}
|
|
2815
|
+
if (p === '/api/ingest/confirm' && req.method === 'POST') {
|
|
2816
|
+
let body = {};
|
|
2817
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
2818
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
2819
|
+
// ONE item. There is no array form, no `items`, no `all` — the tedium IS the gate (R4). A
|
|
2820
|
+
// bulk confirm is how a dozen guessed anchors land on the board with every check green.
|
|
2821
|
+
if (Array.isArray(body.item) || body.items != null || body.all != null) {
|
|
2822
|
+
return json(res, { ok: false, error: 'confirmation is per item' }, 400);
|
|
2823
|
+
}
|
|
2824
|
+
const pr = readProposal(body.id);
|
|
2825
|
+
if (!pr) return json(res, { ok: false, error: 'no such ingest' }, 400);
|
|
2826
|
+
const item = String(body.item == null ? '' : body.item);
|
|
2827
|
+
const row = (pr.initiatives || []).find((r) => r.id === item);
|
|
2828
|
+
if (!row) return json(res, { ok: false, error: `this proposal has no item "${item.slice(0, 60)}"` }, 400);
|
|
2829
|
+
// The anchor comes from the PM's action and nowhere else. There is no code path in which a
|
|
2830
|
+
// `guessedAnchor` reaches `parent_kr` without an explicit request carrying it.
|
|
2831
|
+
const anchor = String(body.anchor == null ? '' : body.anchor).trim();
|
|
2832
|
+
if (!anchor) return json(res, { ok: false, error: 'an anchor is required — a guess is not a declaration and D59 rejects unanchored work' }, 400);
|
|
2833
|
+
if (!pr.root) return json(res, { ok: false, error: 'this proposal has no scanned repository — nothing was cloned, so there is nothing to write to' }, 409);
|
|
2834
|
+
const full = path.resolve(pr.root, row.path);
|
|
2835
|
+
if (!insideRoot(pr.root, full)) return json(res, { ok: false, error: 'that record is outside the scanned repository' }, 400);
|
|
2836
|
+
|
|
2837
|
+
// The resolver's verdict is computed and reported BEFORE the write, so the PM is told whether
|
|
2838
|
+
// the anchor they chose names anything this repository declares — not after the fact.
|
|
2839
|
+
let resolves = false, resolverNote = 'the anchor resolver (scripts/anchor-resolve.js) is not present in this install — whether this anchor resolves was not checked';
|
|
2840
|
+
const A = anchorLib();
|
|
2841
|
+
if (A) {
|
|
2842
|
+
try {
|
|
2843
|
+
const r = A.resolveAnchor(anchor, A.parseOkrIndex(pr.root), A.readBudgets(pr.root));
|
|
2844
|
+
resolves = r.status === 'resolved';
|
|
2845
|
+
resolverNote = r.reason || `anchor status: ${r.status}`;
|
|
2846
|
+
} catch (e) { resolverNote = `the anchor could not be resolved — ${String((e && e.message) || 'error').slice(0, 120)}`; }
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
let current;
|
|
2850
|
+
try { current = fs.readFileSync(full, 'utf8'); }
|
|
2851
|
+
catch (e) { return json(res, { ok: false, error: `${row.path} could not be read — ${String((e && e.code) || 'read failed')}` }, 409); }
|
|
2852
|
+
const edit = anchorEdit(current, anchor);
|
|
2853
|
+
if (edit.error) return json(res, { ok: false, error: `${row.path} — ${edit.error}` }, 409);
|
|
2854
|
+
const applied = applyChange.applyChange(current, edit.proposal, edit.opts);
|
|
2855
|
+
if (!applied.ok) {
|
|
2856
|
+
// applyChange's OWN refusal reason — the record moved between the scan and the confirm, and
|
|
2857
|
+
// a merged-anyway write is a diff the PM never saw.
|
|
2858
|
+
return json(res, { ok: false, error: `${row.path} — ${applied.detail || applied.reason}` }, 409);
|
|
2859
|
+
}
|
|
2860
|
+
try { fs.writeFileSync(full, applied.text); }
|
|
2861
|
+
catch (e) { return json(res, { ok: false, error: `${row.path} could not be written — ${String((e && e.code) || 'write failed')}` }, 409); }
|
|
2862
|
+
row.disposition = 'confirmed';
|
|
2863
|
+
row.declaredAnchor = anchor; row.guessedAnchor = null;
|
|
2864
|
+
row.anchor_state = 'declared'; row.anchor_value = anchor;
|
|
2865
|
+
row.why = `confirmed by the PM on ${nowISO()} — written into ${row.path} as \`parent_kr: ${anchor}\``;
|
|
2866
|
+
row.evidence = row.why;
|
|
2867
|
+
const werr = writeProposal(pr);
|
|
2868
|
+
pushIngest(pr);
|
|
2869
|
+
if (werr) return json(res, { ok: false, error: `${anchor} was written into ${row.path}, but ${werr} — the proposal on screen is now behind the file` }, 500);
|
|
2870
|
+
return json(res, { ok: true, path: row.path, anchor, resolves, resolver_note: resolverNote });
|
|
2871
|
+
}
|
|
2872
|
+
if (p === '/api/ingest/dismiss' && req.method === 'POST') {
|
|
2873
|
+
let body = {};
|
|
2874
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
2875
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
2876
|
+
const pr = readProposal(body.id);
|
|
2877
|
+
if (!pr) return json(res, { ok: false, error: 'no such ingest' }, 400);
|
|
2878
|
+
const row = (pr.initiatives || []).find((r) => r.id === String(body.item == null ? '' : body.item));
|
|
2879
|
+
if (!row) return json(res, { ok: false, error: `this proposal has no item "${String(body.item || '').slice(0, 60)}"` }, 400);
|
|
2880
|
+
row.disposition = 'dismissed';
|
|
2881
|
+
const err = writeProposal(pr);
|
|
2882
|
+
if (err) return json(res, { ok: false, error: err }, 500);
|
|
2883
|
+
pushIngest(pr);
|
|
2884
|
+
return json(res, { ok: true });
|
|
2885
|
+
}
|
|
2886
|
+
if (p === '/api/ingest/adr' && req.method === 'POST') {
|
|
2887
|
+
let body = {};
|
|
2888
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
2889
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
2890
|
+
const pr = readProposal(body.id);
|
|
2891
|
+
if (!pr) return json(res, { ok: false, error: 'no such ingest' }, 400);
|
|
2892
|
+
const disposition = body.disposition;
|
|
2893
|
+
if (disposition !== 'accepted' && disposition !== 'superseded') {
|
|
2894
|
+
return json(res, { ok: false, error: 'disposition must be "accepted" or "superseded"' }, 400);
|
|
2895
|
+
}
|
|
2896
|
+
const rel = String(body.path == null ? '' : body.path);
|
|
2897
|
+
const adr = (pr.adrs || []).find((a) => a.path === rel);
|
|
2898
|
+
if (!adr) return json(res, { ok: false, error: `this proposal has no ADR at "${rel.slice(0, 80)}"` }, 400);
|
|
2899
|
+
const w = getWs(body.ws);
|
|
2900
|
+
if (!w) return json(res, { ok: false, error: 'no workspace to accept this ADR into' }, 400);
|
|
2901
|
+
|
|
2902
|
+
let wrote = null;
|
|
2903
|
+
if (disposition === 'accepted') {
|
|
2904
|
+
if (!pr.root) return json(res, { ok: false, error: 'this proposal has no scanned repository — there is no file to copy' }, 409);
|
|
2905
|
+
const src = path.resolve(pr.root, rel);
|
|
2906
|
+
if (!insideRoot(pr.root, src)) return json(res, { ok: false, error: 'that ADR is outside the scanned repository' }, 400);
|
|
2907
|
+
// The RECEIVING repo's own adr/ directory. PMOS does not invent one: a repository with no
|
|
2908
|
+
// OKF bundle has nowhere an ADR belongs, and creating a tree during an import would be this
|
|
2909
|
+
// server deciding where another repo's knowledge lives.
|
|
2910
|
+
const dirs = findDirsNamed(path.join(w.root, 'okf'), 'adr', 3);
|
|
2911
|
+
if (!dirs.length) {
|
|
2912
|
+
return json(res, { ok: false, error: `“${w.name}” has no \`adr/\` directory under okf/ — PMOS will not create one during an import; make the directory and accept again` }, 409);
|
|
2913
|
+
}
|
|
2914
|
+
let text;
|
|
2915
|
+
try { text = fs.readFileSync(src, 'utf8'); }
|
|
2916
|
+
catch (e) { return json(res, { ok: false, error: `${rel} could not be read — ${String((e && e.code) || 'read failed')}` }, 409); }
|
|
2917
|
+
const name = path.basename(rel);
|
|
2918
|
+
const dest = path.join(dirs[0], name);
|
|
2919
|
+
// Never overwrite. An ADR of that name already there is a collision only a human can settle.
|
|
2920
|
+
if (fs.existsSync(dest)) {
|
|
2921
|
+
return json(res, { ok: false, error: `${path.relative(w.root, dest)} already exists — PMOS will not overwrite an ADR during an import` }, 409);
|
|
2922
|
+
}
|
|
2923
|
+
// The PROVENANCE goes into the FILE. The `ingested` badge is derived from `ingested_from`
|
|
2924
|
+
// being present in the document — never from a stored flag — so a badge cannot outlive the
|
|
2925
|
+
// fact it claims, and a copy someone moves by hand carries its own origin with it.
|
|
2926
|
+
// The scan only proposes a file that already parsed with a `type: ADR` frontmatter block, so
|
|
2927
|
+
// the two lines go inside the block that is known to be there rather than in a new one.
|
|
2928
|
+
const stamp = `ingested_from: ${pr.source.ref}@${pr.source.commit || 'unknown-commit'}\ningested_at: ${nowISO().slice(0, 10)}`;
|
|
2929
|
+
const lines = text.split('\n');
|
|
2930
|
+
if (lines[0].trim() !== '---') {
|
|
2931
|
+
return json(res, { ok: false, error: `${rel} has no leading frontmatter block — PMOS cannot record where it came from, and will not import a document it cannot stamp` }, 409);
|
|
2932
|
+
}
|
|
2933
|
+
lines.splice(1, 0, stamp);
|
|
2934
|
+
try { fs.writeFileSync(dest, lines.join('\n')); }
|
|
2935
|
+
catch (e) { return json(res, { ok: false, error: `${path.relative(w.root, dest)} could not be written — ${String((e && e.code) || 'write failed')}` }, 409); }
|
|
2936
|
+
wrote = path.relative(w.root, dest);
|
|
2937
|
+
}
|
|
2938
|
+
// The disposition itself is a decision record, appended to the workspace's own file-mode
|
|
2939
|
+
// state, keyed by ADR path. It is NOT written to acceptances.jsonl: that file is the PM
|
|
2940
|
+
// Acceptance Gate's log and metrics.py counts its rows for the north star — filing an import
|
|
2941
|
+
// decision there would inflate a metric with something that was never an agent run.
|
|
2942
|
+
const d = stateDirFor(w, { create: true });
|
|
2943
|
+
if (!d) return json(res, { ok: false, error: `the disposition could not be recorded — “${w.name}” has no file-mode state directory and PMOS will not create one in a repository of unknown shape`, wrote }, 500);
|
|
2944
|
+
const rec = { at: nowISO(), date: nowISO().slice(0, 10), kind: 'adr', adr_path: rel,
|
|
2945
|
+
disposition, ingest: pr.id, source: `${pr.source.ref}@${pr.source.commit || 'unknown-commit'}`,
|
|
2946
|
+
wrote, workspace: w.name, root: w.root, pm: true };
|
|
2947
|
+
try { fs.appendFileSync(path.join(d, 'ingest.jsonl'), JSON.stringify(rec) + '\n'); }
|
|
2948
|
+
catch (e) {
|
|
2949
|
+
warnWriteFail('ingest.jsonl (ADR dispositions)', e);
|
|
2950
|
+
return json(res, { ok: false, error: 'the disposition could not be recorded — the file-mode state directory is unwritable; the decision was NOT saved', wrote }, 500);
|
|
2951
|
+
}
|
|
2952
|
+
adr.disposition = disposition;
|
|
2953
|
+
const err = writeProposal(pr);
|
|
2954
|
+
if (err) return json(res, { ok: false, error: err, wrote }, 500);
|
|
2955
|
+
pushIngest(pr);
|
|
2956
|
+
return json(res, { ok: true, wrote });
|
|
2957
|
+
}
|
|
2148
2958
|
if (p === '/api/fs/list') {
|
|
2149
2959
|
const listing = fsList(url.searchParams.get('path'));
|
|
2150
2960
|
if (!listing) return json(res, { ok: false, error: 'not a directory' }, 404);
|
|
@@ -2224,10 +3034,64 @@ async function handleRequest(req, res) {
|
|
|
2224
3034
|
skill: { id: built.slug, label: built.label, skill: built.slug,
|
|
2225
3035
|
description: built.description, output: built.output, source: 'user' } });
|
|
2226
3036
|
}
|
|
3037
|
+
// ── profiles (workspace-profiles R1/R2/R4). ──────────────────────────────────────────────────
|
|
3038
|
+
// Four states, mapped straight from readProfiles() and NEVER collapsed: the directory does not
|
|
3039
|
+
// exist (calm, path named) · it exists and holds nothing (a successful read of nothing, also
|
|
3040
|
+
// calm, different words) · it holds descriptors, some of which are broken (both lists ship —
|
|
3041
|
+
// one bad file must not turn the surface into a stated absence) · it could not be read (RED).
|
|
3042
|
+
//
|
|
3043
|
+
// `env` values are absent from this payload BY CONSTRUCTION (profileWire). An overlay is where a
|
|
3044
|
+
// credential lives, and D08's discipline covers anything the browser receives; `env_keys` exists
|
|
3045
|
+
// so the pane can say WHAT was applied without shipping what it was.
|
|
3046
|
+
if (p === '/api/profiles' && req.method === 'GET') {
|
|
3047
|
+
const read = readProfilesNow();
|
|
3048
|
+
return json(res, {
|
|
3049
|
+
ok: read.ok !== false,
|
|
3050
|
+
absent: !!read.absent,
|
|
3051
|
+
dir: read.dir,
|
|
3052
|
+
profiles: (read.profiles || []).map((x) => profileWire(x, read)),
|
|
3053
|
+
broken: (read.broken || []).map((b) => ({ file: b.file, error: b.error })),
|
|
3054
|
+
prefs: readPrefs(),
|
|
3055
|
+
error: read.error || null,
|
|
3056
|
+
});
|
|
3057
|
+
}
|
|
3058
|
+
if (p === '/api/profiles/prefer' && req.method === 'POST') {
|
|
3059
|
+
let body = {};
|
|
3060
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
3061
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
3062
|
+
const agent = typeof body.agent === 'string' ? body.agent.trim() : '';
|
|
3063
|
+
if (!agent) return json(res, { ok: false, error: 'an agent name is required' }, 400);
|
|
3064
|
+
// "Unknown agent" has to mean something CHECKABLE, and PMOS keeps no agent registry — the
|
|
3065
|
+
// roster is derived from live sessions, which is a client-side fact. What the server can check
|
|
3066
|
+
// is the only thing that matters here: whether it could ever start that agent. A preference
|
|
3067
|
+
// for a CLI that is not on this machine would preselect the composer for something that
|
|
3068
|
+
// cannot spawn. `shell` is the one built-in kind and has no binary of its own.
|
|
3069
|
+
if (agent !== 'shell' && !commandExists(agent)) {
|
|
3070
|
+
return json(res, { ok: false, error: `“${agent.slice(0, 40)}” is not on PATH — PMOS will not remember a preference for an agent it cannot start` }, 400);
|
|
3071
|
+
}
|
|
3072
|
+
// PMOS does not remember a preference for a descriptor it cannot read. The preference is its
|
|
3073
|
+
// OWN memory (it preselects the composer and starts nothing), so a stored id that resolves to
|
|
3074
|
+
// nothing would silently preselect a chip that is not on screen.
|
|
3075
|
+
const pres = resolveProfileForSpawn(body.profile);
|
|
3076
|
+
if (!pres.ok) return json(res, { ok: false, error: pres.error }, 400);
|
|
3077
|
+
const prefs = readPrefs();
|
|
3078
|
+
if (pres.id == null) delete prefs[agent]; else prefs[agent] = pres.id;
|
|
3079
|
+
const err = writePrefs(prefs);
|
|
3080
|
+
if (err) return json(res, { ok: false, error: err }, 500);
|
|
3081
|
+
return json(res, { ok: true, prefs });
|
|
3082
|
+
}
|
|
2227
3083
|
if (p === '/api/schedules' && req.method === 'GET') {
|
|
2228
3084
|
const wsFilter = getWs(url.searchParams.get('ws'));
|
|
2229
3085
|
const id = wsFilter ? wsFilter.id : null;
|
|
2230
|
-
const list = readSchedules().filter((s) => !id || s.ws === id).map((s) =>
|
|
3086
|
+
const list = readSchedules().filter((s) => !id || s.ws === id).map((s) => {
|
|
3087
|
+
// `profile_applies` is the difference between "no profile was chosen" and "a profile could
|
|
3088
|
+
// not apply here at all": a skill-kind task runs IN THIS PROCESS and never creates a PTY, so
|
|
3089
|
+
// the detail row states that instead of offering a control that would do nothing (R8.3).
|
|
3090
|
+
const t = taskById(s.task || s.skill);
|
|
3091
|
+
return { ...s, next: scheduleLogic.nextRun(s),
|
|
3092
|
+
profile: s.profile == null ? null : s.profile,
|
|
3093
|
+
profile_applies: !!(t && t.kind === 'agent') };
|
|
3094
|
+
});
|
|
2231
3095
|
return json(res, { schedules: list, now: nowISO() });
|
|
2232
3096
|
}
|
|
2233
3097
|
if (p === '/api/schedules' && req.method === 'POST') {
|
|
@@ -2236,7 +3100,11 @@ async function handleRequest(req, res) {
|
|
|
2236
3100
|
const norm = scheduleLogic.normalizeSchedule(body);
|
|
2237
3101
|
if (!norm.ok) return json(res, { ok: false, error: norm.error }, 400);
|
|
2238
3102
|
const w = getWs(body.ws);
|
|
2239
|
-
|
|
3103
|
+
// A profile named on a schedule is checked NOW, not at fire time — the PM is here to be told.
|
|
3104
|
+
const pres = resolveProfileForSpawn(body.profile);
|
|
3105
|
+
if (!pres.ok) return json(res, { ok: false, error: pres.error }, 400);
|
|
3106
|
+
const sched = { id: 'sc-' + Date.now().toString(36), ws: w ? w.id : wsId(SELF_ROOT), ...norm.schedule,
|
|
3107
|
+
profile: pres.id, lastRun: null, lastStatus: null };
|
|
2240
3108
|
sched.nextRun = scheduleLogic.nextRun(sched);
|
|
2241
3109
|
const list = readSchedules(); list.unshift(sched); writeSchedules(list);
|
|
2242
3110
|
return json(res, { ok: true, schedule: sched });
|
|
@@ -2247,6 +3115,13 @@ async function handleRequest(req, res) {
|
|
|
2247
3115
|
if (!s) return json(res, { ok: false, error: 'no such schedule' }, 404);
|
|
2248
3116
|
if (typeof body.enabled === 'boolean') s.enabled = body.enabled;
|
|
2249
3117
|
for (const k of ['name', 'cadence', 'day', 'dayOfMonth', 'time', 'task', 'skill']) if (body[k] != null) s[k] = body[k];
|
|
3118
|
+
// `profile` is settable to null (clear it) as well as to an id, so it is checked for PRESENCE
|
|
3119
|
+
// of the key rather than for a non-null value — the other fields above cannot be cleared.
|
|
3120
|
+
if ('profile' in body) {
|
|
3121
|
+
const pres = resolveProfileForSpawn(body.profile);
|
|
3122
|
+
if (!pres.ok) return json(res, { ok: false, error: pres.error }, 400);
|
|
3123
|
+
s.profile = pres.id;
|
|
3124
|
+
}
|
|
2250
3125
|
s.nextRun = scheduleLogic.nextRun(s); writeSchedules(list);
|
|
2251
3126
|
return json(res, { ok: true, schedule: s });
|
|
2252
3127
|
}
|
|
@@ -2375,9 +3250,15 @@ async function handleRequest(req, res) {
|
|
|
2375
3250
|
const w = getWs(url.searchParams.get('ws'));
|
|
2376
3251
|
const root = w ? w.root : SELF_ROOT;
|
|
2377
3252
|
let out;
|
|
2378
|
-
try { out = buildConsole(name, w, root); }
|
|
3253
|
+
try { out = buildConsole(name, w, root, url.searchParams); }
|
|
2379
3254
|
catch (e) { out = { ok: false, error: 'console read failed: ' + String((e && e.message) || 'error').slice(0, 120) }; }
|
|
2380
|
-
|
|
3255
|
+
// A surface may name an HTTP status for a MALFORMED REQUEST only (400 — a missing required
|
|
3256
|
+
// param). Every discriminated READ result stays 200, including {ok:false}: the client's
|
|
3257
|
+
// honest-state rendering is driven by the body, and a transport code cannot carry the reason.
|
|
3258
|
+
// Stripped by spreading rather than deleting — some surfaces return frozen objects.
|
|
3259
|
+
const code = (out && Number.isFinite(out.http_status)) ? out.http_status : 200;
|
|
3260
|
+
if (out && out.http_status != null) { out = { ...out }; delete out.http_status; }
|
|
3261
|
+
return json(res, { name, ...out }, code);
|
|
2381
3262
|
}
|
|
2382
3263
|
if (p === '/api/search') {
|
|
2383
3264
|
// Episodic recall over this workspace's own past sessions. Localhost only, like everything
|
|
@@ -2811,15 +3692,25 @@ wss.on('connection', (ws) => {
|
|
|
2811
3692
|
} else if (m.t === 'resize') {
|
|
2812
3693
|
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 {} }
|
|
2813
3694
|
} else if (m.t === 'new') {
|
|
2814
|
-
const r = spawnWithBranch({ name: m.name, agent: m.agent, cwd: m.cwd, ws: m.ws, branch: m.branch, createBranch: m.createBranch, isolate: m.isolate });
|
|
2815
|
-
|
|
3695
|
+
const r = spawnWithBranch({ name: m.name, agent: m.agent, cwd: m.cwd, ws: m.ws, branch: m.branch, createBranch: m.createBranch, isolate: m.isolate, profile: m.profile });
|
|
3696
|
+
// A refused profile is its own sentence, not "branch: …" — the composer's error line names
|
|
3697
|
+
// what PMOS could not read, and nothing was started.
|
|
3698
|
+
if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: (/^profile /.test(r.error || '') ? '' : 'branch: ') + r.error })); return; }
|
|
2816
3699
|
// a non-fatal truth about what just happened (a worktree's path, a branch ignored on a
|
|
2817
3700
|
// non-repo) — said out loud, never swallowed
|
|
2818
3701
|
if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
|
|
2819
3702
|
ws.send(JSON.stringify({ t: 'created', id: r.session.id }));
|
|
2820
3703
|
} else if (m.t === 'respawn') {
|
|
2821
3704
|
const old = sessions.get(m.id); if (!old || old.pty) return;
|
|
2822
|
-
|
|
3705
|
+
// `respawn` is UNCHANGED on the wire (profiles R9): the profile is read off the record the
|
|
3706
|
+
// server already holds, not re-sent by the client — a client that forgot it would otherwise
|
|
3707
|
+
// silently restart the pane on a different environment, and the pane would look identical.
|
|
3708
|
+
const carried = 'profile' in old ? old.profile : null;
|
|
3709
|
+
const s = spawnSession({ name: old.name, agent: old.agent, cwd: path.relative(old.root, old.cwd), ws: old.ws, profile: carried });
|
|
3710
|
+
// A descriptor deleted since the original spawn does NOT get substituted. The respawn goes
|
|
3711
|
+
// ahead with no profile — the pane line already says so — and the reason is said out loud
|
|
3712
|
+
// rather than left for the PM to notice.
|
|
3713
|
+
if (s.profileRefusal) ws.send(JSON.stringify({ t: 'notice', notice: `respawned without a profile — ${s.profileRefusal}` }));
|
|
2823
3714
|
removeSession(old.id); // the corpse doesn't linger — respawn replaces, not accumulates
|
|
2824
3715
|
ws.send(JSON.stringify({ t: 'created', id: s.id }));
|
|
2825
3716
|
} else if (m.t === 'kill') {
|