myrlin-workbook 1.2.0-alpha.2 → 1.2.0-alpha.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myrlin-workbook",
3
- "version": "1.2.0-alpha.2",
3
+ "version": "1.2.0-alpha.4",
4
4
  "description": "Browser-based project manager for AI coding assistants (Claude Code, ChatGPT Codex). Multi-provider session discovery, search, live terminals, docs, kanban, themes.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,35 +1,34 @@
1
1
  /**
2
2
  * Codex provider spawn descriptor builder.
3
3
  *
4
- * Phase 17 Plan 17-02 (CDX-07 spawn half).
4
+ * Phase 17 Plan 17-02 (CDX-07 spawn half) shipped the minimum surface:
5
+ * `resume <id>` when providerSessionId is set, fresh-session otherwise.
6
+ * Phase 21 Plan 21-01 extends this with optional providerSettings so the
7
+ * frontend right-click menu can drive Codex CLI flags (model, sandbox,
8
+ * approval policy, reasoning effort, bypass, feature enables).
5
9
  *
6
- * Pure function that builds the SpawnDescriptor pty-manager uses to launch
7
- * the Codex CLI. Mirrors the shape established by src/providers/claude/spawn.js
8
- * so the existing pty-manager pass-through plumbing (Plan 14-04) needs no
9
- * Codex-specific code path.
10
+ * Pure function. Pty-manager (Plan 14-04) reads the returned descriptor and
11
+ * owns the actual node-pty.spawn call. No filesystem touches, no env
12
+ * mutation, no network. Validation is defensive: unsafe values trigger a
13
+ * console.warn and are dropped silently rather than throwing, so a stale
14
+ * frontend value never blocks a pane spawn.
10
15
  *
11
- * Phase 17 ships the minimum surface: a `resume <id>` invocation when
12
- * providerSessionId is set, a fresh-session invocation when it is not. No
13
- * --model, --verbose, --workdir, or other flags are passed today; Phase 19
14
- * (Codex Live PTY End-to-End) will add them once the live terminal plumbing
15
- * exists. Keeping the surface narrow now avoids exposing flags that may be
16
- * renamed by upstream Codex before they are tested.
16
+ * Flag translation (providerSettings -> argv):
17
+ * model -> ['-m', model]
18
+ * sandbox -> ['-s', sandbox]
19
+ * approvalPolicy -> ['-a', approvalPolicy]
20
+ * reasoningEffort -> ['-c', 'model_reasoning_effort="<effort>"']
21
+ * bypassApprovalsAndSandbox: true
22
+ * -> ['--dangerously-bypass-approvals-and-sandbox']
23
+ * features: [name,..] -> ['--enable', name] pairs
17
24
  *
18
- * CODEX_HOME env scoping: process.env.CODEX_HOME is propagated through the
19
- * descriptor so a user with a non-default $CODEX_HOME survives a Myrlin pane
20
- * spawn (the spawned `codex` process otherwise inherits the parent's env,
21
- * which works in the common case, but explicit propagation is the
22
- * documentation-friendly form: the descriptor advertises which env keys
23
- * matter). When the parent env does NOT set CODEX_HOME, env.CODEX_HOME is
24
- * set to undefined which pty-manager treats as DELETE-this-key. Net effect
25
- * for the unset case is identical to leaving the env alone; the explicit
26
- * assign documents the contract.
25
+ * Positional ordering invariant: any `resume <id>` positional pair stays
26
+ * LAST in args so flags cannot be misparsed as the resume id. This matches
27
+ * the Codex CLI convention (subcommand positions trail option flags).
27
28
  *
28
- * Validation: the providerSessionId is checked against /^[a-zA-Z0-9_-]+$/
29
- * before being placed in args. Shell unsafe characters (semicolons,
30
- * backticks, spaces, etc.) trigger an Error. This matches the pattern in
31
- * claude/spawn.js verbatim so any input that passes the SHELL_UNSAFE gate
32
- * for Claude also passes for Codex.
29
+ * Enum allow-lists are intentionally short and conservative. Drop-unknown
30
+ * with a console.warn keeps the spawn path resilient against typos and
31
+ * stale fields from older clients.
33
32
  *
34
33
  * SPDX-License-Identifier: AGPL-3.0-only
35
34
  *
@@ -38,68 +37,161 @@
38
37
 
39
38
  'use strict';
40
39
 
41
- // The literal 'codex' below is the CLI binary name (the npm-published Codex
42
- // CLI installs a `codex` executable). This file lives inside src/providers/codex/,
43
- // which the grep gate (Plan 14-05) skips, so the marker is defensive (extra
44
- // signal for future readers) rather than required.
45
40
  const CODEX_BINARY = 'codex'; // gsd:provider-literal-allowed (Codex provider CLI binary)
46
41
 
42
+ // Same shell-safe regex Claude uses for its providerSessionId gate.
43
+ const SAFE_ID_RE = /^[a-zA-Z0-9_-]+$/;
44
+
45
+ // Shell-unsafe characters for free-form values (model names, etc.). Mirrors
46
+ // pty-manager.js SHELL_UNSAFE so anything that passes here also passes the
47
+ // outer gate; we keep the local copy so this module is testable standalone.
48
+ const SHELL_UNSAFE_RE = /[;&|`$(){}[\]<>!#*?\n\r\\'"]/;
49
+
50
+ // Enum allow-lists. Conservative on purpose: only values the Codex CLI
51
+ // accepts today. Unknown values get dropped with a console.warn instead of
52
+ // crashing the spawn so a stale frontend cache never blocks a pane.
53
+ const SANDBOX_VALUES = new Set([
54
+ 'read-only',
55
+ 'workspace-write',
56
+ 'danger-full-access',
57
+ ]);
58
+
59
+ const APPROVAL_VALUES = new Set([
60
+ 'untrusted',
61
+ 'on-failure',
62
+ 'on-request',
63
+ 'never',
64
+ ]);
65
+
66
+ const EFFORT_VALUES = new Set([
67
+ 'minimal',
68
+ 'low',
69
+ 'medium',
70
+ 'high',
71
+ ]);
72
+
73
+ // Feature name format: short alphanumeric + dash/underscore. Matches Codex
74
+ // CLI --enable token shape (e.g. web_search, view_image).
75
+ const FEATURE_NAME_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
76
+
77
+ // Model id format: alphanumeric + dot/dash/underscore/colon. Cap length so
78
+ // argv stays bounded. The colon allows version-suffixed model ids
79
+ // ("gpt-5:2025-09-01"-shape).
80
+ const MODEL_ID_RE = /^[a-zA-Z0-9._:-]{1,128}$/;
81
+
47
82
  /**
48
- * Validate providerSessionId against the same shell-safe regex Claude uses.
49
- * UUIDs (with or without hyphens) and slug-style identifiers pass; anything
50
- * with spaces, quotes, semicolons, backticks, or other shell-special bytes
51
- * trips the gate.
83
+ * Validate providerSessionId. Reused by tests.
52
84
  *
53
- * @param {string} id - candidate providerSessionId
54
- * @returns {boolean} true when id is safe to interpolate into argv tokens
85
+ * @param {*} id
86
+ * @returns {boolean}
55
87
  */
56
88
  function isSafeProviderSessionId(id) {
57
- return typeof id === 'string' && /^[a-zA-Z0-9_-]+$/.test(id);
89
+ return typeof id === 'string' && SAFE_ID_RE.test(id);
90
+ }
91
+
92
+ /**
93
+ * Translate an optional providerSettings bundle to Codex CLI argv tokens.
94
+ * Pure function. Unknown values are dropped with a single console.warn so
95
+ * one bad field does not abort the spawn.
96
+ *
97
+ * @param {Object} [settings] - providerSettings.codex bundle. Optional.
98
+ * @returns {string[]} Flag tokens ordered model, sandbox, approval, effort,
99
+ * bypass, then feature pairs. Caller appends positional args after.
100
+ */
101
+ function buildFlagsFromSettings(settings) {
102
+ if (!settings || typeof settings !== 'object') return [];
103
+ const flags = [];
104
+
105
+ if (typeof settings.model === 'string' && settings.model.length > 0) {
106
+ if (MODEL_ID_RE.test(settings.model) && !SHELL_UNSAFE_RE.test(settings.model)) {
107
+ flags.push('-m', settings.model);
108
+ } else {
109
+ console.warn('[codex/spawn] dropping unsafe/oversized model: ' + settings.model);
110
+ }
111
+ }
112
+
113
+ if (typeof settings.sandbox === 'string' && settings.sandbox.length > 0) {
114
+ if (SANDBOX_VALUES.has(settings.sandbox)) {
115
+ flags.push('-s', settings.sandbox);
116
+ } else {
117
+ console.warn('[codex/spawn] dropping unknown sandbox: ' + settings.sandbox);
118
+ }
119
+ }
120
+
121
+ if (typeof settings.approvalPolicy === 'string' && settings.approvalPolicy.length > 0) {
122
+ if (APPROVAL_VALUES.has(settings.approvalPolicy)) {
123
+ flags.push('-a', settings.approvalPolicy);
124
+ } else {
125
+ console.warn('[codex/spawn] dropping unknown approvalPolicy: ' + settings.approvalPolicy);
126
+ }
127
+ }
128
+
129
+ if (typeof settings.reasoningEffort === 'string' && settings.reasoningEffort.length > 0) {
130
+ if (EFFORT_VALUES.has(settings.reasoningEffort)) {
131
+ // TOML-safe value formatting: quote the string so the Codex `-c key=val`
132
+ // parser treats it as a literal even if a future effort name contains
133
+ // chars TOML would interpret. Today all enum values are bare words and
134
+ // quoting them is a no-op, but the quotes future-proof the contract.
135
+ flags.push('-c', 'model_reasoning_effort="' + settings.reasoningEffort + '"');
136
+ } else {
137
+ console.warn('[codex/spawn] dropping unknown reasoningEffort: ' + settings.reasoningEffort);
138
+ }
139
+ }
140
+
141
+ if (settings.bypassApprovalsAndSandbox === true) {
142
+ flags.push('--dangerously-bypass-approvals-and-sandbox');
143
+ }
144
+
145
+ if (Array.isArray(settings.features)) {
146
+ for (const name of settings.features) {
147
+ if (typeof name === 'string' && FEATURE_NAME_RE.test(name) && !SHELL_UNSAFE_RE.test(name)) {
148
+ flags.push('--enable', name);
149
+ } else {
150
+ console.warn('[codex/spawn] dropping unsafe feature name: ' + name);
151
+ }
152
+ }
153
+ }
154
+
155
+ return flags;
58
156
  }
59
157
 
60
158
  /**
61
159
  * Build a SpawnDescriptor for the Codex CLI.
62
160
  *
63
161
  * Pure function. Does NOT touch the filesystem, the network, or any state.
64
- * Does NOT mutate process.env. Throws on invalid input. Returns a descriptor
65
- * that pty-manager joins, shell-wraps, and runs through node-pty.
162
+ * Does NOT mutate process.env. Throws on invalid providerSessionId. Unknown
163
+ * providerSettings values are dropped (warn only) rather than thrown.
66
164
  *
67
- * Phase 17 minimum surface:
68
- * - providerSessionId set -> ['resume', '<id>']
69
- * - providerSessionId unset -> []
165
+ * argv ordering:
166
+ * [<flag tokens from providerSettings...>, 'resume', '<id>']
70
167
  *
71
- * Phase 19 (Codex Live PTY) will extend this with --model, --verbose, and
72
- * the equivalent of Claude's initialPrompt argument once the live PTY
73
- * plumbing has shipped and the Codex flag surface is locked.
168
+ * The positional resume pair stays LAST so flag-shaped resume ids cannot be
169
+ * misparsed (the Codex CLI takes the subcommand as a trailing position).
74
170
  *
75
- * @param {Object} [init] - spawn input bundle
76
- * @param {string|null} [init.providerSessionId] - Codex session UUID for `resume`.
77
- * Validated against /^[a-zA-Z0-9_-]+$/. Throws on unsafe input.
78
- * @param {string|null} [init.cwd] - Working directory; passes through.
79
- * pty-manager validates and falls back to homedir if missing.
80
- * @returns {{cmd: string, args: string[], cwd: (string|null), env: Object<string,(string|undefined)>}} SpawnDescriptor.
171
+ * @param {Object} [init]
172
+ * @param {string|null} [init.providerSessionId] - Codex session UUID for
173
+ * `resume`. Validated against /^[a-zA-Z0-9_-]+$/. Throws on unsafe input.
174
+ * @param {string|null} [init.cwd] - Working directory; pass-through.
175
+ * @param {Object} [init.providerSettings] - Optional Codex settings bundle.
176
+ * See buildFlagsFromSettings for accepted keys.
177
+ * @returns {{cmd:string, args:string[], cwd:(string|null), env:Object}}
81
178
  * @throws {Error} when providerSessionId fails the safety regex.
82
179
  */
83
- function spawnCommand({ providerSessionId = null, cwd = null } = {}) {
84
- // Validate first so we never produce an unsafe argv even partially.
180
+ function spawnCommand({ providerSessionId = null, cwd = null, providerSettings = null } = {}) {
85
181
  if (providerSessionId && !isSafeProviderSessionId(providerSessionId)) {
86
182
  throw new Error('unsafe providerSessionId: ' + providerSessionId);
87
183
  }
88
184
 
89
- const args = [];
185
+ // Flags first, positional resume <id> last so flag/value pairs cannot
186
+ // accidentally swallow the resume id at argv-parse time.
187
+ const args = buildFlagsFromSettings(providerSettings);
90
188
  if (providerSessionId) {
91
- // Codex's resume subcommand takes the session UUID as a positional arg.
92
- // The exact CLI invocation is: codex resume <uuid>
93
- args.push('resume');
94
- args.push(providerSessionId);
189
+ args.push('resume', providerSessionId);
95
190
  }
96
- // Phase 17 deliberately stops here. Additional flags belong in Phase 19.
97
191
 
98
192
  // CODEX_HOME scoping: read process.env at call time so a test that
99
193
  // mutates the env in a try/finally sees the change reflected here.
100
- // We DO NOT cache the value at module load. The undefined-means-delete
101
- // semantic is honored by pty-manager (see pty-manager.js: any env value
102
- // that is `=== undefined` is removed from the spawn env via delete).
194
+ // Undefined-means-delete semantic honored by pty-manager.
103
195
  const codexHomeFromEnv = process.env.CODEX_HOME;
104
196
  const envOverride = {
105
197
  CODEX_HOME: typeof codexHomeFromEnv === 'string' && codexHomeFromEnv.length > 0
@@ -115,4 +207,4 @@ function spawnCommand({ providerSessionId = null, cwd = null } = {}) {
115
207
  };
116
208
  }
117
209
 
118
- module.exports = { spawnCommand };
210
+ module.exports = { spawnCommand, buildFlagsFromSettings };
@@ -747,6 +747,66 @@ class Store extends EventEmitter {
747
747
  return this.updateSession(id, { status, pid });
748
748
  }
749
749
 
750
+ /**
751
+ * Persist per-session provider settings (Phase 21 Plan 21-01).
752
+ *
753
+ * Shape:
754
+ * session.providerSettings = { [providerId]: { ...settings } }
755
+ *
756
+ * Per-provider merging: the new settings object REPLACES the existing
757
+ * bundle for that providerId. Other providers' bundles on the same
758
+ * session are untouched. Caller is responsible for shallow-merging if
759
+ * a partial update is desired (the route currently sends a full
760
+ * canonical bundle, so replace-on-write is the right default).
761
+ *
762
+ * @param {string} sessionId
763
+ * @param {string} providerId - Provider id string (registry-issued).
764
+ * @param {Object} settings - The canonical settings bundle to persist.
765
+ * @returns {Object|null} Updated session or null if not found.
766
+ */
767
+ updateSessionProviderSettings(sessionId, providerId, settings) {
768
+ const session = this._state.sessions[sessionId];
769
+ if (!session) return null;
770
+ if (!session.providerSettings || typeof session.providerSettings !== 'object') {
771
+ session.providerSettings = {};
772
+ }
773
+ session.providerSettings[providerId] = settings;
774
+ session.lastActive = new Date().toISOString();
775
+ this.save();
776
+ this.emit('session:updated', session);
777
+ return session;
778
+ }
779
+
780
+ /**
781
+ * Read the effective provider settings for a session, with fallback to
782
+ * the per-provider defaults in `state.settings.providerDefaults[providerId]`.
783
+ * Used by the route handler so the UI sees defaults when a session has
784
+ * no overrides yet.
785
+ *
786
+ * @param {string} sessionId
787
+ * @param {string} providerId
788
+ * @returns {Object} Settings bundle (may be empty object).
789
+ */
790
+ getSessionProviderSettings(sessionId, providerId) {
791
+ const session = this._state.sessions[sessionId];
792
+ const fromSession = session
793
+ && session.providerSettings
794
+ && typeof session.providerSettings === 'object'
795
+ && session.providerSettings[providerId]
796
+ && typeof session.providerSettings[providerId] === 'object'
797
+ ? session.providerSettings[providerId]
798
+ : null;
799
+ if (fromSession) return fromSession;
800
+ const defaults = this._state.settings
801
+ && this._state.settings.providerDefaults
802
+ && typeof this._state.settings.providerDefaults === 'object'
803
+ && this._state.settings.providerDefaults[providerId]
804
+ && typeof this._state.settings.providerDefaults[providerId] === 'object'
805
+ ? this._state.settings.providerDefaults[providerId]
806
+ : {};
807
+ return defaults;
808
+ }
809
+
750
810
  addSessionLog(id, message) {
751
811
  const session = this._state.sessions[id];
752
812
  if (!session) return;
@@ -349,6 +349,17 @@ class PtySessionManager {
349
349
  // ── Block B (Plan 14-04): Build descriptor (provider OR inline) ──
350
350
  let descriptor;
351
351
  if (useProvider) {
352
+ // Phase 21 Plan 21-01: per-session providerSettings drives provider CLI flags.
353
+ // Read the bundle for THIS provider so a Claude session never receives
354
+ // a Codex-shaped bundle and vice versa. Missing/non-object yields null;
355
+ // the provider's spawnCommand treats null as "no extra flags".
356
+ const providerSettingsBundle = storeSession
357
+ && storeSession.providerSettings
358
+ && typeof storeSession.providerSettings === 'object'
359
+ && storeSession.providerSettings[providerId]
360
+ && typeof storeSession.providerSettings[providerId] === 'object'
361
+ ? storeSession.providerSettings[providerId]
362
+ : null;
352
363
  try {
353
364
  descriptor = provider.spawnCommand({
354
365
  sessionId,
@@ -359,6 +370,7 @@ class PtySessionManager {
359
370
  model,
360
371
  verbose,
361
372
  initialPrompt,
373
+ providerSettings: providerSettingsBundle,
362
374
  });
363
375
  } catch (err) {
364
376
  console.error('[PTY] Provider ' + providerId + ' spawnCommand failed for ' + sessionId + ': ' + err.message);
@@ -11397,6 +11397,188 @@ class CWMApp {
11397
11397
  this._renderContextItems('Open View', items, x, y);
11398
11398
  }
11399
11399
 
11400
+ /**
11401
+ * Build the "Codex settings" submenu (Plan 21-01).
11402
+ *
11403
+ * Pure factory: returns an array of menu items suitable for the existing
11404
+ * _renderContextItems renderer (label/submenu/check/hint/action/danger
11405
+ * shape). Reads the session's current providerSettings from
11406
+ * this.state.allSessions (or this.state.sessions) and uses the values to
11407
+ * mark the active option with a check in each submenu.
11408
+ *
11409
+ * Each leaf action PUTs the new bundle to the server and shows a toast
11410
+ * hinting the user to restart the pane for the change to take effect
11411
+ * (pty-manager only reads providerSettings on spawn). The bypass toggle
11412
+ * routes through showConfirmModal first because it weakens the sandbox.
11413
+ *
11414
+ * Dispatched from showTerminalContextMenu when the pane's
11415
+ * dataset.provider matches the Codex provider id. Pane menus for other
11416
+ * providers skip this branch so a Claude pane never shows a Codex submenu.
11417
+ *
11418
+ * @param {number} slotIdx - Terminal pane slot index.
11419
+ * @param {Object} tp - The TerminalPane instance for this slot.
11420
+ * @returns {Array<Object>} Menu items to splice into the pane context menu.
11421
+ */
11422
+ _buildCodexPaneMenu(slotIdx, tp) { // gsd:provider-literal-allowed (Codex pane menu factory)
11423
+ const sessionId = tp.sessionId;
11424
+ // Look up the session record to read providerSettings.codex. Defensive
11425
+ // because some panes (Codex Desktop project sessions) may not exist in
11426
+ // the live sessions list yet; in that case we render with empty
11427
+ // settings and let the user dial them in.
11428
+ const allSessions = [
11429
+ ...(this.state.sessions || []),
11430
+ ...(this.state.allSessions || []),
11431
+ ];
11432
+ const sess = allSessions.find(s => s && s.id === sessionId) || {};
11433
+ const codexSettings = (sess.providerSettings && sess.providerSettings.codex) /* gsd:provider-literal-allowed */ || {};
11434
+
11435
+ const putSettings = async (partial) => {
11436
+ const next = { ...codexSettings, ...partial };
11437
+ try {
11438
+ await this.api('PUT', '/api/sessions/' + encodeURIComponent(sessionId) + '/provider-settings', { settings: next });
11439
+ // Mutate the cached session so subsequent menu opens reflect the
11440
+ // change without a full refetch. The next saveTerminalLayout or
11441
+ // loadSessions tick will rehydrate from the server.
11442
+ if (!sess.providerSettings) sess.providerSettings = {};
11443
+ sess.providerSettings.codex = next; // gsd:provider-literal-allowed
11444
+ this.showToast('Codex settings updated — restart pane to apply', 'info');
11445
+ } catch (err) {
11446
+ this.showToast(err.message || 'Failed to update Codex settings', 'error');
11447
+ }
11448
+ };
11449
+
11450
+ // Catalog of accepted values per setting. Mirrors backend allow-lists.
11451
+ const MODEL_OPTIONS = [
11452
+ { id: 'gpt-5-codex', label: 'gpt-5-codex' },
11453
+ { id: 'gpt-5', label: 'gpt-5' },
11454
+ { id: 'o3', label: 'o3' },
11455
+ ];
11456
+ const SANDBOX_OPTIONS = [
11457
+ { id: 'read-only', label: 'read-only' },
11458
+ { id: 'workspace-write', label: 'workspace-write' },
11459
+ { id: 'danger-full-access', label: 'danger-full-access (risky)' },
11460
+ ];
11461
+ const APPROVAL_OPTIONS = [
11462
+ { id: 'untrusted', label: 'untrusted (prompt for unknown commands)' },
11463
+ { id: 'on-failure', label: 'on-failure' },
11464
+ { id: 'on-request', label: 'on-request' },
11465
+ { id: 'never', label: 'never (auto-approve everything)' },
11466
+ ];
11467
+ const EFFORT_OPTIONS = [
11468
+ { id: 'minimal', label: 'minimal' },
11469
+ { id: 'low', label: 'low' },
11470
+ { id: 'medium', label: 'medium' },
11471
+ { id: 'high', label: 'high' },
11472
+ ];
11473
+ const FEATURE_OPTIONS = [
11474
+ { id: 'web_search', label: 'Web search' },
11475
+ { id: 'view_image', label: 'View images' },
11476
+ { id: 'plan_tool', label: 'Plan tool' },
11477
+ { id: 'apply_patch_tool', label: 'Apply patch tool' },
11478
+ ];
11479
+
11480
+ const codexItems = [];
11481
+
11482
+ // 1. Model submenu
11483
+ codexItems.push({
11484
+ label: 'Model',
11485
+ icon: '&#129504;',
11486
+ hint: codexSettings.model || 'default',
11487
+ submenu: MODEL_OPTIONS.map(opt => ({
11488
+ label: opt.label,
11489
+ action: () => putSettings({ model: opt.id }),
11490
+ check: codexSettings.model === opt.id,
11491
+ })),
11492
+ });
11493
+
11494
+ // 2. Sandbox submenu
11495
+ codexItems.push({
11496
+ label: 'Sandbox',
11497
+ icon: '&#128274;',
11498
+ hint: codexSettings.sandbox || 'default',
11499
+ submenu: SANDBOX_OPTIONS.map(opt => ({
11500
+ label: opt.label,
11501
+ action: () => putSettings({ sandbox: opt.id }),
11502
+ check: codexSettings.sandbox === opt.id,
11503
+ danger: opt.id === 'danger-full-access',
11504
+ })),
11505
+ });
11506
+
11507
+ // 3. Approval Policy submenu
11508
+ codexItems.push({
11509
+ label: 'Approval Policy',
11510
+ icon: '&#9989;',
11511
+ hint: codexSettings.approvalPolicy || 'default',
11512
+ submenu: APPROVAL_OPTIONS.map(opt => ({
11513
+ label: opt.label,
11514
+ action: () => putSettings({ approvalPolicy: opt.id }),
11515
+ check: codexSettings.approvalPolicy === opt.id,
11516
+ danger: opt.id === 'never',
11517
+ })),
11518
+ });
11519
+
11520
+ // 4. Reasoning Effort submenu
11521
+ codexItems.push({
11522
+ label: 'Reasoning Effort',
11523
+ icon: '&#128173;',
11524
+ hint: codexSettings.reasoningEffort || 'default',
11525
+ submenu: EFFORT_OPTIONS.map(opt => ({
11526
+ label: opt.label,
11527
+ action: () => putSettings({ reasoningEffort: opt.id }),
11528
+ check: codexSettings.reasoningEffort === opt.id,
11529
+ })),
11530
+ });
11531
+
11532
+ // 5. Bypass toggle with confirmation modal
11533
+ const isBypassOn = codexSettings.bypassApprovalsAndSandbox === true;
11534
+ codexItems.push({
11535
+ label: isBypassOn ? 'Bypass: ON (click to disable)' : 'Bypass Approvals & Sandbox',
11536
+ icon: '&#9888;',
11537
+ danger: true,
11538
+ check: isBypassOn,
11539
+ action: async () => {
11540
+ if (isBypassOn) {
11541
+ // Turning OFF a dangerous flag is safe; no confirmation needed.
11542
+ await putSettings({ bypassApprovalsAndSandbox: false });
11543
+ return;
11544
+ }
11545
+ // Turning ON: require explicit confirmation. The bypass flag
11546
+ // disables BOTH the approval workflow AND the sandbox, so the
11547
+ // session can read/write/exec anything the user can. Worth a
11548
+ // second click before flipping.
11549
+ const confirmed = await this.showConfirmModal({
11550
+ title: 'Enable Bypass for Codex?',
11551
+ message: 'This disables BOTH the approval workflow AND the sandbox for this Codex session. The session can read/write/execute anything you can. Continue?',
11552
+ confirmText: 'Enable Bypass',
11553
+ confirmClass: 'btn-danger',
11554
+ });
11555
+ if (confirmed) {
11556
+ await putSettings({ bypassApprovalsAndSandbox: true });
11557
+ }
11558
+ },
11559
+ });
11560
+
11561
+ // 6. Features submenu (multi-select via per-item toggle)
11562
+ const activeFeatures = Array.isArray(codexSettings.features) ? codexSettings.features : [];
11563
+ codexItems.push({
11564
+ label: 'Features',
11565
+ icon: '&#9881;',
11566
+ hint: activeFeatures.length ? activeFeatures.join(', ') : 'none',
11567
+ submenu: FEATURE_OPTIONS.map(opt => ({
11568
+ label: opt.label,
11569
+ action: () => {
11570
+ const next = activeFeatures.includes(opt.id)
11571
+ ? activeFeatures.filter(f => f !== opt.id)
11572
+ : [...activeFeatures, opt.id];
11573
+ putSettings({ features: next });
11574
+ },
11575
+ check: activeFeatures.includes(opt.id),
11576
+ })),
11577
+ });
11578
+
11579
+ return codexItems;
11580
+ }
11581
+
11400
11582
  showTerminalContextMenu(slotIdx, x, y) {
11401
11583
  const tp = this.terminalPanes[slotIdx];
11402
11584
  if (!tp) return;
@@ -11556,6 +11738,21 @@ class CWMApp {
11556
11738
  });
11557
11739
  }
11558
11740
 
11741
+ // ── Provider-specific submenu (Plan 21-01) ────────────────
11742
+ // Dispatch by data-provider on the pane element. The Codex pane gets
11743
+ // a "Codex settings" submenu with model/sandbox/approval/effort/bypass/
11744
+ // features; Claude panes get nothing here and fall through unchanged.
11745
+ const paneElForProvider = document.getElementById(`term-pane-${slotIdx}`);
11746
+ const paneProvider = paneElForProvider && paneElForProvider.dataset && paneElForProvider.dataset.provider;
11747
+ if (paneProvider === 'codex') { // gsd:provider-literal-allowed (per-provider dispatch)
11748
+ items.push({ type: 'sep' });
11749
+ items.push({
11750
+ label: 'Codex settings',
11751
+ icon: '&#129504;',
11752
+ submenu: this._buildCodexPaneMenu(slotIdx, tp),
11753
+ });
11754
+ }
11755
+
11559
11756
  // ── Pane management ───────────────────────────────────────
11560
11757
  items.push({ type: 'sep' });
11561
11758
 
@@ -10224,13 +10224,25 @@ html {
10224
10224
  fades to transparent before reaching the xterm canvas; the xterm
10225
10225
  canvas paints over the pane background, so text contrast is never
10226
10226
  reduced by the tint (Pitfall F mitigation). */
10227
+ /* Per-design 2026-05-11: pane-provider visual treatment bumped to be
10228
+ noticeable at-a-glance for mixed-provider grids. Combines a 3px solid
10229
+ accent strip at top (was 1px), a deeper top-fade tint (was 24px), and
10230
+ a 2px bottom accent. Whole-pane subtle background tint via color-mix
10231
+ (12% saturation) sits BEHIND the xterm canvas; xterm paints opaque
10232
+ over its own area so text contrast is never reduced (Pitfall F). */
10227
10233
  .terminal-pane[data-provider="claude"]:not(.terminal-pane-empty) {
10228
- border-top: 1px solid var(--provider-claude-accent);
10229
- background: linear-gradient(180deg, var(--provider-claude-tint) 0, transparent 24px), var(--bg-primary);
10234
+ border-top: 3px solid var(--provider-claude-accent);
10235
+ border-bottom: 2px solid var(--provider-claude-accent);
10236
+ background:
10237
+ linear-gradient(180deg, var(--provider-claude-tint) 0, transparent 64px),
10238
+ color-mix(in srgb, var(--mauve) 4%, var(--bg-primary));
10230
10239
  }
10231
10240
  .terminal-pane[data-provider="codex"]:not(.terminal-pane-empty) {
10232
- border-top: 1px solid var(--provider-codex-accent);
10233
- background: linear-gradient(180deg, var(--provider-codex-tint) 0, transparent 24px), var(--bg-primary);
10241
+ border-top: 3px solid var(--provider-codex-accent);
10242
+ border-bottom: 2px solid var(--provider-codex-accent);
10243
+ background:
10244
+ linear-gradient(180deg, var(--provider-codex-tint) 0, transparent 64px),
10245
+ color-mix(in srgb, var(--green) 4%, var(--bg-primary));
10234
10246
  }
10235
10247
 
10236
10248
  /* 2px provider stripe on the left edge of the project accordion. */
package/src/web/server.js CHANGED
@@ -1503,6 +1503,110 @@ app.delete('/api/sessions/:id', requireAuth, (req, res) => {
1503
1503
  return res.json({ success: true });
1504
1504
  });
1505
1505
 
1506
+ /**
1507
+ * PUT /api/sessions/:id/provider-settings
1508
+ *
1509
+ * Phase 21 Plan 21-01: persist per-session provider settings.
1510
+ *
1511
+ * Request body: { settings: { ...providerSpecificFields } }
1512
+ * For Codex sessions, accepted keys are: model, sandbox, approvalPolicy,
1513
+ * reasoningEffort, bypassApprovalsAndSandbox, features.
1514
+ *
1515
+ * Validation:
1516
+ * - 404 if session id is unknown
1517
+ * - 400 if body.settings is not a plain object
1518
+ * - 400 if any key is unknown or its value fails the per-key allow-list
1519
+ * - 400 if a free-form string value contains shell-unsafe characters
1520
+ *
1521
+ * Response: 200 with the canonical persisted bundle.
1522
+ *
1523
+ * Note: pty-manager re-reads providerSettings from the store on every
1524
+ * spawn, so a setting change takes effect the next time the pane is
1525
+ * (re)started. The frontend surfaces a toast hint after a successful PUT.
1526
+ */
1527
+ const CODEX_SANDBOX_VALUES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
1528
+ const CODEX_APPROVAL_VALUES = new Set(['untrusted', 'on-failure', 'on-request', 'never']);
1529
+ const CODEX_EFFORT_VALUES = new Set(['minimal', 'low', 'medium', 'high']);
1530
+ const CODEX_FEATURE_NAME_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
1531
+ const CODEX_MODEL_ID_RE = /^[a-zA-Z0-9._:-]{1,128}$/;
1532
+ const CODEX_ALLOWED_KEYS = new Set([
1533
+ 'model', 'sandbox', 'approvalPolicy', 'reasoningEffort',
1534
+ 'bypassApprovalsAndSandbox', 'features',
1535
+ ]);
1536
+
1537
+ function validateCodexProviderSettings(settings) {
1538
+ if (settings === null || typeof settings !== 'object' || Array.isArray(settings)) {
1539
+ return { ok: false, error: 'settings must be a plain object' };
1540
+ }
1541
+ for (const key of Object.keys(settings)) {
1542
+ if (!CODEX_ALLOWED_KEYS.has(key)) {
1543
+ return { ok: false, error: 'unknown setting key: ' + key };
1544
+ }
1545
+ }
1546
+ if (settings.model !== undefined) {
1547
+ if (typeof settings.model !== 'string' || !CODEX_MODEL_ID_RE.test(settings.model) || SHELL_UNSAFE.test(settings.model)) {
1548
+ return { ok: false, error: 'invalid model id' };
1549
+ }
1550
+ }
1551
+ if (settings.sandbox !== undefined && !CODEX_SANDBOX_VALUES.has(settings.sandbox)) {
1552
+ return { ok: false, error: 'invalid sandbox value' };
1553
+ }
1554
+ if (settings.approvalPolicy !== undefined && !CODEX_APPROVAL_VALUES.has(settings.approvalPolicy)) {
1555
+ return { ok: false, error: 'invalid approvalPolicy value' };
1556
+ }
1557
+ if (settings.reasoningEffort !== undefined && !CODEX_EFFORT_VALUES.has(settings.reasoningEffort)) {
1558
+ return { ok: false, error: 'invalid reasoningEffort value' };
1559
+ }
1560
+ if (settings.bypassApprovalsAndSandbox !== undefined && typeof settings.bypassApprovalsAndSandbox !== 'boolean') {
1561
+ return { ok: false, error: 'bypassApprovalsAndSandbox must be boolean' };
1562
+ }
1563
+ if (settings.features !== undefined) {
1564
+ if (!Array.isArray(settings.features)) {
1565
+ return { ok: false, error: 'features must be an array' };
1566
+ }
1567
+ for (const name of settings.features) {
1568
+ if (typeof name !== 'string' || !CODEX_FEATURE_NAME_RE.test(name) || SHELL_UNSAFE.test(name)) {
1569
+ return { ok: false, error: 'invalid feature name: ' + name };
1570
+ }
1571
+ }
1572
+ }
1573
+ return { ok: true };
1574
+ }
1575
+
1576
+ app.put('/api/sessions/:id/provider-settings', requireAuth, (req, res) => {
1577
+ const store = getStore();
1578
+ const sessionId = req.params.id;
1579
+ const session = store.getSession(sessionId);
1580
+ if (!session) {
1581
+ return res.status(404).json({ error: 'Session not found.' });
1582
+ }
1583
+ const settings = req.body && req.body.settings;
1584
+ if (settings === undefined || settings === null || typeof settings !== 'object' || Array.isArray(settings)) {
1585
+ return res.status(400).json({ error: 'settings must be a plain object' });
1586
+ }
1587
+ const providerId = session.provider || 'claude'; // gsd:provider-literal-allowed (back-compat default; tag for un-tagged legacy sessions)
1588
+ // Per-provider validation. Today only Codex has a settings surface; Claude
1589
+ // gets a 400 until a future plan defines its bundle shape.
1590
+ if (providerId === 'codex') { // gsd:provider-literal-allowed
1591
+ const check = validateCodexProviderSettings(settings);
1592
+ if (!check.ok) {
1593
+ return res.status(400).json({ error: check.error });
1594
+ }
1595
+ } else {
1596
+ return res.status(400).json({ error: 'provider does not accept provider-settings: ' + providerId });
1597
+ }
1598
+ const updated = store.updateSessionProviderSettings(sessionId, providerId, settings);
1599
+ if (!updated) {
1600
+ return res.status(404).json({ error: 'Session not found.' });
1601
+ }
1602
+ return res.json({
1603
+ success: true,
1604
+ sessionId,
1605
+ provider: providerId,
1606
+ settings: updated.providerSettings[providerId],
1607
+ });
1608
+ });
1609
+
1506
1610
  /**
1507
1611
  * POST /api/sessions/:id/start
1508
1612
  * Launch the session process and mark it as recently used.