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.
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * readKit(root) → { present:false } | { present:true, version, kernelSource, installed, mode }
5
5
  * status({ root, appVersion, appDir }) → { app: { version, fromCheckout }, kit } local only, no network
6
- * latest({ exec }) → Promise<{ ok, version } | { ok:false, error }> npm view create-pmos version
6
+ * source(env) → { kind:'npm' } | { kind:'local', path } | { kind:'local', path, error }
7
+ * latest({ exec, source }) → Promise<{ ok, version } | { ok:false, error }> npm view create-pmos version
7
8
  * compare(st, latestVersion) → { appBehind, kitBehind }
8
9
  * updateKit(root, { version, exec }) → Promise<{ ok, output, kit } | { ok:false, error, output }>
9
10
  * upgradeApp(root, { version, logFile, spawn, env }) → { ok, log } | { ok:false, error }
@@ -14,6 +15,10 @@
14
15
  * check npm view create-pmos version
15
16
  * kit npx -y create-pmos@<version> --update <repo> (the kernel only; state/ and planning/ untouched)
16
17
  * app npx -y create-pmos@<version> workspace --reinstall <repo> (what `pmos upgrade` runs)
18
+ * LOCAL SOURCE. PMOS_UPDATE_SOURCE names a built create-pmos package on this computer: its folder (with package.json, bin/
19
+ * and payload/ or workspace/) or its .tgz from `npm pack`. The check then reads that package's version instead of asking
20
+ * the registry, and the update runs `npx -y <that path> ...` in place of `npx -y create-pmos@<version> ...`. Nothing
21
+ * reaches the network, so an update can be tried before it is published. Unset means npm (the default).
17
22
  * The version is the one the check found, so what is installed is what the PM was shown, not whatever `latest` is a
18
23
  * minute later. The app upgrade stops this server (create-pmos runs `pmos stop`), so it runs DETACHED, writes its output
19
24
  * to a log file, and the page waits for the server to come back.
@@ -81,8 +86,34 @@ function fromCheckout(appDir) {
81
86
  try { return fs.existsSync(path.join(String(appDir), '..', '.git')); } catch { return false; }
82
87
  }
83
88
 
84
- function status({ root, appVersion, appDir }) {
85
- return { app: { version: appVersion || null, fromCheckout: fromCheckout(appDir) }, kit: readKit(root) };
89
+ /**
90
+ * Where "latest" comes from. Unset or "npm" is the registry. Anything else is a path to a built create-pmos package: a
91
+ * folder or a .tgz. `error` names what is wrong with it, so the page can say so instead of silently using npm.
92
+ */
93
+ function source(env = process.env) {
94
+ const raw = String((env && env.PMOS_UPDATE_SOURCE) || '').trim();
95
+ if (!raw || raw.toLowerCase() === 'npm') return { kind: 'npm' };
96
+ const p = path.resolve(raw);
97
+ let st;
98
+ try { st = fs.statSync(p); } catch { return { kind: 'local', path: p, error: `PMOS_UPDATE_SOURCE points to ${p}, which does not exist` }; }
99
+ if (st.isDirectory()) {
100
+ let pkg;
101
+ try { pkg = JSON.parse(fs.readFileSync(path.join(p, 'package.json'), 'utf8')); } catch { pkg = null; }
102
+ if (!pkg || pkg.name !== PKG_NAME) return { kind: 'local', path: p, error: `${p} is not a ${PKG_NAME} package folder (no package.json named ${PKG_NAME})` };
103
+ if (!fs.existsSync(path.join(p, 'payload')) && !fs.existsSync(path.join(p, 'workspace'))) return { kind: 'local', path: p, error: `${p} is not a built package: it has no payload/ or workspace/ folder. Point PMOS_UPDATE_SOURCE at the .tgz from \`npm pack\` instead` };
104
+ return { kind: 'local', path: p, version: validVersion(pkg.version) ? pkg.version : null };
105
+ }
106
+ const m = /^create-pmos-(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\.tgz$/.exec(path.basename(p));
107
+ if (!m) return { kind: 'local', path: p, error: `${p} is not a create-pmos-<version>.tgz file` };
108
+ return { kind: 'local', path: p, version: m[1] };
109
+ }
110
+
111
+ /** What npx installs: the registry package at a version, or the local path. */
112
+ const specOf = (src, version) => (src && src.kind === 'local' ? src.path : `${PKG_NAME}@${version}`);
113
+
114
+ function status({ root, appVersion, appDir, env }) {
115
+ const src = source(env);
116
+ return { app: { version: appVersion || null, fromCheckout: fromCheckout(appDir) }, kit: readKit(root), source: src.kind === 'local' ? { kind: 'local', path: src.path, error: src.error || null } : { kind: 'npm' } };
86
117
  }
87
118
 
88
119
  function compare(st, latestVersion) {
@@ -115,7 +146,12 @@ function why(r, what) {
115
146
  return `${what} did not finish${said ? ': ' + said : ''}`;
116
147
  }
117
148
 
118
- async function latest({ exec = cp.execFile } = {}) {
149
+ async function latest({ exec = cp.execFile, source: src = source() } = {}) {
150
+ if (src.kind === 'local') {
151
+ if (src.error) return { ok: false, error: src.error };
152
+ if (!src.version) return { ok: false, error: `${src.path} has no version number` };
153
+ return { ok: true, version: src.version, source: 'local' };
154
+ }
119
155
  const r = await run(exec, NPM, ['view', PKG_NAME, 'version', '--json'], { timeout: CHECK_TIMEOUT_MS });
120
156
  const bad = why(r, 'The check');
121
157
  if (bad) return { ok: false, error: bad };
@@ -135,10 +171,11 @@ function notServedYet(text, version) {
135
171
  }
136
172
  const notServedYetError = (version) => `npm lists ${PKG_NAME} ${version}, but its download is not available yet. A version this new can take up to five minutes to reach every npm server. Press Update again in five minutes`;
137
173
 
138
- async function updateKit(root, { version, exec = cp.execFile } = {}) {
174
+ async function updateKit(root, { version, exec = cp.execFile, source: src = source() } = {}) {
139
175
  if (!validVersion(version)) return { ok: false, error: 'Check for updates first' };
176
+ if (src.kind === 'local' && src.error) return { ok: false, error: src.error };
140
177
  if (!readKit(root).present) return { ok: false, error: 'This repository has no PMOS kit folder (pmos/KIT-VERSION)' };
141
- const r = await run(exec, NPX, ['-y', `${PKG_NAME}@${version}`, '--update', String(root)], { timeout: UPDATE_TIMEOUT_MS, cwd: String(root) });
178
+ const r = await run(exec, NPX, ['-y', specOf(src, version), '--update', String(root)], { timeout: UPDATE_TIMEOUT_MS, cwd: String(root) });
142
179
  const output = tail(r.stdout + (r.stderr ? '\n' + r.stderr : ''));
143
180
  if (r.err && notServedYet(r.stdout + r.stderr, version)) return { ok: false, error: notServedYetError(version), output };
144
181
  const bad = why(r, 'The kit update');
@@ -175,15 +212,16 @@ function updateEnv(env = process.env, nodePath = process.execPath) {
175
212
  return parts.includes(dir) ? { ...env } : { ...env, [key]: [...parts, dir].join(path.delimiter) };
176
213
  }
177
214
 
178
- function upgradeApp(root, { version, logFile, spawn = cp.spawn, env = updateEnv() } = {}) {
215
+ function upgradeApp(root, { version, logFile, spawn = cp.spawn, env = updateEnv(), source: src = source() } = {}) {
179
216
  if (!validVersion(version)) return { ok: false, error: 'Check for updates first' };
217
+ if (src.kind === 'local' && src.error) return { ok: false, error: src.error };
180
218
  let fd;
181
219
  try {
182
220
  fs.mkdirSync(path.dirname(logFile), { recursive: true });
183
221
  fd = fs.openSync(logFile, 'w');
184
222
  fs.writeSync(fd, `${new Date().toISOString()} clearing npm cache…\n`);
185
223
  fs.writeSync(fd, `${new Date().toISOString()} ${NPM} cache clean --force\n`);
186
- fs.writeSync(fd, `${new Date().toISOString()} ${NPX} -y ${PKG_NAME}@${version} workspace --reinstall ${root}\n`);
224
+ fs.writeSync(fd, `${new Date().toISOString()} ${NPX} -y ${specOf(src, version)} workspace --reinstall ${root}\n`);
187
225
  } catch (e) { return { ok: false, error: `Could not write the log file: ${e.message}` }; }
188
226
  try {
189
227
  // Clear npm cache synchronously before spawning the update, so npx downloads the fresh version
@@ -192,7 +230,7 @@ function upgradeApp(root, { version, logFile, spawn = cp.spawn, env = updateEnv(
192
230
  try { fs.appendFileSync(logFile, `Note: cache clear had an issue, but continuing: ${e.message}\n`); } catch { /* silent */ }
193
231
  }
194
232
  try {
195
- const child = spawn(NPX, ['-y', `${PKG_NAME}@${version}`, 'workspace', '--reinstall', String(root)],
233
+ const child = spawn(NPX, ['-y', specOf(src, version), 'workspace', '--reinstall', String(root)],
196
234
  { cwd: String(root), env, detached: true, stdio: ['ignore', fd, fd], windowsHide: true, shell: IS_WIN });
197
235
  // A child that could not start writes nothing itself: say why in the log, which the page names when it gives up.
198
236
  if (child && typeof child.on === 'function') {
@@ -207,4 +245,4 @@ function upgradeApp(root, { version, logFile, spawn = cp.spawn, env = updateEnv(
207
245
  }
208
246
  }
209
247
 
210
- module.exports = { PKG_NAME, cmpVer, notServedYet, validVersion, readKit, fromCheckout, status, compare, latest, updateKit, updateEnv, upgradeApp, whereFrom };
248
+ module.exports = { PKG_NAME, cmpVer, notServedYet, validVersion, source, specOf, readKit, fromCheckout, status, compare, latest, updateKit, updateEnv, upgradeApp, whereFrom };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.95.0",
3
+ "version": "0.97.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.95.0",
9
+ "version": "0.97.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.95.0",
3
+ "version": "0.97.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -3381,6 +3381,28 @@
3381
3381
  margin-left: -8px;
3382
3382
  }
3383
3383
 
3384
+ /* launch-flag picker (flag-picker.js) and the profile form: new-session defaults on Settings, the flag list on the New session form */
3385
+ .fr-fp { display: flex; flex-direction: column; gap: 8px; min-width: 0; width: 100%; }
3386
+ .fr-fp-chips { display: flex; flex-wrap: wrap; gap: 6px; }
3387
+ .fr-fp-chips .fr-fp-chip { font-family: var(--mono); font-weight: 400; }
3388
+ .fr-fp-chips .fr-fp-chip[aria-pressed="true"] { background: var(--accent); border-color: var(--foreground); }
3389
+ .fr-fp-list { max-height: 320px; overflow: auto; border: 1px solid var(--border); border-radius: 8px; background: var(--background); }
3390
+ .fr-fp-row { display: flex; flex-direction: column; gap: 2px; padding: 8px 10px; border-bottom: 1px solid var(--border); min-width: 0; }
3391
+ .fr-fp-row:last-child { border-bottom: 0; }
3392
+ .fr-fp-row--off { opacity: 0.55; }
3393
+ .fr-fp-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-family: var(--mono); font-size: 12px; }
3394
+ .fr-fp-val { display: flex; flex-direction: column; gap: 6px; padding-left: 24px; max-width: 420px; }
3395
+ .fr-fp-stack { display: flex; flex-direction: column; gap: 8px; }
3396
+ .fr-fp-sub { font-size: 12px; color: var(--muted-foreground); line-height: 1.5; }
3397
+ .fr-fp-label { font-family: var(--mono); font-size: 11px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted-foreground); }
3398
+ .fr-fp-warn { font-size: 12px; line-height: 1.5; color: var(--warning); overflow-wrap: anywhere; }
3399
+ .fr-fp-defrow { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
3400
+ .fr-fp-note { margin: 0; color: var(--muted-foreground); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
3401
+ .fr-fp-bad { color: var(--destructive); }
3402
+ .fr-set__form { display: flex; flex-direction: column; gap: 10px; padding: 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--background); }
3403
+ .fr-set__envrows { display: flex; flex-direction: column; gap: 6px; }
3404
+ .fr-set__flags { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; min-width: 0; width: 100%; }
3405
+
3384
3406
  /* -- layouts · .td- Today (Run 3, slice 3B) -- */
3385
3407
  .td-board {
3386
3408
  display: flex;
@@ -1237,6 +1237,12 @@ export async function loadProfiles() {
1237
1237
  return getJson('/api/profiles');
1238
1238
  }
1239
1239
 
1240
+ /** Every flag `claude --help` lists, each marked `offered` or not with the reason. A static file on the server. */
1241
+ export async function loadClaudeFlags() {
1242
+ if (FIXTURE) return { ok: false, error: 'fixture mode reads no flag list', flags: [] };
1243
+ return getJson('/api/claude-flags');
1244
+ }
1245
+
1240
1246
  /** Remember the profile this agent should preselect next time. The server refuses a preference for
1241
1247
  * a descriptor it cannot read, so a stored id always resolves to a chip that is on screen. */
1242
1248
  export async function preferProfile(agent, profile) {
@@ -32,6 +32,7 @@ import { sessionDefaults, setSessionDefaults } from './dock.js';
32
32
  import { COPY } from './frame-copy.js';
33
33
  import { askConfirm } from './dialogs.js';
34
34
  import { icon } from './icons.js';
35
+ import { flagPicker, flagsLine, flagsMissingValue } from './flag-picker.js';
35
36
 
36
37
  const P = COPY.page;
37
38
  const roots = new WeakMap(); // host -> { root, sig }
@@ -178,13 +179,84 @@ function profilesSection() {
178
179
  icon: 'ellipsis', ariaLabel: P.more(P.profilesRow), attrs: { 'data-fr': 'profiles-more' },
179
180
  onClick: () => menu({ anchor: more, ariaLabel: P.more(P.profilesRow), items: [{ label: P.profilesOpen, onSelect: open }] }),
180
181
  });
181
- return section('profiles', P.profiles, [row({
182
+ loadSessionReads();
183
+ const found = sess.profiles && Array.isArray(sess.profiles.profiles) ? sess.profiles.profiles : [];
184
+ const kids = [row({
182
185
  title: P.profilesRow, meta: [P.profilesMeta], onOpen: open, openLabel: P.profilesOpen, trail: [more],
183
186
  attrs: { 'data-fr-row': 'profiles' },
184
- })], null, 'bot');
187
+ })];
188
+ if (sess.formOpen) kids.push(profileForm());
189
+ if (sess.msg) kids.push(el('p', { class: 'fr-set__note', role: 'status', 'data-fr': 'profiles-msg' }, sess.msg));
190
+ kids.push(el('p', { class: 'fr-set__note', 'data-fr': 'profiles-found' }, !sess.profiles ? P.profilesReading
191
+ : sess.profiles.ok === false ? P.profilesUnread(sess.profiles.error)
192
+ : found.length ? P.profilesFound(found.map((x) => x.id)) : P.profilesNone(sess.profiles.dir)));
193
+ const add = button({ label: P.profileNew, variant: 'outline', size: 'sm', icon: 'plus', attrs: { 'data-fr': 'profile-new', 'aria-expanded': sess.formOpen ? 'true' : 'false' },
194
+ onClick: () => { sess.formOpen = !sess.formOpen; sess.msg = ''; setState({}); } });
195
+ return section('profiles', P.profiles, kids, add, 'bot');
196
+ }
197
+
198
+ /* ── the Claude profile and launch flags a new session starts with (workspace-launch-flags) ───────────────────────── */
199
+ const sess = { profiles: null, flags: null, asked: false, flagsOpen: false, filter: '', formOpen: false, msg: '' };
200
+
201
+ /** Both reads once per page load; a created profile asks for the profile read again. */
202
+ function loadSessionReads() {
203
+ if (FIXTURE) return;
204
+ if (!sess.asked) {
205
+ sess.asked = true;
206
+ pmGet('/api/claude-flags').then((j) => { sess.flags = j || { ok: false, error: 'no answer', flags: [] }; setState({}); });
207
+ }
208
+ if (!sess.profiles && !sess.reading) {
209
+ sess.reading = true;
210
+ pmGet('/api/profiles').then((j) => { sess.reading = false; sess.profiles = j || { ok: false, error: 'no answer', profiles: [] }; setState({}); });
211
+ }
212
+ }
213
+
214
+ let profFormNode = null;
215
+ /** Built once per opening and handed back on every render, so typing is never lost to a rebuild. */
216
+ function profileForm() {
217
+ if (profFormNode) return profFormNode;
218
+ const idF = field({ label: P.pfId, placeholder: 'client-acme', attrs: { 'data-fr': 'pf-id' } });
219
+ const labelF = field({ label: P.pfLabel, placeholder: 'Client Acme', attrs: { 'data-fr': 'pf-label' } });
220
+ const acctF = field({ label: P.pfAccount, placeholder: 'you@company.com', attrs: { 'data-fr': 'pf-account' } });
221
+ const mcpF = field({ label: P.pfMcp, placeholder: 'jira confluence slack', attrs: { 'data-fr': 'pf-mcp' } });
222
+ const envHost = el('div', { class: 'fr-set__envrows', 'data-fr': 'pf-env' });
223
+ const rows = [];
224
+ const addEnv = (name) => {
225
+ const n = field({ label: P.pfEnvName, hideLabel: true, placeholder: 'NAME', value: name || '', attrs: { 'data-fr': 'pf-env-name' } });
226
+ const v = field({ label: P.pfEnvValue, hideLabel: true, placeholder: P.pfEnvValuePh, attrs: { 'data-fr': 'pf-env-value', type: 'password', autocomplete: 'new-password' } });
227
+ rows.push({ n, v });
228
+ envHost.append(el('div', { class: 'fr-set__fields fr-set__fields--grid' }, n, v));
229
+ return v;
230
+ };
231
+ addEnv('CLAUDE_CONFIG_DIR');
232
+ const err = el('p', { class: 'fr-set__note fr-set__error', role: 'alert', 'data-fr': 'pf-error' });
233
+ const reset = () => { profFormNode = null; sess.formOpen = false; };
234
+ const submit = async () => {
235
+ err.textContent = '';
236
+ const env = {};
237
+ for (const r of rows) { const k = r.n.control.value.trim(); if (k) env[k] = r.v.control.value; }
238
+ const body = { id: idF.control.value.trim(), label: labelF.control.value.trim(), account: acctF.control.value.trim(),
239
+ mcp: mcpF.control.value.split(/[\s,]+/).filter(Boolean), env };
240
+ const j = await pmPost('/api/profiles/create', body);
241
+ if (!j || j.ok === false) { err.textContent = (j && j.error) || P.pfFailed; return; }
242
+ reset(); sess.profiles = null; sess.msg = P.pfCreated(j.profile.id, j.profile.env_count);
243
+ setState({});
244
+ };
245
+ profFormNode = el('form', { class: 'fr-set__form', 'data-fr': 'profile-form', onSubmit: (e) => { e.preventDefault(); submit(); } },
246
+ el('div', { class: 'fr-set__fields fr-set__fields--grid' }, idF, labelF, acctF, mcpF),
247
+ el('p', { class: 'fr-set__note' }, P.pfEnvHead), envHost,
248
+ el('div', { class: 'fr-set__fields' },
249
+ button({ label: P.pfAddVar, variant: 'ghost', size: 'sm', icon: 'plus', attrs: { type: 'button', 'data-fr': 'pf-add-var' }, onClick: () => { addEnv('').control.focus(); } }),
250
+ ...['CLAUDE_CONFIG_DIR', 'ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL'].map((nm) => button({ label: nm, variant: 'ghost', size: 'sm', attrs: { type: 'button' }, onClick: () => { addEnv(nm).control.focus(); } }))),
251
+ el('p', { class: 'fr-set__note' }, P.pfNote), err,
252
+ el('div', { class: 'fr-set__fields' },
253
+ button({ label: P.cancel, variant: 'outline', attrs: { type: 'button' }, onClick: () => { reset(); setState({}); } }),
254
+ button({ label: P.pfCreate, variant: 'primary', attrs: { type: 'submit', 'data-fr': 'pf-create' } })));
255
+ return profFormNode;
185
256
  }
186
257
 
187
258
  function sessionsSection() {
259
+ loadSessionReads();
188
260
  const d = sessionDefaults();
189
261
  const cli = field({
190
262
  label: P.cli, kind: 'select', value: d.cli,
@@ -198,7 +270,32 @@ function sessionsSection() {
198
270
  onCommit: (v) => { setSessionDefaults({ tier: v }); setState({}); },
199
271
  attrs: { 'data-fr': 'default-model' },
200
272
  });
201
- return section('sessions', P.sessions, [el('div', { class: 'fr-set__fields fr-set__fields--grid' }, cli, model), el('p', { class: 'fr-set__note' }, P.sessionsNote)], null, 'square-terminal');
273
+ // Only descriptors the reader accepted are offered; a saved default that no longer reads stays shown, marked, so it is not silently swapped
274
+ const live = sess.profiles && Array.isArray(sess.profiles.profiles) ? sess.profiles.profiles : [];
275
+ const stale = d.profile && !live.some((x) => x.id === d.profile);
276
+ const profile = field({
277
+ label: P.profileDefault, kind: 'select', value: d.profile || '',
278
+ options: [{ value: '', label: P.noProfile }, ...(stale ? [{ value: d.profile, label: P.profileMissing(d.profile) }] : []),
279
+ ...live.map((x) => ({ value: x.id, label: (x.label && x.label !== x.id ? x.label + ' (' + x.id + ')' : x.id) }))],
280
+ onCommit: (v) => {
281
+ setSessionDefaults({ profile: v || null });
282
+ // the form's own per-agent memory follows, so both starts agree; a refusal is shown by the form itself
283
+ pmPost('/api/profiles/prefer', { agent: 'claude', profile: v || null });
284
+ setState({});
285
+ },
286
+ attrs: { 'data-fr': 'default-profile' },
287
+ });
288
+ const flags = d.flags;
289
+ const flagsBox = el('div', { class: 'fr-set__flags', 'data-fr': 'default-flags' },
290
+ el('span', { class: 'fr-fp-label' }, P.flagsDefault),
291
+ el('p', { class: 'fr-set__note', 'data-fr': 'flags-summary' }, flags.length ? 'claude ' + flagsLine(flags) : P.flagsNone),
292
+ sess.flags && flagsMissingValue(sess.flags, flags).length ? el('p', { class: 'fr-set__note fr-set__error', role: 'alert', 'data-fr': 'flags-missing' }, P.flagsMissing(flagsMissingValue(sess.flags, flags)[0])) : null,
293
+ flagPicker({ list: sess.flags, chosen: flags, filter: sess.filter, compact: !sess.flagsOpen,
294
+ onChange: (next) => { setSessionDefaults({ flags: next }); setState({}); },
295
+ onFilter: (q) => { sess.filter = q; setState({}); } }),
296
+ button({ label: sess.flagsOpen ? P.flagsHide : P.flagsAll, variant: 'outline', size: 'sm', attrs: { 'data-fr': 'flags-all', 'aria-expanded': sess.flagsOpen ? 'true' : 'false' },
297
+ onClick: () => { sess.flagsOpen = !sess.flagsOpen; setState({}); } }));
298
+ return section('sessions', P.sessions, [el('div', { class: 'fr-set__fields fr-set__fields--grid' }, cli, model, profile), flagsBox, el('p', { class: 'fr-set__note' }, P.sessionsNote)], null, 'square-terminal');
202
299
  }
203
300
 
204
301
  function validateSection(scope) {
@@ -341,7 +438,9 @@ async function waitForRestart(from, log) {
341
438
  * server, installs the new version, starts it again, and this page reloads on it. A kit update alone changes no page.
342
439
  */
343
440
  async function updateAll(ws, app, cmp) {
344
- const appToo = !!cmp.appBehind && !app.fromCheckout;
441
+ const cur = kitByWs.get(ws);
442
+ const local = !!(cur && cur.st && cur.st.source && cur.st.source.kind === 'local');
443
+ const appToo = !!cmp.appBehind && (!app.fromCheckout || local);
345
444
  if (appToo) {
346
445
  const yes = await askConfirm({ title: P.updateAppTitle, body: P.updateAppBody(app.version || '?', upd.latest), confirmLabel: P.updateAll });
347
446
  if (!yes) return;
@@ -368,9 +467,11 @@ function updatesSection(scope) {
368
467
  const cmp = upd.compare || {};
369
468
  const kids = [];
370
469
  const kitPresent = !!(kit && kit.present);
371
- const canUpdate = (cmp.appBehind && !app.fromCheckout) || (cmp.kitBehind && kitPresent);
470
+ const canUpdate = (cmp.appBehind && (!app.fromCheckout || (st && st.source && st.source.kind === 'local'))) || (cmp.kitBehind && kitPresent);
372
471
  kids.push(el('div', { class: 'fr-set__line fr-set__line--box' }, icon('panel-top'), el('span', { 'data-fr': 'app-version' }, P.updatesApp(app.version))));
373
- if (cmp.appBehind && app.fromCheckout) kids.push(el('p', { class: 'fr-set__note', 'data-fr': 'app-checkout' }, P.updatesCheckout));
472
+ const src = st && st.source;
473
+ if (src && src.kind === 'local') kids.push(el('p', { class: 'fr-set__note' + (src.error ? ' fr-set__error' : ''), 'data-fr': 'update-source' }, src.error ? P.updatesLocalBad(src.error) : P.updatesLocal(src.path)));
474
+ if (cmp.appBehind && app.fromCheckout && !(src && src.kind === 'local')) kids.push(el('p', { class: 'fr-set__note', 'data-fr': 'app-checkout' }, P.updatesCheckout));
374
475
  const kitText = scope === 'all' || !ws ? P.updatesNoRepo : !kit ? '' : kit.present ? P.updatesKit(kit.version) : P.updatesNoKit;
375
476
  kids.push(el('div', { class: 'fr-set__line fr-set__line--box' }, icon('cloud'), el('span', { 'data-fr': 'kit-version' }, kitText)));
376
477
  if (canUpdate) {
@@ -406,7 +507,7 @@ function signature(scope) {
406
507
  let add = '';
407
508
  try { add = JSON.stringify(addKeys); } catch { add = String(Date.now()); }
408
509
  const kit = ws ? kitByWs.get(ws) : null;
409
- return JSON.stringify([repoTuples(), activeProduct(), scope, state.theme, sessionDefaults(), ws, consentByWs.get(ws) || null, peopleByWs.get(ws) || null, addOpen, !!addMod, online.data, online.error, online.note,
510
+ return JSON.stringify([repoTuples(), activeProduct(), scope, state.theme, sessionDefaults(), sess.profiles, sess.flags, sess.flagsOpen, sess.filter, sess.formOpen, sess.msg, ws, consentByWs.get(ws) || null, peopleByWs.get(ws) || null, addOpen, !!addMod, online.data, online.error, online.note,
410
511
  kit ? kit.st : null, upd]) + add;
411
512
  }
412
513
 
@@ -299,7 +299,10 @@ export function openNewSessionSheet(repo, preset) {
299
299
  const base = (sk ? (sk.label || sk.skill || sk.id) : (d.cli === 'claude' ? 'Claude' : 'Shell')) + (m.initiative ? ' · ' + m.initiative : '');
300
300
  const name = typed || uniqueName(base);
301
301
  const common = {
302
- name, agent: sk ? 'claude' : d.cli, initiative: m.initiative || null, profile: null,
302
+ name, agent: sk ? 'claude' : d.cli, initiative: m.initiative || null,
303
+ // the Settings defaults for a Claude session (workspace-launch-flags); a shell takes neither
304
+ profile: (sk || d.cli === 'claude') ? d.profile : null,
305
+ flags: !sk && d.cli === 'claude' && d.flags.length ? d.flags.map((x) => ({ flag: x.flag, ...(x.value ? { value: x.value } : {}) })) : undefined,
303
306
  skill: sk ? sk.id : null, workType: sk ? typeOfSkill(sk) : 'general',
304
307
  tier: !sk && d.cli === 'claude' && d.tier !== 'auto' ? d.tier : null,
305
308
  };
@@ -33,17 +33,23 @@ const DEFAULTS_KEY = 'pmos.workspace.pm.session-defaults';
33
33
  export const CLIS = Object.freeze(['shell', 'claude']);
34
34
  export const TIERS = Object.freeze(['auto', 'fast', 'balanced', 'deep']);
35
35
 
36
- /** { cli: 'shell'|'claude', tier: 'auto'|'fast'|'balanced'|'deep' }. An unreadable or unknown value is the default. */
36
+ /* The Claude profile and launch flags a new Claude session starts with (workspace-launch-flags). Stored beside the CLI and
37
+ * tier. A malformed entry is dropped, never repaired: the server checks every flag again against its own list. */
38
+ const cleanProfile = (v) => (typeof v === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(v) ? v : null);
39
+ const cleanFlags = (v) => (Array.isArray(v) ? v.filter((x) => x && typeof x.flag === 'string' && /^-{1,2}[A-Za-z][\w-]*$/.test(x.flag))
40
+ .map((x) => ({ flag: x.flag, value: typeof x.value === 'string' ? x.value.slice(0, 500) : '' })) : []);
41
+
42
+ /** { cli: 'shell'|'claude', tier: 'auto'|'fast'|'balanced'|'deep', profile: id|null, flags: [{flag,value}] }. An unreadable or unknown value is the default. */
37
43
  export function sessionDefaults() {
38
44
  let raw = null;
39
45
  if (!FIXTURE) { try { raw = JSON.parse(localStorage.getItem(DEFAULTS_KEY) || 'null'); } catch { raw = null; } }
40
46
  const r = raw && typeof raw === 'object' ? raw : {};
41
- return { cli: CLIS.includes(r.cli) ? r.cli : 'shell', tier: TIERS.includes(r.tier) ? r.tier : 'auto' };
47
+ return { cli: CLIS.includes(r.cli) ? r.cli : 'shell', tier: TIERS.includes(r.tier) ? r.tier : 'auto', profile: cleanProfile(r.profile), flags: cleanFlags(r.flags) };
42
48
  }
43
49
 
44
50
  export function setSessionDefaults(patch) {
45
51
  const next = { ...sessionDefaults(), ...(patch || {}) };
46
- const clean = { cli: CLIS.includes(next.cli) ? next.cli : 'shell', tier: TIERS.includes(next.tier) ? next.tier : 'auto' };
52
+ const clean = { cli: CLIS.includes(next.cli) ? next.cli : 'shell', tier: TIERS.includes(next.tier) ? next.tier : 'auto', profile: cleanProfile(next.profile), flags: cleanFlags(next.flags) };
47
53
  if (!FIXTURE) { try { localStorage.setItem(DEFAULTS_KEY, JSON.stringify(clean)); } catch { /* private mode: the default stands */ } }
48
54
  return clean;
49
55
  }
@@ -0,0 +1,104 @@
1
+ /* pm/flag-picker.js — choose launch flags for a Claude session (initiative workspace-launch-flags).
2
+ *
3
+ * One picker, two places: Settings (the defaults every new session starts with) and the New session form (this
4
+ * session only). It draws the list the server serves at /api/claude-flags — every flag `claude --help` names — and
5
+ * holds NO state of its own: the caller keeps `chosen` ([{flag, value}]) and `filter`, passes them in, and redraws
6
+ * when `onChange` / `onFilter` fire. Text inputs commit on Enter or blur (the `field` primitive), so a redraw never
7
+ * lands between keystrokes and takes the caret with it.
8
+ *
9
+ * What a flag that takes a value offers, in order:
10
+ * choices `claude --help` states the complete set → a select, nothing else can be sent
11
+ * suggestions common values → chips to pick from, and the field still accepts another
12
+ * example free text, a path or JSON → the placeholder says what shape it is
13
+ * A flag PMOS answers itself (--model, --worktree …) or one that needs --print is listed, greyed, with the reason, and
14
+ * cannot be switched on. The server refuses the same flags, so this is a courtesy, not the guard.
15
+ */
16
+ import { el } from './dom.js';
17
+ import { button, field, badge } from './primitives.js';
18
+
19
+ /** The flags worth one tap. Not a limit: every offered flag is in the list below them. */
20
+ export const QUICK_FLAGS = Object.freeze(['--chrome', '--continue', '--permission-mode', '--add-dir', '--verbose', '--bare']);
21
+
22
+ const find = (chosen, f) => (chosen || []).find((x) => x.flag === f);
23
+
24
+ /** `--chrome --permission-mode=plan`, or '' when nothing is chosen. */
25
+ export function flagsLine(chosen) {
26
+ return (chosen || []).map((x) => (x.value && String(x.value).trim() ? x.flag + '=' + String(x.value).trim() : x.flag)).join(' ');
27
+ }
28
+
29
+ /** The flags that need a value and have none yet: [flagName]. The session cannot start with these. */
30
+ export function flagsMissingValue(list, chosen) {
31
+ const by = new Map(((list && list.flags) || []).map((f) => [f.flag, f]));
32
+ return (chosen || []).filter((x) => by.get(x.flag) && by.get(x.flag).value === 'required' && !String(x.value || '').trim()).map((x) => x.flag);
33
+ }
34
+
35
+ /** What goes on the wire: [{flag, value?}], or undefined for none. */
36
+ export function flagsWire(chosen) {
37
+ const out = (chosen || []).map((x) => ({ flag: x.flag, ...(x.value && String(x.value).trim() ? { value: String(x.value).trim() } : {}) }));
38
+ return out.length ? out : undefined;
39
+ }
40
+
41
+ function valueEditor(f, cur, set) {
42
+ const label = 'Value for ' + f.flag;
43
+ if (Array.isArray(f.choices) && f.choices.length) {
44
+ return el('div', { class: 'fr-fp-val' },
45
+ field({ label, hideLabel: true, kind: 'select', value: cur.value || '', attrs: { 'data-fp-value': f.flag },
46
+ options: [{ value: '', label: 'Choose a value…' }, ...f.choices.map((c) => ({ value: c, label: c }))], onCommit: (v) => set(v) }),
47
+ el('span', { class: 'fr-fp-note' }, 'One of ' + f.choices.length + ', as `claude --help` lists them.'));
48
+ }
49
+ const sg = Array.isArray(f.suggestions) ? f.suggestions : [];
50
+ return el('div', { class: 'fr-fp-val' },
51
+ field({ label, hideLabel: true, value: cur.value || '', placeholder: f.example || f.arg || 'value', attrs: { 'data-fp-value': f.flag }, onCommit: (v) => set(v.trim()) }),
52
+ sg.length ? el('div', { class: 'fr-fp-chips', role: 'group', 'aria-label': 'Suggested values for ' + f.flag },
53
+ sg.map((v) => button({ label: v, variant: 'outline', size: 'sm', attrs: { class: 'fr-fp-chip', 'aria-pressed': v === cur.value ? 'true' : 'false', 'data-fp-suggest': f.flag }, onClick: () => set(v) }))) : null,
54
+ el('span', { class: 'fr-fp-note' }, sg.length ? 'Suggestions. You can type another value.' : 'Free text' + (f.arg ? ' ' + f.arg : '') + '.'));
55
+ }
56
+
57
+ /**
58
+ * @param {{list:{ok:boolean,error?:string,flags:object[]}|null, chosen:Array, filter:string, onChange:(next:Array)=>void,
59
+ * onFilter:(q:string)=>void, quick?:boolean}} o
60
+ */
61
+ export function flagPicker({ list, chosen, filter, onChange, onFilter, quick = true, compact = false }) {
62
+ const root = el('div', { class: 'fr-fp', 'data-fp': 'root' });
63
+ if (!list) { root.append(el('p', { class: 'fr-fp-note' }, 'Reading the flag list…')); return root; }
64
+ if (list.ok === false) {
65
+ root.append(el('p', { class: 'fr-fp-note fp-bad', role: 'alert' }, 'The flag list could not be read — ' + (list.error || 'no reason given') + '. No flag can be added.'));
66
+ return root;
67
+ }
68
+ const all = list.flags || [];
69
+ const toggle = (f, on) => {
70
+ const cur = (chosen || []).filter((x) => x.flag !== f.flag);
71
+ onChange(on ? [...cur, { flag: f.flag, value: '' }] : cur);
72
+ };
73
+ const setValue = (f, v) => onChange((chosen || []).map((x) => (x.flag === f.flag ? { ...x, value: v } : x)));
74
+
75
+ if (quick) {
76
+ const qs = QUICK_FLAGS.map((n) => all.find((f) => f.flag === n)).filter((f) => f && f.offered);
77
+ root.append(el('div', { class: 'fr-fp-chips', role: 'group', 'aria-label': 'Common flags' },
78
+ qs.map((f) => button({ label: f.flag, variant: 'outline', size: 'sm', attrs: { class: 'fr-fp-chip', 'aria-pressed': find(chosen, f.flag) ? 'true' : 'false', 'data-fp-quick': f.flag }, onClick: () => toggle(f, !find(chosen, f.flag)) }))));
79
+ }
80
+ if (compact) {
81
+ // only what is switched on gets an editor; the full list opens on request
82
+ for (const f of all.filter((x) => find(chosen, x.flag) && x.value !== 'none')) root.append(el('div', { class: 'fr-fp-row', 'data-fp-row': f.flag }, el('code', { class: 'fr-fp-head' }, f.flag + (f.arg ? ' ' + f.arg : '')), valueEditor(f, find(chosen, f.flag), (v) => setValue(f, v))));
83
+ return root;
84
+ }
85
+ root.append(field({ label: 'Search flags', hideLabel: true, value: filter || '', placeholder: 'Search flags, e.g. chrome, permission, mcp (Enter)', attrs: { 'data-fp-filter': '1' }, onCommit: (v) => onFilter(v) }));
86
+
87
+ const q = String(filter || '').trim().toLowerCase();
88
+ const rows = all.filter((f) => !q || (f.flag + ' ' + (f.aliases || []).join(' ') + ' ' + f.description).toLowerCase().includes(q))
89
+ // what is switched on comes first, so its value editor is not buried in a list of 64
90
+ .sort((a, b) => (find(chosen, b.flag) ? 1 : 0) - (find(chosen, a.flag) ? 1 : 0));
91
+ root.append(el('div', { class: 'fr-fp-list', 'data-fp': 'list' },
92
+ rows.length ? rows.map((f) => {
93
+ const cur = find(chosen, f.flag);
94
+ const box = field({ kind: 'check', label: f.flag + (f.arg ? ' ' + f.arg : ''), value: !!cur, attrs: { 'data-fp-flag': f.flag, ...(f.offered ? {} : { disabled: true }) }, onCommit: (on) => toggle(f, on) });
95
+ return el('div', { class: 'fr-fp-row' + (f.offered ? '' : ' fr-fp-row--off'), 'data-fp-row': f.flag },
96
+ el('div', { class: 'fr-fp-head' }, box,
97
+ (f.aliases || []).length ? el('span', { class: 'fr-fp-note' }, f.aliases.join(' ')) : null,
98
+ f.caution ? badge({ text: 'Skips safety checks', tone: 'warning' }) : null,
99
+ f.offered ? null : badge({ text: 'Not offered' })),
100
+ el('p', { class: 'fr-fp-note' }, f.offered ? f.description : f.why_not),
101
+ cur && f.value !== 'none' ? valueEditor(f, cur, (v) => setValue(f, v)) : null);
102
+ }) : el('p', { class: 'fr-fp-note' }, 'No flag matches “' + (filter || '') + '”.')));
103
+ return root;
104
+ }
@@ -164,7 +164,26 @@ export const COPY = {
164
164
  modelFast: 'Fast',
165
165
  modelBalanced: 'Balanced',
166
166
  modelDeep: 'Deep',
167
- sessionsNote: 'The dock starts new sessions with these defaults. A model applies only to a Claude session.',
167
+ sessionsNote: 'The dock starts new sessions with these defaults. A model, a profile and launch flags apply only to a Claude session. The New session form can change them for one session.',
168
+ profileDefault: 'Default Claude profile',
169
+ noProfile: 'No profile',
170
+ profileMissing: (id) => id + ' (not found now)',
171
+ flagsDefault: 'Default launch flags',
172
+ flagsNone: 'None. Claude starts with its own defaults.',
173
+ flagsAll: 'All flags…',
174
+ flagsMissing: (f) => f + ' needs a value, or switch it off. A new session cannot start with it empty.',
175
+ flagsHide: 'Hide the flag list',
176
+ profileNew: 'New profile',
177
+ profilesReading: 'Reading ~/.claude/profiles…',
178
+ profilesUnread: (e) => 'The profiles could not be read — ' + (e || 'no reason given'),
179
+ profilesFound: (ids) => 'Found ' + ids.length + ': ' + ids.join(', ') + '.',
180
+ profilesNone: (dir) => 'No profile in ' + (dir || '~/.claude/profiles') + ' yet.',
181
+ pfId: 'Id (the file name)', pfLabel: 'Label', pfAccount: 'Account, shown as written and not checked', pfMcp: 'MCP server names, declared only',
182
+ pfEnvHead: 'Environment variables applied to the session',
183
+ pfEnvName: 'Variable name', pfEnvValue: 'Value', pfEnvValuePh: 'value (hidden)', pfAddVar: 'Add variable',
184
+ pfNote: 'The values are written to ~/.claude/profiles/<id>.json on this machine, are never shown again and are never sent to the browser. CLAUDE_CONFIG_DIR gives the profile its own Claude login and settings.',
185
+ pfCreate: 'Create profile', pfFailed: 'The profile was not created.', cancel: 'Cancel',
186
+ pfCreated: (id, n) => 'Created ~/.claude/profiles/' + id + '.json with ' + n + ' environment variable' + (n === 1 ? '' : 's') + '.',
168
187
  validate: 'Validate consent',
169
188
  validateOn: 'Validation is on for this repository.',
170
189
  validateOff: 'Validation is off. Nothing from this repository is sent.',
@@ -223,6 +242,8 @@ export const COPY = {
223
242
  updatesNever: 'Press Check for updates to compare with the latest published version.',
224
243
  updatesLatest: (v) => 'Latest published version: ' + v + '.',
225
244
  updatesCurrent: (v) => 'Up to date. The latest published version is ' + v + '.',
245
+ updatesLocal: (p) => 'Checking against the local package ' + p + ', not npm. Set PMOS_UPDATE_SOURCE to change this.',
246
+ updatesLocalBad: (e) => e,
226
247
  updatesCheckout: 'This workspace runs from a PMOS checkout. Update it with git pull.',
227
248
  updatesNote: 'Updating the kit changes only its own files (skills, scripts, templates and guides). Your records in state/, planning/ and okf/product/ do not change.',
228
249
  updateKitBusy: 'Updating the kit…',