create-pmos 0.96.0 → 0.97.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/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/index.md +1 -1
- package/workspace/guide/layout.md +3 -3
- package/workspace/lib/claude-flags.js +104 -0
- package/workspace/lib/claude-flags.json +992 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +22 -0
- package/workspace/public/pm/data.js +6 -0
- package/workspace/public/pm/dest-settings.js +101 -4
- package/workspace/public/pm/dock-sessions.js +4 -1
- package/workspace/public/pm/dock.js +9 -3
- package/workspace/public/pm/flag-picker.js +104 -0
- package/workspace/public/pm/frame-copy.js +20 -1
- package/workspace/public/pm/panel-newsess.js +85 -3
- package/workspace/public/pm/tab-term.js +3 -0
- package/workspace/server.js +79 -11
- package/workspace/test-lists/workspace-launch-flags.json +15 -0
package/workspace/server.js
CHANGED
|
@@ -130,6 +130,7 @@ const sessionState = require('./lib/session-state'); // the cadence×turn join
|
|
|
130
130
|
// none of their logic is re-derived here, because a second implementation of "what does this
|
|
131
131
|
// record declare" is how two surfaces come to disagree about the same file.
|
|
132
132
|
const repoScan = require('./lib/repo-scan'); // scanRepo(root) — pure, cannot write (ingest R3)
|
|
133
|
+
const claudeFlags = require('./lib/claude-flags'); // list() / resolve(requested, agent) — the launch-flag allow-list
|
|
133
134
|
const profilesLib = require('./lib/profiles'); // readProfiles(homeDir) / profileLine(profile, read)
|
|
134
135
|
const focus = require('./lib/focus'); // buildFocus(input) → FocusView (cross-product)
|
|
135
136
|
const handoff = require('./lib/handoff'); // readLinks(parsedFrontmatterObject) — never a path
|
|
@@ -712,6 +713,10 @@ function announceModel(s) {
|
|
|
712
713
|
if (!s.model || s.model.state === 'not-asked') return;
|
|
713
714
|
appendBuf(s, `\x1b[38;5;244mpmos: ${modelLib.line(s.model)}\x1b[0m\r\n`);
|
|
714
715
|
}
|
|
716
|
+
function announceLaunchFlags(s) {
|
|
717
|
+
if (!Array.isArray(s.launchFlags) || !s.launchFlags.length) return;
|
|
718
|
+
appendBuf(s, `\x1b[38;5;244mpmos: ${claudeFlags.line(s.launchFlags)}\x1b[0m\r\n`);
|
|
719
|
+
}
|
|
715
720
|
function announceStateHealth(s) {
|
|
716
721
|
const h = stateHealthNow();
|
|
717
722
|
if (h.ok) return;
|
|
@@ -2507,6 +2512,7 @@ function persist(s) {
|
|
|
2507
2512
|
// D75 — what model was asked for and what was APPLIED. Same `in`-guard as the profile: a record from before
|
|
2508
2513
|
// routing existed restores as 'not recorded', and writing a key for the past would be a lie about it.
|
|
2509
2514
|
if ('model' in s) { meta.model = s.model; meta.modelAsk = s.modelAsk || null; }
|
|
2515
|
+
if ('launchFlags' in s) meta.launchFlags = s.launchFlags;
|
|
2510
2516
|
// WHAT THIS SESSION IS FOR, and where that came from. Same `in`-guard as `profile` above and for
|
|
2511
2517
|
// the same reason: every session running before this field existed has no attribution, and
|
|
2512
2518
|
// writing a key for it would turn "we were never told" into "it belongs to nothing". Both keys
|
|
@@ -3290,7 +3296,7 @@ function sendToSession(id, text) {
|
|
|
3290
3296
|
try { firstInput.deliver(s, safeFirstInput(t) + '\r'); return true; } catch { return false; }
|
|
3291
3297
|
}
|
|
3292
3298
|
|
|
3293
|
-
function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, initiative, modelAsk, planItem, workType, autoDelete }) {
|
|
3299
|
+
function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, initiative, modelAsk, planItem, workType, autoDelete, launchFlags }) {
|
|
3294
3300
|
const id = nextSessionId();
|
|
3295
3301
|
/* pm-os-absorb Run 1 — every AGENT session gets a token, `<id>.<8 hex>`, in its environment as PMOS_SESSION, and
|
|
3296
3302
|
* PMOS_BIN, this workspace's own bin/pmos.js. `pmos item` run inside it is then attributed `agent:<token>` (the token
|
|
@@ -3355,7 +3361,14 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
3355
3361
|
base.model = { state: mres.state, tier: mres.tier || null, model: mres.model || null, effort: mres.effort || null,
|
|
3356
3362
|
decidedBy: mres.decidedBy || null, why: mres.why || null };
|
|
3357
3363
|
base.modelAsk = modelAsk || null;
|
|
3358
|
-
|
|
3364
|
+
/* LAUNCH FLAGS (workspace-launch-flags). Checked against the generated claude flag list, and refused like a model
|
|
3365
|
+
* choice: a flag that cannot be honoured starts nothing. The tokens go AFTER the model's, and a flag the model
|
|
3366
|
+
* chips already own is refused by the list itself, so the two never both speak. */
|
|
3367
|
+
const fres = claudeFlags.resolve(launchFlags, agent);
|
|
3368
|
+
if (!fres.ok) return exitedSession(base, workdir, `launch flag refused — ${fres.error}`);
|
|
3369
|
+
if (launchFlags !== undefined) base.launchFlags = fres.applied;
|
|
3370
|
+
const argv = [...(mres.state === 'applied' ? mres.args : []), ...fres.args];
|
|
3371
|
+
const launchArgs = argv;
|
|
3359
3372
|
if (mres.tier) {
|
|
3360
3373
|
const mc = modelLib.core();
|
|
3361
3374
|
if (mc.ok) mc.core.logDecision(root, { kind: 'spawn', session: id, agent, tier: mres.tier, model: mres.model, effort: mres.effort,
|
|
@@ -3396,6 +3409,8 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
3396
3409
|
// the OVERLAY, not the merged env — the merged env is for the direct-PTY path below, which
|
|
3397
3410
|
// really does spawn the process itself. See createTmuxSession for why the two differ.
|
|
3398
3411
|
// The launch args are plain tokens by construction (pmos-model's strict pattern), so joining them is safe.
|
|
3412
|
+
// tmux hands the command to a shell, so every token is quoted; the direct PTY below passes the raw argv
|
|
3413
|
+
const launchArgs = argv.map(claudeFlags.shellQuote);
|
|
3399
3414
|
if (!createTmuxSession(tname, launchArgs.length ? [cmd, ...launchArgs].join(' ') : cmd, workdir, 80, 24, itemEnv, sessionFlag.tmuxOverlay(profileOverlay(pres.profile)))) {
|
|
3400
3415
|
const why = `could not start a tmux session for '${cmd}'. Run \`npm run doctor\` in workspace/.`;
|
|
3401
3416
|
s.state = 'exited'; s.tmux = null; s.note = why;
|
|
@@ -3405,6 +3420,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
3405
3420
|
announceStateHealth(s); // the unmigrated notice comes FIRST — it explains an empty skills list
|
|
3406
3421
|
announceTree(s); // before attach, so the advisory is the first line of the pane's record
|
|
3407
3422
|
announceModel(s);
|
|
3423
|
+
announceLaunchFlags(s);
|
|
3408
3424
|
attachPty(s);
|
|
3409
3425
|
feed(s);
|
|
3410
3426
|
persist(s); pushSessions(); pushWorkspaces(); return s;
|
|
@@ -3460,7 +3476,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, ini
|
|
|
3460
3476
|
// A FAILED git operation in a real repo must never spawn a session on the wrong branch (rubric
|
|
3461
3477
|
// hard floor): we return {ok:false} and spawn nothing. Kept as its own function so the
|
|
3462
3478
|
// git-then-maybe-spawn contract is unit-testable without a WS client.
|
|
3463
|
-
function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile, initiative, modelAsk, origin, initialInput, planItem, workType }) {
|
|
3479
|
+
function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile, initiative, modelAsk, origin, initialInput, planItem, workType, launchFlags }) {
|
|
3464
3480
|
const w = getWs(ws);
|
|
3465
3481
|
if (!w) return { ok: false, error: 'no workspace' };
|
|
3466
3482
|
// The profile is checked BEFORE any git operation. A composer that picked a profile PMOS cannot
|
|
@@ -3471,6 +3487,9 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
|
|
|
3471
3487
|
// The model choice is checked on the same terms (D75): a refused choice starts nothing, and leaves no branch moved.
|
|
3472
3488
|
const mchk = modelLib.resolveForSpawn(w.root, agent || 'shell', modelAsk);
|
|
3473
3489
|
if (!mchk.ok) return { ok: false, error: `model choice refused — ${mchk.error}` };
|
|
3490
|
+
// …and so is a launch flag the list does not accept (workspace-launch-flags): before any branch moves.
|
|
3491
|
+
const fchk = claudeFlags.resolve(launchFlags, agent || 'shell');
|
|
3492
|
+
if (!fchk.ok) return { ok: false, error: `launch flag refused — ${fchk.error}` };
|
|
3474
3493
|
let targetWs = w.id;
|
|
3475
3494
|
let notice = null;
|
|
3476
3495
|
// Isolation exists to protect OTHER sessions. Alone in the working TREE there are none to
|
|
@@ -3507,7 +3526,7 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
|
|
|
3507
3526
|
broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) }); // let every client refresh the chip
|
|
3508
3527
|
}
|
|
3509
3528
|
}
|
|
3510
|
-
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id, initiative, modelAsk, origin, initialInput, planItem, workType });
|
|
3529
|
+
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id, initiative, modelAsk, origin, initialInput, planItem, workType, launchFlags });
|
|
3511
3530
|
// The co-tenancy fact, on the ordinary path too. Note where this sits: OUTSIDE the `if (branch)`
|
|
3512
3531
|
// block above. The isolate default already computes that other sessions would be disturbed — and
|
|
3513
3532
|
// then the whole git block is skipped when no branch was picked, which is the common path and the
|
|
@@ -3610,7 +3629,7 @@ function startPreview({ initiative, repoKey, ws }) {
|
|
|
3610
3629
|
// One click. Resolves the policy, then hands the SAME seam spawnWithBranch already exposes the
|
|
3611
3630
|
// branch/isolate decision it made — so the inherited hard floor (a failed git operation spawns
|
|
3612
3631
|
// nothing) is enforced in exactly one place and is not re-implemented here.
|
|
3613
|
-
function startWork({ initiative, repoKey, ws, agent, name, profile, origin, initialInput, planItem, workType, modelAsk }) {
|
|
3632
|
+
function startWork({ initiative, repoKey, ws, agent, name, profile, origin, initialInput, planItem, workType, modelAsk, launchFlags }) {
|
|
3614
3633
|
const key = repoKeyFor({ repoKey, ws });
|
|
3615
3634
|
if (!key) return { ok: false, error: 'no repository selected' };
|
|
3616
3635
|
const trees = treesOfRepo(key);
|
|
@@ -3639,7 +3658,7 @@ function startWork({ initiative, repoKey, ws, agent, name, profile, origin, init
|
|
|
3639
3658
|
// Hand to agent (pm-os-absorb Run 1): only /api/plan-items/handoff/go passes it.
|
|
3640
3659
|
planItem,
|
|
3641
3660
|
// 0.55.1 — the dock's new-session sheet: the type of work, and a skill's model ask.
|
|
3642
|
-
workType, modelAsk: modelAsk || null,
|
|
3661
|
+
workType, modelAsk: modelAsk || null, launchFlags,
|
|
3643
3662
|
});
|
|
3644
3663
|
if (!r.ok) return r;
|
|
3645
3664
|
return { ok: true, session: r.session, notice: r.notice || plan.why, plan: plan.plan };
|
|
@@ -6091,6 +6110,55 @@ async function handleRequest(req, res) {
|
|
|
6091
6110
|
error: read.error || null,
|
|
6092
6111
|
});
|
|
6093
6112
|
}
|
|
6113
|
+
// Every flag `claude --help` lists, with whether the form may offer it (workspace-launch-flags). A static file.
|
|
6114
|
+
if (p === '/api/claude-flags' && req.method === 'GET') return json(res, claudeFlags.list());
|
|
6115
|
+
/* Create one descriptor from the Settings form (workspace-launch-flags). It writes ONE file, ~/.claude/profiles/<id>.json,
|
|
6116
|
+
* and only when none by that name exists: PMOS never overwrites a descriptor the PM keeps. The file is read back through
|
|
6117
|
+
* the same reader every other surface uses, so a descriptor this route wrote that the reader would refuse is removed
|
|
6118
|
+
* again and the refusal is returned. The answer is profileWire(): the variable NAMES, never the values. */
|
|
6119
|
+
if (p === '/api/profiles/create' && req.method === 'POST') {
|
|
6120
|
+
let body = {};
|
|
6121
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
6122
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
6123
|
+
const bad = (error) => json(res, { ok: false, error }, 400);
|
|
6124
|
+
const id = typeof body.id === 'string' ? body.id.trim() : '';
|
|
6125
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(id)) return bad('the id must be lowercase letters, digits, dots, dashes or underscores, and at most 64 characters — it is the file name');
|
|
6126
|
+
const desc = { id };
|
|
6127
|
+
if (body.label != null && String(body.label).trim()) desc.label = String(body.label).trim().slice(0, 80);
|
|
6128
|
+
if (body.account != null && String(body.account).trim()) desc.account = String(body.account).trim().slice(0, 200);
|
|
6129
|
+
if (body.mcp != null) {
|
|
6130
|
+
if (!Array.isArray(body.mcp) || body.mcp.some((x) => typeof x !== 'string' || !/^[A-Za-z0-9][\w.-]{0,63}$/.test(x))) return bad('mcp must be a list of server names (letters, digits, dots, dashes, underscores)');
|
|
6131
|
+
if (body.mcp.length) desc.mcp = body.mcp.slice(0, 50);
|
|
6132
|
+
}
|
|
6133
|
+
if (body.env != null) {
|
|
6134
|
+
if (!body.env || typeof body.env !== 'object' || Array.isArray(body.env)) return bad('env must be an object of variable names and values');
|
|
6135
|
+
const env = {};
|
|
6136
|
+
for (const [k, v] of Object.entries(body.env)) {
|
|
6137
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(k)) return bad(`“${k.slice(0, 40)}” is not a valid environment variable name`);
|
|
6138
|
+
if (k === sessionFlag.FLAG) return bad(`${k} is set by the workspace itself and cannot come from a profile`);
|
|
6139
|
+
if (typeof v !== 'string' || v.length > 4000 || /\0/.test(v)) return bad(`the value for ${k} must be text of at most 4000 characters`);
|
|
6140
|
+
env[k] = v;
|
|
6141
|
+
}
|
|
6142
|
+
if (Object.keys(env).length) desc.env = env;
|
|
6143
|
+
}
|
|
6144
|
+
const dir = profilesLib.profilesDir(os.homedir());
|
|
6145
|
+
const file = path.join(dir, id + '.json');
|
|
6146
|
+
try {
|
|
6147
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
6148
|
+
fs.writeFileSync(file, JSON.stringify(desc, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
6149
|
+
} catch (e) {
|
|
6150
|
+
if (e && e.code === 'EEXIST') return bad(`a profile named “${id}” already exists — PMOS does not overwrite one`);
|
|
6151
|
+
return json(res, { ok: false, error: `the profile could not be written — ${String((e && e.code) || (e && e.message) || 'write failed').slice(0, 120)}` }, 500);
|
|
6152
|
+
}
|
|
6153
|
+
const read = readProfilesNow();
|
|
6154
|
+
const made = (read.profiles || []).find((x) => x.id === id);
|
|
6155
|
+
if (!made) {
|
|
6156
|
+
try { fs.unlinkSync(file); } catch { /* nothing more to do */ }
|
|
6157
|
+
const why = (read.broken || []).find((b) => b.file === id + '.json');
|
|
6158
|
+
return bad(`the profile was refused when read back — ${why ? why.error : 'it could not be read'}; nothing was kept`);
|
|
6159
|
+
}
|
|
6160
|
+
return json(res, { ok: true, profile: profileWire(made, read), path: file });
|
|
6161
|
+
}
|
|
6094
6162
|
if (p === '/api/profiles/prefer' && req.method === 'POST') {
|
|
6095
6163
|
let body = {};
|
|
6096
6164
|
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
@@ -8761,13 +8829,13 @@ wss.on('connection', (ws) => {
|
|
|
8761
8829
|
// 0.55.1 — a session started FROM a skill: the skill's agent, prompt and tier, resolved here and never sent.
|
|
8762
8830
|
const sk = m.skill ? skillLaunch(m.ws, m.skill) : null;
|
|
8763
8831
|
if (sk && !sk.ok) { ws.send(JSON.stringify({ t: 'error', error: 'skill: ' + sk.error })); return; }
|
|
8764
|
-
const r = spawnWithBranch({ name: m.name, agent: sk ? sk.agent : m.agent, cwd: m.cwd, ws: m.ws, branch: m.branch, createBranch: m.createBranch, isolate: m.isolate, profile: m.profile, initiative: wsBindable(ws, m.initiative),
|
|
8832
|
+
const r = spawnWithBranch({ name: m.name, agent: sk ? sk.agent : m.agent, cwd: m.cwd, ws: m.ws, branch: m.branch, createBranch: m.createBranch, isolate: m.isolate, profile: m.profile, initiative: wsBindable(ws, m.initiative), launchFlags: m.flags,
|
|
8765
8833
|
// D75 — what the composer chose (a tier, or a model), carried like the profile. Absent = not asked.
|
|
8766
8834
|
modelAsk: sk ? sk.modelAsk : ((m.tier || m.model) ? { explicit: { tier: m.tier, model: m.model, effort: m.effort } } : null),
|
|
8767
8835
|
initialInput: sk ? sk.initialInput : null, workType: m.workType });
|
|
8768
8836
|
// A refused profile is its own sentence, not "branch: …" — the composer's error line names
|
|
8769
8837
|
// what PMOS could not read, and nothing was started.
|
|
8770
|
-
if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: (/^(profile|model choice) /.test(r.error || '') ? '' : 'branch: ') + r.error })); return; }
|
|
8838
|
+
if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: (/^(profile|model choice|launch flag) /.test(r.error || '') ? '' : 'branch: ') + r.error })); return; }
|
|
8771
8839
|
// a non-fatal truth about what just happened (a worktree's path, a branch ignored on a
|
|
8772
8840
|
// non-repo) — said out loud, never swallowed
|
|
8773
8841
|
if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
|
|
@@ -8779,11 +8847,11 @@ wss.on('connection', (ws) => {
|
|
|
8779
8847
|
const sk = m.skill ? skillLaunch(m.ws, m.skill) : null;
|
|
8780
8848
|
if (sk && !sk.ok) { ws.send(JSON.stringify({ t: 'error', error: 'skill: ' + sk.error })); return; }
|
|
8781
8849
|
const r = startWork({ initiative: wsBindable(ws, m.initiative), repoKey: m.repoKey, ws: m.ws,
|
|
8782
|
-
agent: sk ? sk.agent : m.agent, name: m.name, profile: m.profile,
|
|
8850
|
+
agent: sk ? sk.agent : m.agent, name: m.name, profile: m.profile, launchFlags: m.flags,
|
|
8783
8851
|
initialInput: sk ? sk.initialInput : null, workType: m.workType,
|
|
8784
8852
|
// a blank session from the dock carries the Settings tier (D75); a skill's own tier wins
|
|
8785
8853
|
modelAsk: sk ? sk.modelAsk : (m.tier ? { explicit: { tier: m.tier } } : null) });
|
|
8786
|
-
if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: (/^profile /.test(r.error || '') ? '' : 'start: ') + r.error })); return; }
|
|
8854
|
+
if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: (/^(profile|launch flag) /.test(r.error || '') ? '' : 'start: ') + r.error })); return; }
|
|
8787
8855
|
if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
|
|
8788
8856
|
ws.send(JSON.stringify({ t: 'created', id: r.session.id }));
|
|
8789
8857
|
} else if (m.t === 'respawn') {
|
|
@@ -8792,7 +8860,7 @@ wss.on('connection', (ws) => {
|
|
|
8792
8860
|
// server already holds, not re-sent by the client — a client that forgot it would otherwise
|
|
8793
8861
|
// silently restart the pane on a different environment, and the pane would look identical.
|
|
8794
8862
|
const carried = 'profile' in old ? old.profile : null;
|
|
8795
|
-
const s = spawnSession({ name: old.name, agent: old.agent, cwd: path.relative(old.root, old.cwd), ws: old.ws, profile: carried, modelAsk: old.modelAsk || null, planItem: old.plan_item || null });
|
|
8863
|
+
const s = spawnSession({ name: old.name, agent: old.agent, cwd: path.relative(old.root, old.cwd), ws: old.ws, profile: carried, modelAsk: old.modelAsk || null, planItem: old.plan_item || null, launchFlags: Array.isArray(old.launchFlags) ? old.launchFlags.map((a) => ({ flag: a.flag, value: a.value })) : undefined });
|
|
8796
8864
|
// A descriptor deleted since the original spawn does NOT get substituted. The respawn goes
|
|
8797
8865
|
// ahead with no profile — the pane line already says so — and the reason is said out loud
|
|
8798
8866
|
// rather than left for the PM to notice.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "workspace-launch-flags: lib/claude-flags.js and the generated list it reads.",
|
|
3
|
+
"modules": [
|
|
4
|
+
"claude-flags.test.js",
|
|
5
|
+
"launch-flags.test.js"
|
|
6
|
+
],
|
|
7
|
+
"seconds": {
|
|
8
|
+
"claude-flags.test.js": 1,
|
|
9
|
+
"launch-flags.test.js": 25,
|
|
10
|
+
"pm-launch-flags-test.js": 20
|
|
11
|
+
},
|
|
12
|
+
"panels": [
|
|
13
|
+
"pm-launch-flags-test.js"
|
|
14
|
+
]
|
|
15
|
+
}
|