@bridge4dev/runner 0.45.1 → 0.46.1

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,7 +5,7 @@ import { AsyncQueue } from '../async-queue.js';
5
5
  import { log } from '../log.js';
6
6
  import { mcpConfigPath } from '../paths.js';
7
7
  import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
8
- import { availableModes, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, } from './types.js';
8
+ import { availableModes, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
9
9
  import { percentFromUtilization, RATE_WINDOW_MINUTES, rateWindowKey } from './rate-limits.js';
10
10
  import { applyUsagePercentages, parseUsageText, probeUsageText } from './claude-usage.js';
11
11
  import { claudeCliPath } from '../agent-binary.js';
@@ -486,7 +486,7 @@ class ClaudeSession {
486
486
  // to a 0600 file before the CLI is spawned and removed in `stop()`.
487
487
  const mcpServers = spec.mcp
488
488
  ? {
489
- devbridge: {
489
+ [DEVBRIDGE_MCP_SERVER_NAME]: {
490
490
  type: 'http',
491
491
  url: spec.mcp.url,
492
492
  headers: { Authorization: `Bearer ${spec.mcp.token}` },
@@ -81,6 +81,16 @@ export declare function readEfforts(value: unknown): EffortOption[];
81
81
  * we say we do not know — which is the honest answer and the safe one.
82
82
  */
83
83
  export declare function codexResetsAt(raw: unknown): string | null;
84
+ /**
85
+ * The reset time as a sentence, not as a machine stamp.
86
+ *
87
+ * `2026-08-31T14:02:42.000Z` in the middle of a feed reads like a log line that
88
+ * escaped. The hour and minute in UTC is what a person actually needs, and UTC
89
+ * is named rather than converted: this runs on a dev server whose timezone has
90
+ * nothing to do with the reader's, and a silently wrong local time is worse
91
+ * than an explicit foreign one.
92
+ */
93
+ export declare function describeResetTime(iso: string): string;
84
94
  export declare class CodexAdapter implements AgentAdapter {
85
95
  private readonly deps;
86
96
  readonly id: "codex";
@@ -5,7 +5,7 @@ import { RUNNER_VERSION } from '../version.js';
5
5
  import { repairCodexAuth } from './codex-home.js';
6
6
  import { AppServerClient, asRecord, num, str } from './codex-protocol.js';
7
7
  import { truncate } from './claude.js';
8
- import { availableModes, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, } from './types.js';
8
+ import { availableModes, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
9
9
  import { clampPercent, rateWindowKeyFromMinutes } from './rate-limits.js';
10
10
  import { answerSummary, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
11
11
  // Codex adapter over `codex app-server` (stage C). The normalized AgentEvent
@@ -267,6 +267,15 @@ class CodexSession {
267
267
  // `missing`, not `expired`: nothing expired, there is simply no login.
268
268
  code: 'auth_missing',
269
269
  });
270
+ // The fourth door, and the one that matters most now: this is the
271
+ // ordinary «Codex was never signed in on this machine» path, and
272
+ // `auth_missing` is the only code that still earns a relaunch. With
273
+ // `finish()` alone the app-server we just started is orphaned — it
274
+ // sets `stopped`, so the later `stop()` returns on its first line and
275
+ // nothing ever kills the child — and the relaunch then starts a
276
+ // second one beside it. Two strays per session, from the branch
277
+ // built to recover. Found by independent QA, 01.09.2026.
278
+ this.stop();
270
279
  this.finish();
271
280
  return;
272
281
  }
@@ -297,6 +306,13 @@ class CodexSession {
297
306
  // Before finish(): it ends the output queue, so emitting after it would
298
307
  // drop the error entirely and the session would just stop with no reason.
299
308
  this.classifyAndEmitFailure(error);
309
+ // `stop()` before `finish()`, not instead of it. The start may have failed
310
+ // with the app-server alive and well (a refused sign-in does exactly
311
+ // that), and `finish()` alone sets `stopped` — after which `stop()`
312
+ // returns on its first line and nothing ever kills the child. `finish()`
313
+ // still follows, because a client that died before it could spawn will
314
+ // never fire the exit handler that would otherwise end the queue.
315
+ this.stop();
300
316
  this.finish();
301
317
  }
302
318
  }
@@ -463,7 +479,7 @@ class CodexSession {
463
479
  return {};
464
480
  return {
465
481
  mcp_servers: {
466
- devbridge: {
482
+ [DEVBRIDGE_MCP_SERVER_NAME]: {
467
483
  url: mcp.url,
468
484
  http_headers: { Authorization: `Bearer ${mcp.token}` },
469
485
  // Reading and updating tickets is what the session is FOR — the same
@@ -541,6 +557,13 @@ class CodexSession {
541
557
  this.activeTurnId = str(turn['id']) ?? this.activeTurnId;
542
558
  })
543
559
  .catch((error) => {
560
+ // #344. This rejection was the back door: it reported every failure as
561
+ // a bare turn ending with the provider's raw sentence and no code, so
562
+ // a refused sign-in arrived as «Please log out and sign in again» —
563
+ // which people read as being about their DevBridge account — with
564
+ // nothing for the dashboard to offer a «Sign in» button on.
565
+ if (this.endedOnAuthFailure(describe(error)))
566
+ return;
544
567
  this.emit({ type: 'turn_end', ok: false, errorMessage: describe(error) });
545
568
  });
546
569
  }
@@ -1196,6 +1219,21 @@ class CodexSession {
1196
1219
  if (willRetry)
1197
1220
  return;
1198
1221
  const detail = asRecord(params['error']);
1222
+ // #258, reopened 31.08.2026. A spent plan arrives here FIRST and as a
1223
+ // turn failure only afterwards — on production the two were seventy-one
1224
+ // seconds apart. Treated as a crash, the first one filed the session
1225
+ // FAILED, which is terminal, and every later frame was answered with
1226
+ // «that session is gone»: the auto-pause could not arm, the reset time
1227
+ // was never read, and the person saw the same sentence eight times over
1228
+ // a dead session. Claude has no equivalent path, which is the whole of
1229
+ // why it behaved and this did not.
1230
+ if (this.noteRateLimitRefusal(detail))
1231
+ return;
1232
+ // #344. The one door of the three that always did classify — but it
1233
+ // left the process running, so the session sat FAILED beside a live
1234
+ // agent that would refuse every turn with the same credential.
1235
+ if (this.endedOnAuthFailure(str(detail['message']) ?? ''))
1236
+ return;
1199
1237
  this.classifyAndEmitFailure(new Error(str(detail['message']) ?? 'Codex reported an error'));
1200
1238
  return;
1201
1239
  }
@@ -1220,10 +1258,20 @@ class CodexSession {
1220
1258
  return;
1221
1259
  }
1222
1260
  case 'mcpServer/startupStatus/updated': {
1223
- const name = str(params['name']) ?? 'MCP server';
1261
+ // Named or not, the sentence below already says «MCP server» — an
1262
+ // absent name used to make it say so twice.
1263
+ const name = str(params['name']);
1224
1264
  const status = str(params['status']);
1225
1265
  if (status === 'failed') {
1226
- this.notice('warn', `MCP server ${name} failed to start — the agent cannot reach tickets`);
1266
+ // #346. The sentence about tickets belongs to OUR server and to no
1267
+ // other. Codex starts built-in ones of its own — `codex_apps`, its
1268
+ // ChatGPT Apps connector, which fails precisely when the sign-in is
1269
+ // dead — and saying «the agent cannot reach tickets» over a
1270
+ // `devbridge` that is up with all 29 tools is a false alarm that
1271
+ // costs the true alarm its credibility.
1272
+ this.notice('warn', name === DEVBRIDGE_MCP_SERVER_NAME
1273
+ ? `MCP server ${name} failed to start — the agent cannot reach tickets`
1274
+ : `MCP server ${name ?? '(unnamed)'} failed to start — its tools are unavailable to the agent`);
1227
1275
  }
1228
1276
  return;
1229
1277
  }
@@ -1442,8 +1490,45 @@ class CodexSession {
1442
1490
  resetsAt: codexResetsAt(window['resetsAt'] ?? window['resets_at']),
1443
1491
  });
1444
1492
  }
1445
- this.rateLimitWindows = windows.sort((a, b) => (a.windowMinutes ?? Number.MAX_SAFE_INTEGER) - (b.windowMinutes ?? Number.MAX_SAFE_INTEGER));
1446
- this.ratePlanType = str(snapshot['planType'] ?? snapshot['plan_type']) ?? null;
1493
+ /**
1494
+ * A snapshot with no windows never LOWERS what we know (#258, 31.08.2026).
1495
+ *
1496
+ * Codex sends one at the exact moment it refuses a turn: on production the
1497
+ * five-hour window was reported at 100 % with a reset time of 14:02, and
1498
+ * six hundred milliseconds later the same process sent an empty snapshot,
1499
+ * then the refusal. `rateLimitRefusal` borrows its clock from this list, so
1500
+ * by the time it looked there was nothing to borrow — the block went out as
1501
+ * `{key: 'other', resetsAt: null}`, the API answered «reset time unknown»
1502
+ * and the session stopped instead of sleeping until the window reopened.
1503
+ *
1504
+ * «No plan» stays expressible, because that is the case where a window has
1505
+ * never arrived at all — an API-key account sends empty snapshots and
1506
+ * nothing else, and `available` below is still false for it. What cannot
1507
+ * happen any more is knowing something and then unknowing it.
1508
+ */
1509
+ const merged = new Map(this.rateLimitWindows.map((window) => [window.key, window]));
1510
+ for (const window of windows)
1511
+ merged.set(window.key, window);
1512
+ this.rateLimitWindows = [...merged.values()].sort((a, b) => (a.windowMinutes ?? Number.MAX_SAFE_INTEGER) - (b.windowMinutes ?? Number.MAX_SAFE_INTEGER));
1513
+ /**
1514
+ * Merged BY WINDOW, not snapshot-for-snapshot.
1515
+ *
1516
+ * Codex routinely sends a snapshot carrying one slot with the other null —
1517
+ * the live payload recorded above is exactly that — so replacing the list
1518
+ * wholesale erases a window that has not gone anywhere. It matters because
1519
+ * `rateLimitRefusal` borrows its clock from the FULLEST known window: lose
1520
+ * the five-hour one and the refusal borrows the weekly one instead, whose
1521
+ * reset is days out, and the API answers «longer than five hours» and calls
1522
+ * a human rather than sleeping twenty minutes.
1523
+ *
1524
+ * The cost is a window that lingers after Codex stops mentioning it, with a
1525
+ * percentage going stale on the ring. That is the smaller error: a stale
1526
+ * percentage misinforms, a missing clock changes what the product does.
1527
+ *
1528
+ * The plan type survives an empty snapshot for the same reason — «pro»
1529
+ * does not stop being true because one update carried no fields.
1530
+ */
1531
+ this.ratePlanType = str(snapshot['planType'] ?? snapshot['plan_type']) ?? this.ratePlanType;
1447
1532
  this.emitRateLimits();
1448
1533
  }
1449
1534
  emitRateLimits(blocked = null) {
@@ -1494,6 +1579,103 @@ class CodexSession {
1494
1579
  this.turnIrreversible = true;
1495
1580
  }
1496
1581
  }
1582
+ /** A refusal already recognised, waiting for the turn it belongs to to end. */
1583
+ limitBlockPending = false;
1584
+ limitSettleTimer;
1585
+ /** A turn this adapter closed itself; Codex's own late ending is ignored. */
1586
+ selfSettledTurnId = null;
1587
+ /**
1588
+ * How long a recognised refusal waits for `turn/completed` before we end the
1589
+ * turn ourselves. Codex took seventy-one seconds on the production incident,
1590
+ * so this is generous on purpose: the cost of waiting is a session that says
1591
+ * «working» a little too long, and the cost of not waiting is two endings for
1592
+ * one turn.
1593
+ */
1594
+ static LIMIT_SETTLE_MS = 180_000;
1595
+ /**
1596
+ * The plan is spent — say so, and keep the session alive to be woken.
1597
+ *
1598
+ * This is the Codex half of what the Claude adapter already does through
1599
+ * `limitBlockPending`: a refusal is recorded, the block goes out on a
1600
+ * `rate_limits` frame so the API can arm its clock, and the turn ends
1601
+ * carrying `limitBlocked` — which is what keeps the status out of FAILED.
1602
+ * Terminal is the one thing it must not be: `applyRateLimitBlock` refuses to
1603
+ * schedule anything for a session that has already ended, so a session filed
1604
+ * FAILED here can never be the session that wakes up later.
1605
+ *
1606
+ * A `notice` rather than an `error`, deliberately: the supervisor collapses
1607
+ * repeated notices for the life of a session and does not collapse errors, so
1608
+ * the same sentence printed itself once per refusal — eight times in the
1609
+ * feed of the session this was found on.
1610
+ */
1611
+ noteRateLimitRefusal(error) {
1612
+ const blocked = this.rateLimitRefusal(error);
1613
+ if (!blocked)
1614
+ return false;
1615
+ this.emitRateLimits(blocked);
1616
+ this.emit({
1617
+ type: 'notice',
1618
+ level: 'warn',
1619
+ text: 'Codex refused the turn: the plan limit is spent' +
1620
+ (blocked.resetsAt ? `, and it lifts at ${describeResetTime(blocked.resetsAt)}.` : '.'),
1621
+ });
1622
+ /**
1623
+ * The flag is set ONLY while a turn is in flight, and that is not caution.
1624
+ *
1625
+ * Its whole job is to make the ending of THIS turn carry `limitBlocked`. A
1626
+ * refusal that arrives between turns has no ending to mark, so a flag set
1627
+ * here would simply wait — and the next turn, which may be a perfectly good
1628
+ * one after the window reopened, would inherit it and be filed as refused.
1629
+ * The API is told about the refusal either way: `applyRateLimitBlock` is
1630
+ * driven by the `rate_limits` frame above, not by this flag.
1631
+ */
1632
+ if (this.activeTurnId === null)
1633
+ return true;
1634
+ this.limitBlockPending = true;
1635
+ if (this.limitSettleTimer === undefined) {
1636
+ this.limitSettleTimer = setTimeout(() => {
1637
+ this.limitSettleTimer = undefined;
1638
+ if (this.stopped || this.activeTurnId === null)
1639
+ return;
1640
+ // Codex never closed the turn. End it the way `onTurnCompleted` would
1641
+ // have, so the session lands in a status a human can act on instead of
1642
+ // saying «working» for ever.
1643
+ //
1644
+ // Remembered by id, because Codex closing it LATE is the likelier
1645
+ // outcome than never — it was seventy-one seconds late in the incident
1646
+ // — and a second `turn_end` for one turn would report the session back
1647
+ // out of the pause this one is arming.
1648
+ this.selfSettledTurnId = this.activeTurnId;
1649
+ // The turn is over as far as this adapter is concerned. Leaving
1650
+ // `activeTurnId` set would make everything downstream believe an agent
1651
+ // is still working — a steer would be aimed at a turn that has ended,
1652
+ // and the next real turn would look like a turn already in flight.
1653
+ this.activeTurnId = null;
1654
+ this.emit({
1655
+ type: 'turn_end',
1656
+ ok: false,
1657
+ errorMessage: maskString(str(error['message']) ?? 'The plan limit is spent').slice(0, 500),
1658
+ ...(this.consumeLimitBlock() ? { limitBlocked: true } : {}),
1659
+ ...(this.turnProduced ? { produced: true } : {}),
1660
+ });
1661
+ }, CodexSession.LIMIT_SETTLE_MS);
1662
+ this.limitSettleTimer.unref?.();
1663
+ }
1664
+ return true;
1665
+ }
1666
+ /** Read the refusal flag and clear it: one refusal marks exactly one ending. */
1667
+ consumeLimitBlock() {
1668
+ const blocked = this.limitBlockPending;
1669
+ this.limitBlockPending = false;
1670
+ this.clearLimitSettle();
1671
+ return blocked;
1672
+ }
1673
+ clearLimitSettle() {
1674
+ if (this.limitSettleTimer === undefined)
1675
+ return;
1676
+ clearTimeout(this.limitSettleTimer);
1677
+ this.limitSettleTimer = undefined;
1678
+ }
1497
1679
  rateLimitRefusal(error) {
1498
1680
  // The machine-readable cause FIRST. Codex 0.147.0 says `usageLimitExceeded`
1499
1681
  // — Exceeded, not Reached — so not one of the four substrings below ever
@@ -1521,12 +1703,29 @@ class CodexSession {
1521
1703
  // `activeTurnId` so a steered turn anchors on what the thread actually
1522
1704
  // recorded.
1523
1705
  this.lastCompletedTurnId = str(turn['id']) ?? this.activeTurnId ?? this.lastCompletedTurnId;
1706
+ // We already ended this one ourselves when the plan refusal arrived and
1707
+ // Codex did not close the turn in time. Its late ending is the same event,
1708
+ // not a second one — and reporting it would move the session out of the
1709
+ // pause the first ending armed.
1710
+ if (this.selfSettledTurnId !== null && str(turn['id']) === this.selfSettledTurnId) {
1711
+ this.selfSettledTurnId = null;
1712
+ // `activeTurnId` is deliberately untouched. It was cleared when we
1713
+ // settled, so anything sitting in it now belongs to a NEWER turn that
1714
+ // started while Codex was still catching up with this one.
1715
+ return;
1716
+ }
1524
1717
  this.activeTurnId = null;
1525
1718
  // A held plan means the turn ended by proposing, not by finishing the work.
1526
1719
  if (this.heldPlan)
1527
1720
  return;
1528
1721
  if (status === 'failed') {
1529
1722
  const error = asRecord(turn['error']);
1723
+ // #344. A refused sign-in ends the SESSION, not just this turn, so it
1724
+ // leaves through the one funnel below rather than as a turn failure
1725
+ // carrying Codex's own English at the person. Checked before the rate
1726
+ // limit, because the two are alternatives and this one is terminal.
1727
+ if (this.endedOnAuthFailure(str(error['message']) ?? ''))
1728
+ return;
1530
1729
  // Ticket #258. Codex says «you were refused»; it does NOT have to say
1531
1730
  // when the refusal lifts, because `account/rateLimits/updated` has been
1532
1731
  // telling us that all along. So the error only has to be recognised, and
@@ -1535,6 +1734,20 @@ class CodexSession {
1535
1734
  const blocked = this.rateLimitRefusal(error);
1536
1735
  if (blocked)
1537
1736
  this.emitRateLimits(blocked);
1737
+ // The refusal may have been recognised already, on the `error`
1738
+ // notification that precedes this by up to a minute — `consumeLimitBlock`
1739
+ // is what carries it across that gap. Either source is enough; a Codex
1740
+ // that stops carrying the marker on one of the two frames must not cost
1741
+ // the session its pause.
1742
+ //
1743
+ // Consumed on its own line, NEVER as the right-hand side of `||`: in the
1744
+ // production sequence the marker rides on BOTH frames, so short-circuit
1745
+ // evaluation would skip the consume and leave the pending flag set and
1746
+ // the settle timer armed. The timer would then fire a second ending for
1747
+ // a turn that is already over, and the stale flag would mark the next,
1748
+ // unrelated turn as refused.
1749
+ const carried = this.consumeLimitBlock();
1750
+ const refused = Boolean(blocked) || carried;
1538
1751
  // #252: the machine-readable cause, beside the sentence. `codexErrorInfo`
1539
1752
  // is Codex's own enum (`usageLimitExceeded`, `httpConnectionFailed`,
1540
1753
  // `responseStreamConnectionFailed`, `contextWindowExceeded`, …) and it is
@@ -1549,7 +1762,7 @@ class CodexSession {
1549
1762
  // Without this the session goes FAILED — terminal — and the pause armed
1550
1763
  // one line earlier would ring into a dead row and throw the person's
1551
1764
  // queued words away instead of sending them.
1552
- ...(blocked ? { limitBlocked: true } : {}),
1765
+ ...(refused ? { limitBlocked: true } : {}),
1553
1766
  ...(failureCode !== undefined ? { failureCode } : {}),
1554
1767
  ...(typeof failureStatus === 'number' ? { failureStatus } : {}),
1555
1768
  ...(this.turnProduced ? { produced: true } : {}),
@@ -1564,6 +1777,11 @@ class CodexSession {
1564
1777
  type: 'turn_end',
1565
1778
  ok: true,
1566
1779
  ...(status === 'interrupted' ? { aborted: true } : {}),
1780
+ // #258: a refusal can end the turn EITHER way, so the flag rides on both
1781
+ // branches exactly as it does in the Claude adapter. Consuming it here
1782
+ // also disarms the settle timer — a turn that ended is not a turn we are
1783
+ // still owed an ending for.
1784
+ ...(this.consumeLimitBlock() ? { limitBlocked: true } : {}),
1567
1785
  // #300, the twin of the failing branch above: a turn that ended well
1568
1786
  // having produced nothing is usually no turn at all, and the supervisor
1569
1787
  // holds it rather than saying «your turn» over a working agent.
@@ -1738,20 +1956,70 @@ class CodexSession {
1738
1956
  message: 'The previous Codex thread could not be resumed — starting a fresh one',
1739
1957
  code: 'resume_failed',
1740
1958
  });
1741
- return;
1959
+ return 'resume_failed';
1742
1960
  }
1743
1961
  if (isAuthError(message)) {
1744
1962
  const probe = this.probeAuth();
1963
+ const code = probe === 'missing' ? 'auth_missing' : 'auth_expired';
1745
1964
  this.emit({
1746
1965
  type: 'error',
1747
1966
  message: probe === 'missing'
1748
1967
  ? 'Codex is not signed in on this server — sign in from the Server panel'
1749
1968
  : 'Codex authentication was rejected — sign in again from the Server panel',
1750
- code: probe === 'missing' ? 'auth_missing' : 'auth_expired',
1969
+ code,
1751
1970
  });
1752
- return;
1971
+ return code;
1753
1972
  }
1754
1973
  this.emit({ type: 'error', message: `Codex session error: ${message.slice(0, 1_000)}` });
1974
+ return null;
1975
+ }
1976
+ /**
1977
+ * The one way a refused sign-in leaves this adapter (#344).
1978
+ *
1979
+ * Codex reports it through three unrelated doors — the `error` notification,
1980
+ * a rejected `turn/start`, and a `turn/failed` — and only the first went
1981
+ * through the classifier. The other two shipped the provider's own sentence
1982
+ * with no machine-readable code, which is the same as shipping nothing: the
1983
+ * supervisor decides from codes, and so does the dashboard's «Sign in».
1984
+ *
1985
+ * Ending the session is the second half, and it is not tidiness. A credential
1986
+ * the provider has refused will be refused for every later turn, so leaving
1987
+ * the process alive achieves nothing — and the supervisor's one-shot auth
1988
+ * relaunch is discharged in `handleExit`, i.e. only when the process is gone.
1989
+ * That is why the retry it announced never once happened here while Claude's
1990
+ * did: Claude's SDK loop ends its stream on the same failure.
1991
+ *
1992
+ * `stop()` and NOT `finish()`, and the difference is a leaked child: `finish()`
1993
+ * ends the event queue and sets `stopped`, after which `stop()` returns on its
1994
+ * first line and the `codex app-server` process outlives the session. `stop()`
1995
+ * withdraws the open cards, kills the client, and its exit handler calls
1996
+ * `finish()` for us.
1997
+ */
1998
+ endedOnAuthFailure(message) {
1999
+ if (!isAuthError(maskString(message)))
2000
+ return false;
2001
+ /**
2002
+ * One refusal, one report — even though the refusal arrives twice.
2003
+ *
2004
+ * `stop()` is graceful (stdin, SIGTERM, SIGKILL only after five seconds),
2005
+ * and the queue closes on the child's exit, so for those seconds this
2006
+ * object is still dispatching. The production sequence had two of the three
2007
+ * doors fire **20 ms apart** — the `error` notification and then the
2008
+ * rejected `turn/start` — so without this guard the very incident this
2009
+ * change exists for would now print two identical red lines, each with its
2010
+ * own «Sign in» button, and file the session FAILED twice.
2011
+ *
2012
+ * `true`, not `false`: the caller must still suppress its own turn ending.
2013
+ * The refusal HAS been reported; it just was not reported by this call.
2014
+ *
2015
+ * The test fake could not see this — its `kill()` ends the queue
2016
+ * synchronously, which no real child does. Found by independent QA.
2017
+ */
2018
+ if (this.stopped)
2019
+ return true;
2020
+ this.classifyAndEmitFailure(new Error(message));
2021
+ this.stop();
2022
+ return true;
1755
2023
  }
1756
2024
  /**
1757
2025
  * Second opinion on the sign-in.
@@ -1770,6 +2038,7 @@ class CodexSession {
1770
2038
  }
1771
2039
  finish() {
1772
2040
  this.stopped = true;
2041
+ this.clearLimitSettle();
1773
2042
  this.output.end();
1774
2043
  }
1775
2044
  }
@@ -1970,6 +2239,21 @@ export function codexResetsAt(raw) {
1970
2239
  return null;
1971
2240
  return new Date(raw * 1000).toISOString();
1972
2241
  }
2242
+ /**
2243
+ * The reset time as a sentence, not as a machine stamp.
2244
+ *
2245
+ * `2026-08-31T14:02:42.000Z` in the middle of a feed reads like a log line that
2246
+ * escaped. The hour and minute in UTC is what a person actually needs, and UTC
2247
+ * is named rather than converted: this runs on a dev server whose timezone has
2248
+ * nothing to do with the reader's, and a silently wrong local time is worse
2249
+ * than an explicit foreign one.
2250
+ */
2251
+ export function describeResetTime(iso) {
2252
+ const at = Date.parse(iso);
2253
+ if (Number.isNaN(at))
2254
+ return iso;
2255
+ return `${new Date(at).toISOString().slice(11, 16)} UTC`;
2256
+ }
1973
2257
  function stringifyMcpResult(item) {
1974
2258
  const errorMessage = str(asRecord(item['error'])['message']);
1975
2259
  if (errorMessage)
@@ -648,4 +648,20 @@ export interface AgentAdapter {
648
648
  readonly id: 'claude' | 'codex';
649
649
  startSession(spec: SessionSpec): AgentSession;
650
650
  }
651
+ /**
652
+ * The name our MCP server is registered under inside an agent session.
653
+ *
654
+ * Both adapters register it under this key, and since #346 the Codex adapter
655
+ * also has to tell OUR server apart from the CLI's own built-in ones before it
656
+ * may claim that a failed handshake cost the agent its tickets. It claimed that
657
+ * about every server for months — including over a `devbridge` that was up with
658
+ * all 29 tools — and a false alarm is what teaches people to ignore the true
659
+ * one. So the name is compared, and a name that is compared must not be a
660
+ * literal spelled in three places.
661
+ *
662
+ * Unrelated to the `devbridge-<projectId>` naming the MCP setup tab generates
663
+ * for a user's OWN CLI config: that one is global and has to be unique per
664
+ * project, this one is scoped to a single session we launch ourselves.
665
+ */
666
+ export declare const DEVBRIDGE_MCP_SERVER_NAME = "devbridge";
651
667
  //# sourceMappingURL=types.d.ts.map
@@ -33,4 +33,20 @@ export const MODE_WITHDRAWN_TEXT = 'This project was just set to Strict trust, s
33
33
  export function availableModes(trustMode) {
34
34
  return trustMode === 'STRICT' ? AGENT_MODES.filter((mode) => mode !== 'full') : [...AGENT_MODES];
35
35
  }
36
+ /**
37
+ * The name our MCP server is registered under inside an agent session.
38
+ *
39
+ * Both adapters register it under this key, and since #346 the Codex adapter
40
+ * also has to tell OUR server apart from the CLI's own built-in ones before it
41
+ * may claim that a failed handshake cost the agent its tickets. It claimed that
42
+ * about every server for months — including over a `devbridge` that was up with
43
+ * all 29 tools — and a false alarm is what teaches people to ignore the true
44
+ * one. So the name is compared, and a name that is compared must not be a
45
+ * literal spelled in three places.
46
+ *
47
+ * Unrelated to the `devbridge-<projectId>` naming the MCP setup tab generates
48
+ * for a user's OWN CLI config: that one is global and has to be unique per
49
+ * project, this one is scoped to a single session we launch ourselves.
50
+ */
51
+ export const DEVBRIDGE_MCP_SERVER_NAME = 'devbridge';
36
52
  //# sourceMappingURL=types.js.map
@@ -0,0 +1,27 @@
1
+ export interface PublishFileArgs {
2
+ /** Корень рабочей копии сессии. */
3
+ root: string;
4
+ /** Путь относительно корня. */
5
+ relPath: string;
6
+ /** Одноразовый слот, выданный API. */
7
+ slotId: string;
8
+ /** Сколько байт разрешено, по мнению API. */
9
+ maxBytes: number;
10
+ /** Адрес API, с которым спарен ЭТОТ раннер. */
11
+ apiUrl: string;
12
+ /** Токен этого раннера. */
13
+ token: string;
14
+ fetchImpl?: typeof fetch;
15
+ }
16
+ export interface PublishFileResult {
17
+ fileName: string;
18
+ size: number;
19
+ }
20
+ /** Прочитать файл по правилам просмотра и убедиться, что его можно отдать. */
21
+ export declare function readFileForPublish(root: string, relPath: string, maxBytes: number): {
22
+ buffer: Buffer;
23
+ fileName: string;
24
+ displayPath: string;
25
+ };
26
+ export declare function publishFile(args: PublishFileArgs): Promise<PublishFileResult>;
27
+ //# sourceMappingURL=file-publish.d.ts.map
@@ -0,0 +1,82 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { resolveInsideRoot } from './fsview.js';
4
+ import { isSecretPath } from './policy.js';
5
+ import { log } from './log.js';
6
+ /**
7
+ * Выложить файл с этой машины в хранилище платформы (0.46.0).
8
+ *
9
+ * Что здесь происходит и почему именно так:
10
+ *
11
+ * - **Адрес собирается из СВОЕГО `apiUrl`, а не из присланного.** Команда с
12
+ * той стороны говорит только «какой файл» и «в какой слот», но не «куда
13
+ * отправить». Тот же принцип, по которому раннер отказывается брать код
14
+ * откуда угодно, кроме сервера, с которым спарен: иначе одна подделанная
15
+ * команда превращается в способ вытащить файл с чужой машины на чужой хост.
16
+ *
17
+ * - **Правила пути — те же, что у просмотра.** `resolveInsideRoot` (выход за
18
+ * корень, символические ссылки, `.git`) плюс `isSecretPath` (`.env`, ключи).
19
+ * Своих правил здесь нет ни одного.
20
+ *
21
+ * - **Содержимое НЕ маскируется.** `fs_view` заменяет секреты в предпросмотре,
22
+ * но опубликованный архив с подменёнными байтами — битый архив, а документ с
23
+ * тихо изменённым текстом — ложь. Защита здесь — отказ по `isSecretPath` и
24
+ * то, что файл выбирает человек, а не агент.
25
+ */
26
+ /**
27
+ * Свой потолок поверх присланного.
28
+ *
29
+ * Присланное число — просьба, а не приказ: сторона API может однажды попросить
30
+ * прочитать гигабайт, и машина владельца не обязана соглашаться.
31
+ */
32
+ const HARD_MAX_BYTES = 64 * 1024 * 1024;
33
+ const UPLOAD_TIMEOUT_MS = 120_000;
34
+ /** Прочитать файл по правилам просмотра и убедиться, что его можно отдать. */
35
+ export function readFileForPublish(root, relPath, maxBytes) {
36
+ const target = resolveInsideRoot(root, relPath);
37
+ if (isSecretPath(target) || isSecretPath(path.basename(target))) {
38
+ throw new Error('This path is protected by runner policy');
39
+ }
40
+ const stat = fs.statSync(target);
41
+ if (!stat.isFile())
42
+ throw new Error('Not a regular file');
43
+ if (stat.size === 0)
44
+ throw new Error('The file is empty');
45
+ const ceiling = Math.min(maxBytes, HARD_MAX_BYTES);
46
+ if (stat.size > ceiling) {
47
+ throw new Error(`The file is larger than ${Math.round(ceiling / 1024 / 1024)}MB and cannot be published`);
48
+ }
49
+ const rootReal = fs.realpathSync(root);
50
+ return {
51
+ buffer: fs.readFileSync(target),
52
+ fileName: path.basename(target),
53
+ displayPath: path.relative(rootReal, target) || path.basename(target),
54
+ };
55
+ }
56
+ export async function publishFile(args) {
57
+ const doFetch = args.fetchImpl ?? fetch;
58
+ const { buffer, fileName, displayPath } = readFileForPublish(args.root, args.relPath, args.maxBytes);
59
+ const base = args.apiUrl.replace(/\/$/, '');
60
+ const response = await doFetch(`${base}/api/v1/dev/runner/uploads/${encodeURIComponent(args.slotId)}`, {
61
+ method: 'POST',
62
+ headers: {
63
+ Authorization: `Bearer ${args.token}`,
64
+ 'Content-Type': 'application/octet-stream',
65
+ // Имя файла и путь — в заголовках, а не в теле: тело здесь это ровно
66
+ // байты файла и ничего больше. Кодирование обязательно — в заголовок
67
+ // нельзя положить произвольный UTF-8 и уж точно нельзя перевод строки.
68
+ 'X-Devbridge-File-Name': encodeURIComponent(fileName),
69
+ 'X-Devbridge-File-Path': encodeURIComponent(displayPath),
70
+ },
71
+ body: new Uint8Array(buffer),
72
+ signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
73
+ });
74
+ if (!response.ok) {
75
+ const body = (await response.json().catch(() => null));
76
+ const reason = body?.error?.message ?? `HTTP ${response.status}`;
77
+ log.warn('file-publish: upload rejected', { slotId: args.slotId, reason });
78
+ throw new Error(reason);
79
+ }
80
+ return { fileName, size: buffer.length };
81
+ }
82
+ //# sourceMappingURL=file-publish.js.map
package/dist/fsview.d.ts CHANGED
@@ -16,5 +16,13 @@ export interface FsEntry {
16
16
  type: 'dir' | 'file';
17
17
  size: number | null;
18
18
  }
19
+ /**
20
+ * Разрешение пути внутри корня рабочей копии.
21
+ *
22
+ * Экспортируется, потому что публикация файла (`file-publish.ts`) обязана
23
+ * применять РОВНО те же правила, что и просмотр: своя копия этой функции — это
24
+ * второй набор правил, который однажды разойдётся с первым.
25
+ */
26
+ export declare function resolveInsideRoot(root: string, relPath: string): string;
19
27
  export declare function fsView(root: string, relPath?: string): FsViewResult;
20
28
  //# sourceMappingURL=fsview.d.ts.map
package/dist/fsview.js CHANGED
@@ -18,7 +18,14 @@ function assertRepoRoot(rootReal) {
18
18
  throw new Error('The workspace path is not a git repository');
19
19
  }
20
20
  }
21
- function resolveInsideRoot(root, relPath) {
21
+ /**
22
+ * Разрешение пути внутри корня рабочей копии.
23
+ *
24
+ * Экспортируется, потому что публикация файла (`file-publish.ts`) обязана
25
+ * применять РОВНО те же правила, что и просмотр: своя копия этой функции — это
26
+ * второй набор правил, который однажды разойдётся с первым.
27
+ */
28
+ export function resolveInsideRoot(root, relPath) {
22
29
  let rootReal;
23
30
  try {
24
31
  rootReal = fs.realpathSync(root);