create-pmos 0.95.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 +4 -3
- package/workspace/lib/claude-flags.js +104 -0
- package/workspace/lib/claude-flags.json +992 -0
- package/workspace/lib/kit-update.js +48 -10
- 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 +108 -7
- 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 +22 -1
- package/workspace/public/pm/panel-newsess.js +85 -3
- package/workspace/public/pm/tab-term.js +3 -0
- package/workspace/server.js +82 -13
- package/workspace/test-files.json +4 -0
- package/workspace/test-lists/workspace-launch-flags.json +15 -0
|
@@ -34,6 +34,9 @@ import { state, setState, FIXTURE } from './state.js';
|
|
|
34
34
|
import * as FX from './fixture.js';
|
|
35
35
|
import { openTerm as openTermTab } from './panel-initiatives.js';
|
|
36
36
|
import { startHere } from './panel-trees.js';
|
|
37
|
+
import { sessionDefaults } from './dock.js';
|
|
38
|
+
import { button } from './primitives.js';
|
|
39
|
+
import { flagPicker, flagsLine, flagsMissingValue, flagsWire } from './flag-picker.js';
|
|
37
40
|
|
|
38
41
|
/* Both are another module's file, loaded lazily for the reason panel-tabs.js gives: a static import
|
|
39
42
|
* of a file that fails to load takes this whole module down with it, and a panel that cannot draw
|
|
@@ -124,6 +127,8 @@ export function openNewSession(slug, seed) {
|
|
|
124
127
|
// Cleared on the way IN as well as on the way out: a form that opened still showing the profile
|
|
125
128
|
// a previous, abandoned question had chosen would be proposing an answer nobody gave.
|
|
126
129
|
preflightProfile: s.profile === undefined ? null : s.profile,
|
|
130
|
+
// the Settings defaults, for a Claude session (workspace-launch-flags)
|
|
131
|
+
newSessFlags: (s.agent || 'shell') === 'claude' ? sessionDefaults().flags.map((x) => ({ ...x })) : [], newSessFlagFilter: '',
|
|
127
132
|
});
|
|
128
133
|
refreshReads();
|
|
129
134
|
}
|
|
@@ -139,7 +144,7 @@ export function openNewSession(slug, seed) {
|
|
|
139
144
|
* other half is that `+ session` no longer reads this at all (panel-tabs.js).
|
|
140
145
|
*/
|
|
141
146
|
export function closeNewSession() {
|
|
142
|
-
setState({ newSess: null, newSessError: null, newSessBusy: false, preflightProfile: null });
|
|
147
|
+
setState({ newSess: null, newSessError: null, newSessBusy: false, preflightProfile: null, newSessFlags: [], newSessFlagFilter: '' });
|
|
143
148
|
}
|
|
144
149
|
|
|
145
150
|
export const isNewSessionOpen = () => !!state.newSess;
|
|
@@ -174,14 +179,18 @@ function refreshReads() {
|
|
|
174
179
|
if (typeof dataMod.loadBranches === 'function') {
|
|
175
180
|
dataMod.loadBranches(ws).then((r) => setState({ newSessBranches: r || null })).catch(() => {});
|
|
176
181
|
}
|
|
182
|
+
if (typeof dataMod.loadClaudeFlags === 'function') {
|
|
183
|
+
dataMod.loadClaudeFlags().then((r) => setState({ claudeFlags: r || null })).catch(() => {});
|
|
184
|
+
}
|
|
177
185
|
if (typeof dataMod.loadProfiles === 'function') {
|
|
178
186
|
dataMod.loadProfiles().then((r) => {
|
|
179
187
|
setState({ profiles: r || null });
|
|
180
188
|
// Preselect from the server's remembered preference — never from a hardcoded name. A
|
|
181
189
|
// preference naming a descriptor that no longer reads preselects NOTHING rather than
|
|
182
190
|
// substituting another, which is the same rule the server applies to the preference itself.
|
|
183
|
-
const pref = r && r.prefs ? r.prefs[state.newSessAgent] : null;
|
|
184
191
|
const live = (r && Array.isArray(r.profiles) ? r.profiles : []).map((p) => p && p.id);
|
|
192
|
+
const own = state.newSessAgent === 'claude' ? sessionDefaults().profile : null;
|
|
193
|
+
const pref = own && live.includes(own) ? own : (r && r.prefs ? r.prefs[state.newSessAgent] : null);
|
|
185
194
|
if (pref && live.includes(pref) && !state.preflightProfile) setState({ preflightProfile: pref });
|
|
186
195
|
}).catch(() => {});
|
|
187
196
|
}
|
|
@@ -377,6 +386,59 @@ function modelSection() {
|
|
|
377
386
|
: 'a ' + cur + ' tier — the model and effort it maps to are in your models policy')));
|
|
378
387
|
}
|
|
379
388
|
|
|
389
|
+
/* ── agent change ──────────────────────────────────────────────────────────────────────────────
|
|
390
|
+
* The remembered default is per agent, so a different agent preselects its own — but only when the PM has
|
|
391
|
+
* not chosen something else since the form opened. Launch flags belong to a claude session; leaving
|
|
392
|
+
* claude clears them rather than carrying them silently to a binary that may not know them. */
|
|
393
|
+
function defaultFor(agent) {
|
|
394
|
+
const p = state.profiles;
|
|
395
|
+
const live = (p && Array.isArray(p.profiles) ? p.profiles : []).map((x) => x && x.id);
|
|
396
|
+
// Settings holds the Claude default; the server's per-agent memory covers every other agent
|
|
397
|
+
const fromSettings = agent === 'claude' ? sessionDefaults().profile : null;
|
|
398
|
+
if (fromSettings && live.includes(fromSettings)) return fromSettings;
|
|
399
|
+
const pref = p && p.prefs ? p.prefs[agent] : null;
|
|
400
|
+
return pref && live.includes(pref) ? pref : null;
|
|
401
|
+
}
|
|
402
|
+
function pickAgent(a) {
|
|
403
|
+
const before = state.newSessAgent;
|
|
404
|
+
const untouched = !state.preflightProfile || state.preflightProfile === defaultFor(before);
|
|
405
|
+
setState({
|
|
406
|
+
newSessAgent: a, newSessError: null,
|
|
407
|
+
...(untouched ? { preflightProfile: defaultFor(a) } : {}),
|
|
408
|
+
...(a !== 'claude' ? { newSessFlags: [] } : (before !== 'claude' && !(state.newSessFlags || []).length ? { newSessFlags: sessionDefaults().flags.map((x) => ({ ...x })) } : {})),
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Remember the chosen profile (or “no profile”) as this agent's default for the next form. */
|
|
413
|
+
async function setDefault() {
|
|
414
|
+
if (!dataMod || typeof dataMod.preferProfile !== 'function') return;
|
|
415
|
+
const r = await dataMod.preferProfile(state.newSessAgent || 'shell', state.preflightProfile === undefined ? null : state.preflightProfile);
|
|
416
|
+
if (!r || !r.ok) { setState({ newSessError: (r && r.error) || 'the default could not be saved' }); return; }
|
|
417
|
+
setState((s) => ({ newSessError: null, profiles: s.profiles ? { ...s.profiles, prefs: r.prefs || {} } : s.profiles }));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/* ── the launch flags ───────────────────────────────────────────────────────────────────────────
|
|
421
|
+
* Every flag `claude --help` lists, from the server's generated file. A flag PMOS answers itself
|
|
422
|
+
* (--model, --worktree, --name …) or one that needs --print is shown, greyed, with the reason — never
|
|
423
|
+
* silently missing and never sendable. A flag that takes a value gets an input once it is switched on. */
|
|
424
|
+
function flagsSection() {
|
|
425
|
+
if (state.newSessAgent !== 'claude') {
|
|
426
|
+
return el('div', { 'data-flags': 'note', class: 'fr-fp-sub' }, iv('launch flags: only a Claude session takes flags — a shell, or any other CLI, starts as it is'));
|
|
427
|
+
}
|
|
428
|
+
const chosen = state.newSessFlags || [];
|
|
429
|
+
const r = state.claudeFlags;
|
|
430
|
+
const missing = r ? flagsMissingValue(r, chosen) : [];
|
|
431
|
+
return el('div', { class: 'fr-fp-stack', 'data-flags': 'section' },
|
|
432
|
+
el('div', { class: 'fr-fp-label' }, 'launch flags'),
|
|
433
|
+
el('div', { class: 'fr-fp-sub', 'data-flags': 'summary' }, iv(chosen.length
|
|
434
|
+
? 'will start with: claude ' + flagsLine(chosen)
|
|
435
|
+
: 'none chosen — claude starts with its own defaults' + (r && r.cli_version ? '. From claude ' + r.cli_version + ' --help.' : ''))),
|
|
436
|
+
missing.length ? el('div', { class: 'fr-fp-warn', 'data-flags': 'missing' }, iv(missing[0] + ' needs a value before the session can start.')) : null,
|
|
437
|
+
flagPicker({ list: r, chosen, filter: state.newSessFlagFilter,
|
|
438
|
+
onChange: (next) => setState({ newSessFlags: next, newSessError: null }),
|
|
439
|
+
onFilter: (q) => setState({ newSessFlagFilter: q }) }));
|
|
440
|
+
}
|
|
441
|
+
|
|
380
442
|
/* ── the profile chips ─────────────────────────────────────────────────────────────────────── */
|
|
381
443
|
|
|
382
444
|
/**
|
|
@@ -426,6 +488,21 @@ function profileSection() {
|
|
|
426
488
|
iv((b.file || '?').replace(/\.json$/, '') + ' — unusable'))),
|
|
427
489
|
));
|
|
428
490
|
|
|
491
|
+
{
|
|
492
|
+
const agent = state.newSessAgent || 'shell';
|
|
493
|
+
const def = defaultFor(agent);
|
|
494
|
+
const chosen = state.preflightProfile || null;
|
|
495
|
+
out.push(el('div', { class: 'fr-fp-defrow', 'data-profile': 'default' },
|
|
496
|
+
button({
|
|
497
|
+
label: chosen === def ? 'default for ' + agent : 'make ' + (chosen || 'no profile') + ' the default for ' + agent,
|
|
498
|
+
icon: chosen === def ? 'check' : undefined, size: 'sm', onClick: () => setDefault(), disabled: chosen === def,
|
|
499
|
+
attrs: { 'data-profile-default': 'set' },
|
|
500
|
+
}),
|
|
501
|
+
el('span', { class: 'fr-fp-sub' }, iv(def
|
|
502
|
+
? 'new ' + agent + ' sessions start with “' + def + '” unless you pick another here'
|
|
503
|
+
: 'no default is set — new ' + agent + ' sessions start with no profile'))));
|
|
504
|
+
}
|
|
505
|
+
|
|
429
506
|
if (p.absent) {
|
|
430
507
|
out.push(el('div', { style: SUB }, iv('no profile directory at ' + (p.dir || '?')
|
|
431
508
|
+ ' — nothing was read, so nothing is offered but “no profile”')));
|
|
@@ -490,6 +567,9 @@ async function onStart() {
|
|
|
490
567
|
return;
|
|
491
568
|
}
|
|
492
569
|
|
|
570
|
+
const missingVal = state.newSessAgent === 'claude' && state.claudeFlags ? flagsMissingValue(state.claudeFlags, state.newSessFlags) : [];
|
|
571
|
+
if (missingVal.length) { setState({ newSessError: missingVal[0] + ' needs a value, or switch it off — nothing was started' }); return; }
|
|
572
|
+
|
|
493
573
|
setState({ newSessBusy: true, newSessError: null });
|
|
494
574
|
const res = await termMod.requestSpawn({
|
|
495
575
|
name,
|
|
@@ -503,6 +583,7 @@ async function onStart() {
|
|
|
503
583
|
isolate: branch ? isolateNow() : null,
|
|
504
584
|
profile: state.preflightProfile === undefined ? null : state.preflightProfile,
|
|
505
585
|
// D75 — only for a Claude session; 'auto' sends nothing and lets the policy route.
|
|
586
|
+
flags: state.newSessAgent === 'claude' ? flagsWire(state.newSessFlags) : undefined,
|
|
506
587
|
tier: (state.newSessAgent === 'claude' && state.newSessTier && state.newSessTier !== 'auto') ? state.newSessTier : undefined,
|
|
507
588
|
});
|
|
508
589
|
|
|
@@ -584,13 +665,14 @@ export function newSessionBlock() {
|
|
|
584
665
|
el('div', { style: LABEL }, 'agent'),
|
|
585
666
|
el('div', { style: ROW },
|
|
586
667
|
['shell', 'claude'].map((a) => el('button', {
|
|
587
|
-
onClick: () =>
|
|
668
|
+
onClick: () => pickAgent(a),
|
|
588
669
|
style: state.newSessAgent === a ? CHIP_ON : CHIP, hover: HOVER,
|
|
589
670
|
}, iv(a))),
|
|
590
671
|
el('span', { style: SUB }, 'a CLI that is not on PATH is refused by the server, and says so'),
|
|
591
672
|
),
|
|
592
673
|
|
|
593
674
|
modelSection(),
|
|
675
|
+
el('div', { class: 'fr-fp-stack', 'data-r29': 'newsess-flags' }, flagsSection()),
|
|
594
676
|
el('div', { style: 'display:flex;flex-direction:column;gap:8px', 'data-r29': 'newsess-where' }, branchSection()),
|
|
595
677
|
el('div', { style: 'display:flex;flex-direction:column;gap:8px', 'data-r29': 'newsess-profile' }, profileSection()),
|
|
596
678
|
|
|
@@ -252,6 +252,7 @@ export function requestSpawn(opts) {
|
|
|
252
252
|
...(o.skill ? { skill: o.skill } : {}),
|
|
253
253
|
...(o.workType ? { workType: o.workType } : {}),
|
|
254
254
|
...(o.tier ? { tier: o.tier } : {}),
|
|
255
|
+
...(Array.isArray(o.flags) && o.flags.length ? { flags: o.flags } : {}),
|
|
255
256
|
} : {
|
|
256
257
|
t: 'new',
|
|
257
258
|
...(o.skill ? { skill: o.skill } : {}),
|
|
@@ -272,6 +273,8 @@ export function requestSpawn(opts) {
|
|
|
272
273
|
profile: o.profile === undefined ? null : o.profile,
|
|
273
274
|
// D75 — a tier the PM picked (fast | balanced | deep). Absent = "auto": the server routes by policy, or asks for nothing.
|
|
274
275
|
...(o.tier ? { tier: o.tier } : {}),
|
|
276
|
+
// workspace-launch-flags — `[{flag, value?}]`, checked by the server against the generated claude flag list.
|
|
277
|
+
...(Array.isArray(o.flags) && o.flags.length ? { flags: o.flags } : {}),
|
|
275
278
|
},
|
|
276
279
|
};
|
|
277
280
|
SOCK.spawnQueue.push(req);
|
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)) || '{}'); }
|
|
@@ -7261,7 +7329,7 @@ async function handleRequest(req, res) {
|
|
|
7261
7329
|
if (!w) return json(res, { ok: false, error: 'no such workspace' }, 400);
|
|
7262
7330
|
const st = () => kitUpdate.status({ root: w.root, appVersion: APP_VERSION, appDir: __dirname });
|
|
7263
7331
|
if (p === '/api/kit/check') {
|
|
7264
|
-
const r = await kitUpdate.latest();
|
|
7332
|
+
const r = await kitUpdate.latest({ source: kitUpdate.source() });
|
|
7265
7333
|
const now = st();
|
|
7266
7334
|
if (!r.ok) return json(res, { ok: false, error: r.error, ws: w.id, ...now }, 502);
|
|
7267
7335
|
return json(res, { ok: true, ws: w.id, ...now, latest: r.version, ...kitUpdate.compare(now, r.version) });
|
|
@@ -7283,7 +7351,8 @@ async function handleRequest(req, res) {
|
|
|
7283
7351
|
tokenKept: !!TOKEN && !BIND.generated,
|
|
7284
7352
|
});
|
|
7285
7353
|
if (!where.ok) return json(res, { ok: false, error: where.error }, 403);
|
|
7286
|
-
|
|
7354
|
+
// A local source (PMOS_UPDATE_SOURCE) is how a checkout tries an update, so only the npm source is refused there.
|
|
7355
|
+
if (st().app.fromCheckout && st().source.kind !== 'local') return json(res, { ok: false, error: 'This workspace runs from a PMOS checkout. Update it with git pull' }, 409);
|
|
7287
7356
|
const r = kitUpdate.upgradeApp(w.root, { version: body.version, logFile: path.join(STATE, 'app-update.log') });
|
|
7288
7357
|
if (!r.ok) return json(res, { ok: false, error: r.error }, 400);
|
|
7289
7358
|
kitBusy = 'app';
|
|
@@ -8760,13 +8829,13 @@ wss.on('connection', (ws) => {
|
|
|
8760
8829
|
// 0.55.1 — a session started FROM a skill: the skill's agent, prompt and tier, resolved here and never sent.
|
|
8761
8830
|
const sk = m.skill ? skillLaunch(m.ws, m.skill) : null;
|
|
8762
8831
|
if (sk && !sk.ok) { ws.send(JSON.stringify({ t: 'error', error: 'skill: ' + sk.error })); return; }
|
|
8763
|
-
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,
|
|
8764
8833
|
// D75 — what the composer chose (a tier, or a model), carried like the profile. Absent = not asked.
|
|
8765
8834
|
modelAsk: sk ? sk.modelAsk : ((m.tier || m.model) ? { explicit: { tier: m.tier, model: m.model, effort: m.effort } } : null),
|
|
8766
8835
|
initialInput: sk ? sk.initialInput : null, workType: m.workType });
|
|
8767
8836
|
// A refused profile is its own sentence, not "branch: …" — the composer's error line names
|
|
8768
8837
|
// what PMOS could not read, and nothing was started.
|
|
8769
|
-
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; }
|
|
8770
8839
|
// a non-fatal truth about what just happened (a worktree's path, a branch ignored on a
|
|
8771
8840
|
// non-repo) — said out loud, never swallowed
|
|
8772
8841
|
if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
|
|
@@ -8778,11 +8847,11 @@ wss.on('connection', (ws) => {
|
|
|
8778
8847
|
const sk = m.skill ? skillLaunch(m.ws, m.skill) : null;
|
|
8779
8848
|
if (sk && !sk.ok) { ws.send(JSON.stringify({ t: 'error', error: 'skill: ' + sk.error })); return; }
|
|
8780
8849
|
const r = startWork({ initiative: wsBindable(ws, m.initiative), repoKey: m.repoKey, ws: m.ws,
|
|
8781
|
-
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,
|
|
8782
8851
|
initialInput: sk ? sk.initialInput : null, workType: m.workType,
|
|
8783
8852
|
// a blank session from the dock carries the Settings tier (D75); a skill's own tier wins
|
|
8784
8853
|
modelAsk: sk ? sk.modelAsk : (m.tier ? { explicit: { tier: m.tier } } : null) });
|
|
8785
|
-
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; }
|
|
8786
8855
|
if (r.notice) ws.send(JSON.stringify({ t: 'notice', notice: r.notice }));
|
|
8787
8856
|
ws.send(JSON.stringify({ t: 'created', id: r.session.id }));
|
|
8788
8857
|
} else if (m.t === 'respawn') {
|
|
@@ -8791,7 +8860,7 @@ wss.on('connection', (ws) => {
|
|
|
8791
8860
|
// server already holds, not re-sent by the client — a client that forgot it would otherwise
|
|
8792
8861
|
// silently restart the pane on a different environment, and the pane would look identical.
|
|
8793
8862
|
const carried = 'profile' in old ? old.profile : null;
|
|
8794
|
-
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 });
|
|
8795
8864
|
// A descriptor deleted since the original spawn does NOT get substituted. The respawn goes
|
|
8796
8865
|
// ahead with no profile — the pane line already says so — and the reason is said out loud
|
|
8797
8866
|
// rather than left for the PM to notice.
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"test.js"
|
|
9
9
|
],
|
|
10
10
|
"modules": [
|
|
11
|
+
"report-template-cadence.test.js",
|
|
11
12
|
"proactive.test.js",
|
|
12
13
|
"goals.test.js",
|
|
13
14
|
"agenda.test.js",
|
|
@@ -100,6 +101,7 @@
|
|
|
100
101
|
"pm-r29-skills-scheduled-test.js",
|
|
101
102
|
"pm-r29-validate-guide-test.js",
|
|
102
103
|
"pm-r29-reports-addrepo-test.js",
|
|
104
|
+
"pm-report-tab-test.js",
|
|
103
105
|
"pm-r29-filespad-test.js",
|
|
104
106
|
"pm-r29-newsession-alignment-test.js",
|
|
105
107
|
"pm-live-notes-test.js",
|
|
@@ -199,6 +201,8 @@
|
|
|
199
201
|
"pm-r29-skills-scheduled-test.js": 6,
|
|
200
202
|
"pm-r29-validate-guide-test.js": 20,
|
|
201
203
|
"pm-r29-reports-addrepo-test.js": 4,
|
|
204
|
+
"pm-report-tab-test.js": 10,
|
|
205
|
+
"report-template-cadence.test.js": 1,
|
|
202
206
|
"pm-r29-filespad-test.js": 5,
|
|
203
207
|
"pm-r29-newsession-alignment-test.js": 8,
|
|
204
208
|
"pm-live-notes-test.js": 24,
|
|
@@ -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
|
+
}
|