@harness-nexus/cli 0.1.0-alpha.3 → 0.1.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.
@@ -2,14 +2,20 @@ import { randomUUID } from 'node:crypto';
2
2
  import { closeSync, fstatSync, openSync, readSync, readdirSync, readFileSync, statSync, } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { acpPermissionOptionSchema, acpToolCallViewSchema, chatPermissionRespondEventSchema, chatPromptEventSchema, chatSessionCloseEventSchema, chatSessionResyncEventSchema, chatSessionStartEventSchema, chatTurnCancelEventSchema, } from '@harness-nexus/shared';
5
+ import { acpPermissionOptionSchema, acpToolCallViewSchema, chatConfigSetEventSchema, chatPermissionRespondEventSchema, chatPromptEventSchema, chatReconcileEventSchema, adaptersReportRequestSchema, chatSessionCloseEventSchema, chatSessionResyncEventSchema, chatSessionStartEventSchema, chatTurnCancelEventSchema, sessionConfigOptionSchema, sessionModeStateSchema, } from '@harness-nexus/shared';
6
6
  import { AcpAgentConnection } from './acp/agent-connection.js';
7
7
  import { resolveAcpCommand } from './acp/adapters.js';
8
+ import { auditAdapterLedger, writeAdapterLedgerEntry } from './adapter-ledger.js';
8
9
  import { createDshLiveMapper, decodeTranscript, dshHistoryItems, findTranscript, nativeZstd, TranscriptTail, } from './dsh-sessions.js';
9
10
  import { TapListener, tapPluginAvailable, tapPluginPath, writeTapPatch, } from './dsh-tap-listener.js';
10
11
  const HISTORY_MAX = 2000;
11
12
  /** How long after arming the tap plugin may take to say hello (design: 3s). */
12
13
  const TAP_HANDSHAKE_MS = 3000;
14
+ /**
15
+ * codex-acp's unknown-model notice (see `mapAcpUpdate`'s agent_message_chunk
16
+ * arm). Anchored on the stable prefix; the model id varies.
17
+ */
18
+ const CODEX_MODEL_METADATA_NOTICE = /^Model metadata for `[^`]*` not found\./;
13
19
  /** Node fs surface for TranscriptTail. */
14
20
  const nodeTailFs = {
15
21
  size(path) {
@@ -49,6 +55,14 @@ export function attachChatHandlers(socket, opts = {}) {
49
55
  */
50
56
  const closedBeforeReady = new Set();
51
57
  const CLOSED_BEFORE_READY_MAX = 64;
58
+ /**
59
+ * 9 W11 E — starts currently mid-establishment (spawn → registration).
60
+ * `chat:reconcile` reports these (when their id is still listed server-side)
61
+ * as held, and flags UNLISTED ones into `closedBeforeReady` so the
62
+ * establishment aborts — a row the server no longer knows must not finish
63
+ * into an unjoinable orphan.
64
+ */
65
+ const inFlightStarts = new Set();
52
66
  const emitEvent = (session, event) => {
53
67
  pushHistory(session, { type: 'event', event });
54
68
  socket.emit('chat:event', { sessionId: session.sessionId, event });
@@ -69,6 +83,19 @@ export function attachChatHandlers(socket, opts = {}) {
69
83
  items: session.history.slice(-HISTORY_MAX),
70
84
  });
71
85
  };
86
+ /**
87
+ * 9 W9 A — emit the session's FULL merged config snapshot as a
88
+ * `session_config` stream event (into the ring and the live room). The
89
+ * browser's selectors settle exclusively through these events — the
90
+ * daemon-side optimistic merge after `chat:config.set` reuses this path.
91
+ */
92
+ const emitConfig = (session) => {
93
+ emitEvent(session, {
94
+ kind: 'session_config',
95
+ ...(session.config.modes !== undefined ? { modes: { ...session.config.modes } } : {}),
96
+ ...(session.config.options.length > 0 ? { configOptions: session.config.options } : {}),
97
+ });
98
+ };
72
99
  const teardown = (session, reason) => {
73
100
  if (sessions.get(session.sessionId) !== session)
74
101
  return;
@@ -79,8 +106,38 @@ export function attachChatHandlers(socket, opts = {}) {
79
106
  clearTimeout(p.timer);
80
107
  session.permissions.clear();
81
108
  session.conn.kill();
109
+ // 9 W11 A — the kill path does NOT unlink the ledger file: if the daemon
110
+ // hard-dies inside the SIGTERM→SIGKILL grace, an unlinked file would
111
+ // lose accounting (the group survives with no record). The audit below
112
+ // removes it once the group is gone.
82
113
  socket.emit('chat:session.closed', { sessionId: session.sessionId, reason });
83
114
  };
115
+ // 9 W11 A — periodic audit: the only runtime remover of ledger files
116
+ // (entries whose process group is gone), and the backstop for a session
117
+ // whose group died without the exit event reaching teardown.
118
+ const auditMs = opts.auditIntervalMs ?? 60_000;
119
+ if (auditMs > 0) {
120
+ const auditTimer = setInterval(() => {
121
+ auditAdapterLedger(home);
122
+ for (const session of [...sessions.values()]) {
123
+ if (!session.conn.isGroupAlive())
124
+ teardown(session, 'agent-exited');
125
+ }
126
+ }, auditMs);
127
+ auditTimer.unref();
128
+ }
129
+ /**
130
+ * 9 W11 E — disconnect grace window. `HN_TEARDOWN_GRACE_MS` (default 8000;
131
+ * 0 restores the pre-W11 immediate teardown). Socket.IO reconnects from a
132
+ * transport blip within ~1s, so holding channels (and in-flight turns) for
133
+ * the window survives the blip; the server delays its offline reap by the
134
+ * same window and reconciles on reconnect.
135
+ */
136
+ const graceMs = (() => {
137
+ const raw = Number.parseInt(env.HN_TEARDOWN_GRACE_MS ?? '', 10);
138
+ return Number.isFinite(raw) ? Math.max(raw, 0) : 8000;
139
+ })();
140
+ let graceTimer = null;
84
141
  // In-flight tail attachments by native session id (one per session).
85
142
  const tailAttaches = new Map();
86
143
  /**
@@ -178,6 +235,7 @@ export function attachChatHandlers(socket, opts = {}) {
178
235
  ack?.({ accepted: true });
179
236
  const { sessionId, target, cwd, resume } = parsed.data;
180
237
  void (async () => {
238
+ inFlightStarts.add(sessionId);
181
239
  const cmd = resolveAcpCommand(target, env);
182
240
  if (cmd === null) {
183
241
  socket.emit('chat:session.ready', {
@@ -207,8 +265,26 @@ export function attachChatHandlers(socket, opts = {}) {
207
265
  if (abortIfClosed())
208
266
  return;
209
267
  try {
268
+ // 9 W11 A — ledger the process group the moment it exists (inside
269
+ // start, right after the detached spawn and before initialize): a
270
+ // hard death during establishment still leaves a boot-sweepable
271
+ // record. The entry is re-written after registration with the native
272
+ // session id; removal is the audit's job, never a kill path.
273
+ const ledgerStartedAt = Date.now();
274
+ let ledgerPgid = null;
275
+ const onSpawned = (pgid) => {
276
+ ledgerPgid = pgid;
277
+ writeAdapterLedgerEntry(home, {
278
+ pgid,
279
+ target,
280
+ command: cmd.command,
281
+ wireSessionId: sessionId,
282
+ startedAt: ledgerStartedAt,
283
+ });
284
+ };
210
285
  const spawnOpts = {
211
286
  cwd,
287
+ onSpawned,
212
288
  ...(tapArmed === null
213
289
  ? opts.spawnEnv !== undefined
214
290
  ? { env: opts.spawnEnv }
@@ -233,7 +309,7 @@ export function attachChatHandlers(socket, opts = {}) {
233
309
  tapArmed.hello,
234
310
  ]);
235
311
  }
236
- const { conn, agentInfo, sessionCaps } = started;
312
+ const { conn, agentInfo, sessionCaps, promptCaps } = started;
237
313
  liveConn = conn;
238
314
  // `mcpServers` is sent explicitly (spec: an array): ACP wrappers
239
315
  // (zed 0.23.x AND the @agentclientprotocol one we ship for claude-code)
@@ -246,9 +322,14 @@ export function attachChatHandlers(socket, opts = {}) {
246
322
  // for provider …"). Retry that specific failure a few times.
247
323
  let acpSessionId;
248
324
  let history = [];
325
+ // 9 W9 A — the establishment response's session-config snapshot
326
+ // (modes + configOptions). Read from whichever arm established the
327
+ // session; null when the adapter advertised nothing (or only junk).
328
+ let establishedConfig = null;
249
329
  if (resume === undefined) {
250
330
  const created = (await establish(conn, 'session/new', { cwd, mcpServers: [] }));
251
331
  acpSessionId = created?.sessionId ?? sessionId;
332
+ establishedConfig = takeSessionConfig(created);
252
333
  }
253
334
  else {
254
335
  // 9 W7 — pick the method from the ADVERTISED capability: `load`
@@ -265,18 +346,20 @@ export function attachChatHandlers(socket, opts = {}) {
265
346
  }));
266
347
  acpSessionId = loaded?.sessionId ?? resume.sessionId;
267
348
  history = finishCaptured(captured);
349
+ establishedConfig = takeSessionConfig(loaded);
268
350
  }
269
351
  finally {
270
352
  stopCapture();
271
353
  }
272
354
  }
273
355
  else if (sessionCaps.resume) {
274
- await establish(conn, 'session/resume', {
356
+ const resumed = (await establish(conn, 'session/resume', {
275
357
  sessionId: resume.sessionId,
276
358
  cwd,
277
359
  mcpServers: [],
278
- });
360
+ }));
279
361
  acpSessionId = resume.sessionId;
362
+ establishedConfig = takeSessionConfig(resumed);
280
363
  history =
281
364
  target === 'deepseek' ? await dshTranscriptHistory(home, resume.sessionId) : [];
282
365
  }
@@ -293,9 +376,12 @@ export function attachChatHandlers(socket, opts = {}) {
293
376
  sessionId,
294
377
  acpSessionId,
295
378
  target,
379
+ command: cmd.command,
380
+ startedAt: ledgerStartedAt,
296
381
  conn,
297
382
  busy: false,
298
383
  permissions: new Map(),
384
+ config: establishedConfig ?? { options: [] },
299
385
  history: [],
300
386
  wireTextEmitted: false,
301
387
  tailReplayEligible: resume === undefined,
@@ -307,6 +393,18 @@ export function attachChatHandlers(socket, opts = {}) {
307
393
  };
308
394
  sessions.set(sessionId, session);
309
395
  liveConn = null; // registered — teardown owns the connection from here
396
+ // 9 W11 A — enrich the ledger entry with the native session id (the
397
+ // adapter report in slice C and post-mortem forensics key off it).
398
+ if (ledgerPgid !== null) {
399
+ writeAdapterLedgerEntry(home, {
400
+ pgid: ledgerPgid,
401
+ target,
402
+ command: cmd.command,
403
+ wireSessionId: sessionId,
404
+ nativeSessionId: acpSessionId,
405
+ startedAt: ledgerStartedAt,
406
+ });
407
+ }
310
408
  wireSession(session, emitEvent);
311
409
  conn.onExit(() => {
312
410
  // Crash/quit outside our control — end the channel honestly.
@@ -348,11 +446,17 @@ export function attachChatHandlers(socket, opts = {}) {
348
446
  // pending attach, so it cannot be active without the tail.
349
447
  ensureTail(session);
350
448
  emitHistory(session, history);
449
+ // 9 W9 A — the authoritative config snapshot rides AFTER the replayed
450
+ // history (a load's replay patches settle first; last write wins) and
451
+ // enters the ring, so a resync restores the selectors.
452
+ if (establishedConfig !== null)
453
+ emitConfig(session);
351
454
  socket.emit('chat:session.ready', {
352
455
  sessionId,
353
456
  nativeSessionId: acpSessionId,
354
457
  ...(agentInfo.name !== undefined ? { agentName: agentInfo.name } : {}),
355
458
  ...(agentInfo.version !== undefined ? { agentVersion: agentInfo.version } : {}),
459
+ promptCapabilities: promptCaps,
356
460
  });
357
461
  }
358
462
  catch (e) {
@@ -363,7 +467,7 @@ export function attachChatHandlers(socket, opts = {}) {
363
467
  error: e instanceof Error ? e.message : String(e),
364
468
  });
365
469
  }
366
- })();
470
+ })().finally(() => inFlightStarts.delete(sessionId));
367
471
  });
368
472
  // ---- server → daemon: prompt / cancel / permission / disconnect / resync ----
369
473
  socket.on('chat:message.send', (payload, ack) => {
@@ -405,6 +509,44 @@ export function attachChatHandlers(socket, opts = {}) {
405
509
  // The pending session/prompt resolves as 'cancelled' → turn_result fires.
406
510
  void session.conn.request('session/cancel', {}, 5000).catch(() => { });
407
511
  });
512
+ // 9 W9 A — switch the live session's permission mode / one config option.
513
+ // NOT busy-gated: dsh pins the selection per PROMPT (a mid-turn set applies
514
+ // to the next turn) and the UI disables the selectors during a turn anyway.
515
+ socket.on('chat:config.set', (payload, ack) => {
516
+ const parsed = chatConfigSetEventSchema.safeParse(payload);
517
+ if (!parsed.success) {
518
+ ack?.({ error: 'proto:invalid' });
519
+ return;
520
+ }
521
+ const session = sessions.get(parsed.data.sessionId);
522
+ if (session === undefined) {
523
+ ack?.({ error: 'unknown-session' });
524
+ return;
525
+ }
526
+ const set = parsed.data;
527
+ const request = set.kind === 'mode' ? 'session/set_mode' : 'session/set_config_option';
528
+ const params = set.kind === 'mode'
529
+ ? { sessionId: session.acpSessionId, modeId: set.modeId }
530
+ : { sessionId: session.acpSessionId, configId: set.configId, value: set.value };
531
+ void session.conn.request(request, params, 15000).then(() => {
532
+ // Optimistic daemon-side merge: adapters confirm/correct through
533
+ // config pushes, but codex does not reliably push after a set —
534
+ // without this the selector would sit on the stale value.
535
+ if (set.kind === 'mode') {
536
+ session.config.modes = {
537
+ currentModeId: set.modeId,
538
+ availableModes: session.config.modes?.availableModes ?? [],
539
+ };
540
+ }
541
+ else {
542
+ session.config.options = session.config.options.map((o) => o.id === set.configId ? { ...o, currentValue: set.value } : o);
543
+ }
544
+ emitConfig(session);
545
+ ack?.({ accepted: true });
546
+ }, (e) => {
547
+ ack?.({ error: e instanceof Error ? e.message : String(e) });
548
+ });
549
+ });
408
550
  socket.on('chat:permission.respond', (payload, ack) => {
409
551
  const parsed = chatPermissionRespondEventSchema.safeParse(payload);
410
552
  if (!parsed.success) {
@@ -468,10 +610,93 @@ export function attachChatHandlers(socket, opts = {}) {
468
610
  }
469
611
  ack?.({ accepted: true });
470
612
  });
471
- socket.on('disconnect', () => {
472
- // Channels die with the daemon's connection; the native sessions survive.
473
- for (const session of [...sessions.values()])
474
- teardown(session, 'daemon-disconnected');
613
+ // 9 W11 C — the adapter report: present-tense process truth from the live
614
+ // sessions map (the LEDGER is crash accounting, not a status surface; a
615
+ // report built from it could list processes that already died). Instant by
616
+ // construction — no spawn, no round-trip beyond the socket.
617
+ socket.on('adapters:report', (payload, ack) => {
618
+ const parsed = adaptersReportRequestSchema.safeParse(payload);
619
+ if (!parsed.success) {
620
+ ack?.({ error: 'proto:invalid' });
621
+ return;
622
+ }
623
+ ack?.({ accepted: true });
624
+ socket.emit('adapters:report:result', {
625
+ requestId: parsed.data.requestId,
626
+ adapters: [...sessions.values()].map((s) => ({
627
+ wireSessionId: s.sessionId,
628
+ target: s.target,
629
+ pgid: s.conn.pgid ?? 0,
630
+ nativeSessionId: s.acpSessionId,
631
+ startedAt: s.startedAt,
632
+ command: s.command,
633
+ })),
634
+ });
635
+ });
636
+ // 9 W11 E — the server's live rows for this machine, sent on EVERY /ctl
637
+ // (re)connect. Drop what it disowned; report what we still hold.
638
+ socket.on('chat:reconcile', (payload, ack) => {
639
+ const parsed = chatReconcileEventSchema.safeParse(payload);
640
+ if (!parsed.success) {
641
+ ack?.({ error: 'proto:invalid' });
642
+ return;
643
+ }
644
+ const listed = new Set(parsed.data.sessionIds);
645
+ // A session whose row is gone server-side (reaped past the server's
646
+ // grace, or the server restarted) is unjoinable and invisible — no tab,
647
+ // no closer — tear it down now instead of leaking the adapter.
648
+ for (const session of [...sessions.values()]) {
649
+ if (!listed.has(session.sessionId))
650
+ teardown(session, 'reconciled');
651
+ }
652
+ // The same orphan risk exists MID-ESTABLISHMENT: flag unlisted starts so
653
+ // they abort at their checkpoints (the close-consume path).
654
+ for (const id of inFlightStarts) {
655
+ if (listed.has(id))
656
+ continue;
657
+ if (closedBeforeReady.size >= CLOSED_BEFORE_READY_MAX)
658
+ closedBeforeReady.clear();
659
+ closedBeforeReady.add(id);
660
+ }
661
+ // Held = registered sessions + establishments in flight for rows the
662
+ // server still knows (reporting an unlisted in-flight start as held
663
+ // would keep a ghost row alive).
664
+ const held = [...sessions.keys(), ...[...inFlightStarts].filter((id) => listed.has(id))];
665
+ ack?.({ held: held.slice(0, 64) });
666
+ });
667
+ // A reconnect within the grace window revives every channel: cancel the
668
+ // pending teardown BEFORE the server's reconcile lands (its list decides
669
+ // what survives; the grace only buys time for the reconnect itself).
670
+ socket.on('connect', () => {
671
+ if (graceTimer !== null) {
672
+ clearTimeout(graceTimer);
673
+ graceTimer = null;
674
+ }
675
+ });
676
+ socket.on('disconnect', (reason) => {
677
+ // 9 W11 E — a deliberate stop ('io client disconnect': SIGTERM → stop())
678
+ // or the kill-switch (HN_TEARDOWN_GRACE_MS=0) tears down immediately,
679
+ // exactly as before. A transport blip instead HOLDS every channel for
680
+ // the grace window: Socket.IO reconnects within ~1s, the server mirrors
681
+ // the grace on its reap, and a mid-grace turn keeps running against the
682
+ // local adapter (packets buffer; the viewer resyncs on rejoin).
683
+ if (graceMs <= 0 || reason === 'io client disconnect') {
684
+ if (graceTimer !== null) {
685
+ clearTimeout(graceTimer);
686
+ graceTimer = null;
687
+ }
688
+ for (const session of [...sessions.values()])
689
+ teardown(session, 'daemon-disconnected');
690
+ return;
691
+ }
692
+ if (graceTimer !== null)
693
+ return; // already armed by an earlier blip cycle
694
+ graceTimer = setTimeout(() => {
695
+ graceTimer = null;
696
+ for (const session of [...sessions.values()])
697
+ teardown(session, 'daemon-disconnected');
698
+ }, graceMs);
699
+ graceTimer.unref();
475
700
  });
476
701
  }
477
702
  /**
@@ -559,13 +784,37 @@ function wireSession(session, emitEvent) {
559
784
  conn.setNotificationHandler((method, params) => {
560
785
  if (method !== 'session/update')
561
786
  return;
787
+ const update = (params.update ?? {});
788
+ // 9 W9 A — session-config pushes merge into the daemon's snapshot and
789
+ // re-emit it in full (needs the session state, so they are intercepted
790
+ // BEFORE the stateless mapping below).
791
+ if (update.sessionUpdate === 'current_mode_update') {
792
+ const modeId = typeof update.currentModeId === 'string' && update.currentModeId !== ''
793
+ ? update.currentModeId
794
+ : null;
795
+ if (modeId !== null) {
796
+ session.config.modes = {
797
+ currentModeId: modeId,
798
+ availableModes: session.config.modes?.availableModes ?? [],
799
+ };
800
+ emitEvent(session, { kind: 'session_config', modes: { ...session.config.modes } });
801
+ }
802
+ return;
803
+ }
804
+ if (update.sessionUpdate === 'config_option_update') {
805
+ const options = takeConfigOptions(update.configOptions);
806
+ if (options !== null) {
807
+ session.config.options = options;
808
+ emitEvent(session, { kind: 'session_config', configOptions: options });
809
+ }
810
+ return;
811
+ }
562
812
  // dsh commits block-level text at turn end — while a streaming source is
563
813
  // live (the transcript tail OR the 9 W7.1 event tap), its deltas already
564
814
  // streamed this content AND its mapper emits the complete blocks for
565
815
  // steps whose deltas it never saw, so the wire's committed chunk is
566
816
  // redundant in every case; letting it through would double-render the
567
817
  // message. Tools/usage still flow (idempotent by callId / field-merged).
568
- const update = (params.update ?? {});
569
818
  if ((session.tail !== null || session.tap !== null) &&
570
819
  (update.sessionUpdate === 'agent_message_chunk' ||
571
820
  update.sessionUpdate === 'agent_thought_chunk')) {
@@ -681,12 +930,72 @@ async function attachTranscriptTail(home, acpSessionId, onEvent, onFatal) {
681
930
  }
682
931
  return null;
683
932
  }
933
+ /**
934
+ * 9 W9 A — validate an adapter's `configOptions` array down to the
935
+ * platform's bounded view: only `type:'select'` rows survive (the three
936
+ * shipped adapters expose mode/model/effort as selects; boolean options from
937
+ * future adapters are dropped rather than half-surfaced). Null = nothing
938
+ * usable in the payload.
939
+ */
940
+ export function takeConfigOptions(raw) {
941
+ if (!Array.isArray(raw))
942
+ return null;
943
+ const out = [];
944
+ for (const row of raw) {
945
+ if (typeof row !== 'object' || row === null)
946
+ continue;
947
+ const r = row;
948
+ if (r['type'] !== undefined && r['type'] !== 'select')
949
+ continue;
950
+ const parsed = sessionConfigOptionSchema.safeParse(r);
951
+ if (parsed.success)
952
+ out.push(parsed.data);
953
+ }
954
+ return out.length > 0 ? out : null;
955
+ }
956
+ /**
957
+ * 9 W9 A — the session-config slice of an establishment response
958
+ * (`session/new` / `load` / `resume`): `modes` + `configOptions`, each
959
+ * independently validated. Null = the adapter advertised nothing usable.
960
+ */
961
+ export function takeSessionConfig(result) {
962
+ const r = (result ?? {});
963
+ const modes = sessionModeStateSchema.safeParse(r['modes']);
964
+ const options = takeConfigOptions(r['configOptions']);
965
+ if (!modes.success && options === null)
966
+ return null;
967
+ return {
968
+ ...(modes.success ? { modes: modes.data } : {}),
969
+ options: options ?? [],
970
+ };
971
+ }
684
972
  /** Map one ACP `session/update` params object; null = drop (user echo). */
685
973
  export function mapAcpUpdate(params) {
686
974
  const update = (params.update ?? {});
687
975
  switch (update.sessionUpdate) {
688
- case 'agent_message_chunk':
689
- return { kind: 'message_delta', delta: chunkText(update) };
976
+ // 9 W9 A — config pushes on the CAPTURE path (session/load replay: no
977
+ // session state to merge into, so the adapter's patch maps verbatim).
978
+ case 'current_mode_update': {
979
+ const modeId = typeof update.currentModeId === 'string' && update.currentModeId !== ''
980
+ ? update.currentModeId
981
+ : null;
982
+ return modeId === null ? null : { kind: 'session_config', modes: { currentModeId: modeId } };
983
+ }
984
+ case 'config_option_update': {
985
+ const options = takeConfigOptions(update.configOptions);
986
+ return options === null ? null : { kind: 'session_config', configOptions: options };
987
+ }
988
+ case 'agent_message_chunk': {
989
+ const delta = chunkText(update);
990
+ // codex-acp announces an unknown gateway model id by streaming a
991
+ // diagnostic AS AN ASSISTANT CHUNK, so it lands mid-transcript as if the
992
+ // model had said it. It is not model output; drop it. (The underlying
993
+ // condition — codex's built-in registry not knowing the custom model id —
994
+ // is what makes it fall back to a default context window; a root-level
995
+ // `model_context_window` in config.toml overrides that window, verified
996
+ // against codex-acp 0.16, but does not silence this notice.)
997
+ return CODEX_MODEL_METADATA_NOTICE.test(delta) ? null : { kind: 'message_delta', delta };
998
+ }
690
999
  case 'agent_thought_chunk':
691
1000
  return { kind: 'thought_delta', delta: chunkText(update) };
692
1001
  case 'tool_call':