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.
@@ -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
- const launchArgs = mres.state === 'applied' ? mres.args : [];
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
+ }