@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.
- package/dist/adapters/claude.js +2 -2
- package/dist/adapters/codex.d.ts +10 -0
- package/dist/adapters/codex.js +294 -10
- package/dist/adapters/types.d.ts +16 -0
- package/dist/adapters/types.js +16 -0
- package/dist/file-publish.d.ts +27 -0
- package/dist/file-publish.js +82 -0
- package/dist/fsview.d.ts +8 -0
- package/dist/fsview.js +8 -1
- package/dist/index.js +15 -0
- package/dist/protocol.d.ts +39 -0
- package/dist/protocol.js +1 -1
- package/dist/supervisor.d.ts +89 -0
- package/dist/supervisor.js +349 -27
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/adapters/claude.js
CHANGED
|
@@ -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
|
-
|
|
489
|
+
[DEVBRIDGE_MCP_SERVER_NAME]: {
|
|
490
490
|
type: 'http',
|
|
491
491
|
url: spec.mcp.url,
|
|
492
492
|
headers: { Authorization: `Bearer ${spec.mcp.token}` },
|
package/dist/adapters/codex.d.ts
CHANGED
|
@@ -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";
|
package/dist/adapters/codex.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1446
|
-
|
|
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
|
-
...(
|
|
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
|
|
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)
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -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
|
package/dist/adapters/types.js
CHANGED
|
@@ -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
|
-
|
|
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);
|