tickmarkr 1.56.0 → 1.57.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.
@@ -5,4 +5,6 @@ export declare function readCodexModelsCache(path?: string): {
5
5
  };
6
6
  export declare function seedCodexTrust(repoRoot: string, configPath?: string): TrustVerdict;
7
7
  export declare function hasCodexTrustedProject(text: string, root: string): boolean;
8
+ export declare function codexConfigMcpServerNames(configPath?: string): string[];
9
+ export declare function codexMcpSuppressionFlags(configPath?: string): string;
8
10
  export declare const codex: WorkerAdapter;
@@ -123,6 +123,44 @@ export function hasCodexTrustedProject(text, root) {
123
123
  const re = new RegExp(String.raw `\[projects\.(?:"${esc}"|'${esc}')\][^\[]*?trust_level\s*=\s*"trusted"`, "s");
124
124
  return re.test(text);
125
125
  }
126
+ // v1.57 T1 / OBS-82: first key segment after `mcp_servers.` — bare, "basic", or 'literal' TOML
127
+ // keys; sub-tables ([mcp_servers.x.env]) dedupe to their server name. Fails OPEN to [] on a
128
+ // missing/unreadable config (fresh install has none — base flags must still work).
129
+ export function codexConfigMcpServerNames(configPath) {
130
+ const p = configPath ?? join(process.env.CODEX_HOME || join(homedir(), ".codex"), "config.toml");
131
+ let text;
132
+ try {
133
+ text = readFileSync(p, "utf8");
134
+ }
135
+ catch {
136
+ return [];
137
+ }
138
+ const names = new Set();
139
+ for (const m of text.matchAll(/^[ \t]*\[mcp_servers\.(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))/gm)) {
140
+ names.add((m[1] ?? m[2] ?? m[3]));
141
+ }
142
+ return [...names];
143
+ }
144
+ // OBS-24 → OBS-82: -c 'mcp_servers={}' was codex's analog of claude's --strict-mcp-config, but
145
+ // codex ≥0.144 MERGES the empty inline table with config instead of replacing it (live probe
146
+ // 2026-07-18, codex-cli 0.144.5: `mcp list` identical with and without the override) — so a down
147
+ // operator-global MCP server wedges startup indefinitely again (OBS-82: 45m spinner). No global
148
+ // MCP kill switch exists (`codex features list` has nothing MCP-shaped), and plugin-bundled
149
+ // servers (~/.codex/plugins/cache) never appear under [mcp_servers.*], so suppression is
150
+ // two-pronged: --disable plugins kills plugin loading (incl. the OBS-82 sites-design-picker),
151
+ // per-name enabled=false overrides kill every server named in $CODEX_HOME/config.toml. The empty
152
+ // table stays for older codex (replace semantics there; harmless no-op under merge). The LIVE
153
+ // test in real-adapters.test.ts runs this exact builder against the real `codex mcp list`
154
+ // surface and asserts zero enabled servers — executable proof, not a comment claim. Scanned
155
+ // names reach the shell line ONLY through shq — config values flow into shells.
156
+ export function codexMcpSuppressionFlags(configPath) {
157
+ const flags = ["--disable plugins", `-c 'mcp_servers={}'`];
158
+ for (const name of codexConfigMcpServerNames(configPath)) {
159
+ const key = /^[A-Za-z0-9_-]+$/.test(name) ? name : JSON.stringify(name);
160
+ flags.push(`-c ${shq(`mcp_servers.${key}.enabled=false`)}`);
161
+ }
162
+ return flags.join(" ");
163
+ }
126
164
  export const codex = {
127
165
  id: "codex",
128
166
  vendor: "openai",
@@ -132,14 +170,12 @@ export const codex = {
132
170
  probe: async () => probeVersion("codex"),
133
171
  channels: (cfg) => channelsFromConfig("codex", cfg),
134
172
  // --sandbox workspace-write is the autonomous sandbox mode (codex v0.144.1+)
135
- // OBS-24: -c 'mcp_servers={}' is codex's analog of claude's --strict-mcp-configoperator-global
136
- // MCP servers in ~/.codex/config.toml block startup indefinitely when one is down (live-measured
137
- // 2026-07-15: probe wedged >120s with MCP, 30s clean), wedging probes and worktree workers alike.
138
- headlessCommand: (promptFile, model) => `codex exec --sandbox workspace-write -c 'mcp_servers={}' ${GITDIR_WRITABLE} --model ${shq(model)} "$(cat ${shq(promptFile)})"`,
173
+ // MCP suppression built per dispatch (config can change between runs) see codexMcpSuppressionFlags.
174
+ headlessCommand: (promptFile, model) => `codex exec --sandbox workspace-write ${codexMcpSuppressionFlags()} ${GITDIR_WRITABLE} --model ${shq(model)} "$(cat ${shq(promptFile)})"`,
139
175
  // TUI uses expanded -a never -s workspace-write (exec-only flags do not apply)
140
176
  // (--help 2026-07-09: valid approval policies are untrusted|on-request|never; the previously
141
177
  // used `on-failure` is invalid and made codex exit 2 pre-inference)
142
- interactiveCommand: (promptFile, model) => `codex -a never -s workspace-write -c 'mcp_servers={}' ${GITDIR_WRITABLE} --model ${shq(model)} "$(cat ${shq(promptFile)})"`,
178
+ interactiveCommand: (promptFile, model) => `codex -a never -s workspace-write ${codexMcpSuppressionFlags()} ${GITDIR_WRITABLE} --model ${shq(model)} "$(cat ${shq(promptFile)})"`,
143
179
  invoke(task, _cwd, a, ctx) {
144
180
  return { command: this.headlessCommand(ctx.promptFile, a.model) };
145
181
  },
@@ -21,6 +21,7 @@ import { acquireRunLock, releaseRunLock } from "./lock.js";
21
21
  import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
22
22
  import { nextChannel, QUALITY_ENV, route } from "../route/router.js";
23
23
  import { desiredPanes } from "./reconcile.js";
24
+ import { normalizeStallSnapshot } from "./stall.js";
24
25
  const MODE_RANK = { "staff-led": 0, "risk-based": 1, "partner-led": 2 };
25
26
  // An override (flag/spec) re-resolves through loadConfigWithMode itself, via a synthesized repo overlay
26
27
  // carrying routing.mode — floors, explore, lints, and provenance all come from config.ts's preset
@@ -630,7 +631,10 @@ export async function runDaemon(repoRoot, opts = {}) {
630
631
  // OBS-54: reaping keys on new pane output, not dispatch wall clock. Poll at least twice per
631
632
  // stall window (and at the existing 30s cadence for normal windows) so an active worker resets it.
632
633
  const stallWindowMs = taskTimeoutMinutes * 60_000;
633
- let lastOutput = output;
634
+ // OBS-82: the stall clock compares NORMALIZED snapshots so a spinner glyph/elapsed-time
635
+ // repaint is silence, not activity. ONLY this inactivity compare sees normalized text —
636
+ // trailer detection, harvest, paging, and quota checks all read the raw pane.
637
+ let lastStallSnapshot = normalizeStallSnapshot(output);
634
638
  let lastOutputAt = Date.now();
635
639
  while (Date.now() - lastOutputAt < stallWindowMs) {
636
640
  const sliceStart = Date.now();
@@ -649,9 +653,9 @@ export async function runDaemon(repoRoot, opts = {}) {
649
653
  break;
650
654
  }
651
655
  }
652
- const currentOutput = await driver.read(slot, 1000);
653
- if (currentOutput !== lastOutput) {
654
- lastOutput = currentOutput;
656
+ const currentStallSnapshot = normalizeStallSnapshot(await driver.read(slot, 1000));
657
+ if (currentStallSnapshot !== lastStallSnapshot) {
658
+ lastStallSnapshot = currentStallSnapshot;
655
659
  lastOutputAt = Date.now();
656
660
  }
657
661
  // v1.23 T2: piggyback on this poll slice — same cadence as blocked/idle checks, no new timer.
@@ -709,8 +713,10 @@ export async function runDaemon(repoRoot, opts = {}) {
709
713
  // T5: same brand header as the interactive path and the gate/consult dispatch scripts.
710
714
  await driver.run(slot, `${bannerShell()}; ${inv.command}; ${exitMarkerCmd}`);
711
715
  // OBS-54: headless workers have the same output-inactivity budget as visible panes.
716
+ // OBS-82: same normalized-snapshot compare as the interactive site — spinner-only repaints
717
+ // exhaust the budget here too; harvest below still reads the raw pane.
712
718
  const stallWindowMs = taskTimeoutMinutes * 60_000;
713
- let lastOutput = await driver.read(slot, 500);
719
+ let lastStallSnapshot = normalizeStallSnapshot(await driver.read(slot, 500));
714
720
  let lastOutputAt = Date.now();
715
721
  finished = false;
716
722
  while (Date.now() - lastOutputAt < stallWindowMs) {
@@ -720,9 +726,9 @@ export async function runDaemon(repoRoot, opts = {}) {
720
726
  finished = true;
721
727
  break;
722
728
  }
723
- const currentOutput = await driver.read(slot, 500);
724
- if (currentOutput !== lastOutput) {
725
- lastOutput = currentOutput;
729
+ const currentStallSnapshot = normalizeStallSnapshot(await driver.read(slot, 500));
730
+ if (currentStallSnapshot !== lastStallSnapshot) {
731
+ lastStallSnapshot = currentStallSnapshot;
726
732
  lastOutputAt = Date.now();
727
733
  }
728
734
  }
@@ -0,0 +1,4 @@
1
+ /** Normalize one pane snapshot for the stall-inactivity compare (and ONLY that compare — trailer
2
+ * parsing, harvest, waitOutput, and paging read the raw text). Two snapshots that normalize equal
3
+ * are the same frame modulo spinner presentation; any other byte difference is worker activity. */
4
+ export declare function normalizeStallSnapshot(text: string): string;
@@ -0,0 +1,24 @@
1
+ // OBS-82: codex's MCP-startup spinner repaints a braille glyph + elapsed-time cell forever, so the
2
+ // daemon's raw snapshot compare reads a wedged pane as active and the stall clock never fires.
3
+ // This normalizer deletes ONLY presentation tokens from a closed allowlist — ANSI/VT escape
4
+ // sequences, braille-range spinner glyphs, and elapsed-time tokens bound to time-unit suffixes.
5
+ // Every other byte passes through identical: words, paths, server names, and progress counts
6
+ // (a five-of-seven counter change IS activity) all remain change-sensitive. The asymmetry is the
7
+ // design: an allowlist MISS degrades to today's recoverable no-reap behavior, while an over-broad
8
+ // deletion would reap a healthy worker — a new failure class. Grow the allowlist only with
9
+ // captured evidence (tests/fixtures/codex-mcp-spinner/).
10
+ // CSI (with intermediates), OSC (BEL- or ST-terminated), DCS/SOS/PM/APC strings, single-char
11
+ // escapes, and charset selection — the raw-pty forms; herdr pane reads are already rendered.
12
+ // eslint-disable-next-line no-control-regex
13
+ const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x9b[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b[PX^_][^\x1b]*(?:\x1b\\)?|\x1b[()][0-9A-Za-z]|\x1b[0-~]/g;
14
+ // Braille patterns U+2800–U+28FF — the codex spinner cell (captured fixture: ⠋⠙⠸⠴⠦⠇ …).
15
+ const SPINNER_RE = /[⠀-⣿]/g;
16
+ // A digit run (optionally decimal) bound directly to a time-unit suffix, standing alone as a
17
+ // word: 9s, 41s, 3m, 1h, 800ms. Never bare digits — "(6/7)" and "5 of 7" stay change-sensitive.
18
+ const ELAPSED_RE = /(?<![\w.])\d+(?:\.\d+)?(?:ms|[hms])(?!\w)/g;
19
+ /** Normalize one pane snapshot for the stall-inactivity compare (and ONLY that compare — trailer
20
+ * parsing, harvest, waitOutput, and paging read the raw text). Two snapshots that normalize equal
21
+ * are the same frame modulo spinner presentation; any other byte difference is worker activity. */
22
+ export function normalizeStallSnapshot(text) {
23
+ return text.replace(ANSI_RE, "").replace(SPINNER_RE, "").replace(ELAPSED_RE, "");
24
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.56.0",
3
+ "version": "1.57.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",