@bridge4dev/runner 0.63.0 → 0.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,8 @@ import { RUNNER_VERSION } from '../version.js';
6
6
  import { repairCodexAuth } from './codex-home.js';
7
7
  import { AppServerClient, asRecord, num, RpcError, RpcTimeoutError, str, } from './codex-protocol.js';
8
8
  import { truncate } from './claude.js';
9
+ import { AgentTaskTray } from './agent-tasks.js';
10
+ import { CodexSubagents, threadProbeOver } from './codex-subagents.js';
9
11
  import { availableModes, cardDescription, DIRECT_BRANCH_RULE, folderRuleFor, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
10
12
  import { clampPercent, rateWindowKeyFromMinutes } from './rate-limits.js';
11
13
  import { answerSummary, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
@@ -182,6 +184,24 @@ const APPROVAL_METHODS = new Set([
182
184
  'item/permissions/requestApproval',
183
185
  'mcpServer/elicitation/request',
184
186
  ]);
187
+ /**
188
+ * Notifications that concern the whole session whichever thread they name
189
+ * (#382). Everything else from a thread other than the session's own belongs to
190
+ * a helper – see `CodexSession.onHelperNotification`.
191
+ *
192
+ * - `serverRequest/resolved`: a helper's approval is shown as an ordinary card,
193
+ * and the card has to close when it is answered elsewhere;
194
+ * - MCP start-up and the warnings: the helpers share the session's servers and
195
+ * configuration, and each distinct sentence is shown once anyway.
196
+ */
197
+ const SESSION_WIDE = new Set([
198
+ 'serverRequest/resolved',
199
+ 'mcpServer/startupStatus/updated',
200
+ 'configWarning',
201
+ 'warning',
202
+ 'guardianWarning',
203
+ 'deprecationNotice',
204
+ ]);
185
205
  class CodexSession {
186
206
  spec;
187
207
  home;
@@ -236,6 +256,15 @@ class CodexSession {
236
256
  stopped = false;
237
257
  ready = false;
238
258
  capabilitiesInFlight = false;
259
+ /**
260
+ * Helpers at work beside the conversation (#382) – the same tray, and so the
261
+ * same «is anybody still working» rule, as the Claude adapter's.
262
+ */
263
+ tray = new AgentTaskTray({
264
+ emit: (event) => this.emit(event),
265
+ isStopped: () => this.stopped,
266
+ });
267
+ subagents;
239
268
  events = this.output;
240
269
  constructor(spec, home, deps) {
241
270
  this.spec = spec;
@@ -272,6 +301,16 @@ class CodexSession {
272
301
  sessionId: spec.sessionId,
273
302
  ...wiring,
274
303
  });
304
+ this.subagents = new CodexSubagents({
305
+ tray: this.tray,
306
+ ownThreadId: () => this.threadId,
307
+ probe: threadProbeOver((method, params, timeoutMs) => this.client.request(method, params, timeoutMs)),
308
+ isStopped: () => this.stopped,
309
+ ...(deps.subagentReconcileMs === undefined ? {} : { reconcileMs: deps.subagentReconcileMs }),
310
+ ...(deps.subagentSpawnGraceMs === undefined
311
+ ? {}
312
+ : { spawnGraceMs: deps.subagentSpawnGraceMs }),
313
+ });
275
314
  if (this.modeRefusedAtLaunch) {
276
315
  this.notice('warn', MODE_REFUSED_TEXT);
277
316
  this.emit({ type: 'settings', mode: this.mode });
@@ -307,6 +346,17 @@ class CodexSession {
307
346
  throw new Error(`codex is using an unexpected CODEX_HOME (${reportedHome ?? 'not reported'}) — refusing to start the session`);
308
347
  }
309
348
  this.client.notify('initialized', {});
349
+ // The helper set is per PROCESS (#113, #382): a session relaunched after a
350
+ // runner restart must not go on showing the helpers of its previous life,
351
+ // and nothing else would say so until the next one starts or ends.
352
+ //
353
+ // Here and not in the constructor, and that is not tidiness: the
354
+ // supervisor reads the FIRST event of a session as «the process came up»
355
+ // and calls off the watch for a CLI that never boots (#225). A frame
356
+ // emitted before a single byte reached the app-server would answer that
357
+ // watch for every Codex session, on behalf of a process that has said
358
+ // nothing. This is the first moment the process really has.
359
+ this.tray.announceEmpty();
310
360
  if (this.home.auth === 'missing') {
311
361
  // One repair attempt before telling the user their login is broken: the
312
362
  // credential link can be removed under a running daemon, and putting it
@@ -956,6 +1006,9 @@ class CodexSession {
956
1006
  if (this.stopped)
957
1007
  return;
958
1008
  this.stopped = true;
1009
+ // The helpers go with the process; nothing more is published about them.
1010
+ this.subagents.close();
1011
+ this.tray.close();
959
1012
  // Release anything the agent is blocked on so the child can exit cleanly —
960
1013
  // and say in the feed that the runner did it, not the user. A pending card
961
1014
  // used to gutter out looking like a human decision.
@@ -1262,6 +1315,17 @@ class CodexSession {
1262
1315
  }
1263
1316
  // ─── Notifications ─────────────────────────────────────────────────
1264
1317
  onNotification(method, params) {
1318
+ // #382. One app-server runs the session's thread AND every helper it spawns,
1319
+ // and the helpers' notifications arrive on this same connection tagged with
1320
+ // their own thread id (measured on 0.154.0). Read as ours, a helper's
1321
+ // `turn/completed` ended THIS session's turn while it was still working, its
1322
+ // `turn/started` became the turn a Stop or a steer is aimed at, and its
1323
+ // prose went into the feed as this session's answer.
1324
+ const thread = str(params['threadId']);
1325
+ if (thread && this.threadId && thread !== this.threadId && !SESSION_WIDE.has(method)) {
1326
+ this.onHelperNotification(thread, method, params);
1327
+ return;
1328
+ }
1265
1329
  switch (method) {
1266
1330
  case 'item/started':
1267
1331
  case 'item/updated':
@@ -1417,21 +1481,100 @@ class CodexSession {
1417
1481
  return;
1418
1482
  }
1419
1483
  }
1484
+ /**
1485
+ * A notification from another thread in this app-server – a helper's, or one
1486
+ * of Codex's own (#382).
1487
+ *
1488
+ * What it may change is the helper count and, while a turn of this session is
1489
+ * running, that turn's «work was done» flags – and nothing else: not the turn
1490
+ * itself, not the feed, not the context meter or the settings on the header,
1491
+ * and not the session's health. A helper's failure is the helper's; the one
1492
+ * exception is the plan limit, which belongs to the account and refuses this
1493
+ * session next. Its tool calls stay
1494
+ * out of the feed too, and that is not tidiness: the supervisor reads any
1495
+ * tool event from a resting session as «the agent is working» (#185), and no
1496
+ * turn of THIS session would ever come to put it back – a helper finishing
1497
+ * does not start one.
1498
+ *
1499
+ * Its items are still remembered: a helper's command that needs a person
1500
+ * arrives as an ordinary approval request, and the card is only readable
1501
+ * because the item behind it is known.
1502
+ */
1503
+ onHelperNotification(thread, method, params) {
1504
+ switch (method) {
1505
+ case 'item/started':
1506
+ case 'item/updated':
1507
+ case 'item/completed': {
1508
+ const item = asRecord(params['item']);
1509
+ this.rememberItem(item);
1510
+ // What a helper does while this session's turn is running is that turn's
1511
+ // work (#252, #257) – the Claude adapter counts a subagent's tool calls
1512
+ // for the same reason: a helper's `git push` is still a push, and a
1513
+ // failed turn that pushed must not be sent again as if nothing happened.
1514
+ if (method === 'item/started' &&
1515
+ item['type'] === 'commandExecution' &&
1516
+ this.activeTurnId !== null &&
1517
+ this.subagents.isHelper(thread)) {
1518
+ this.noteWork(str(item['command']) ?? '');
1519
+ }
1520
+ this.subagents.onItem(thread, item);
1521
+ return;
1522
+ }
1523
+ case 'turn/started':
1524
+ this.subagents.onHelperTurn(thread, 'started');
1525
+ return;
1526
+ case 'turn/completed': {
1527
+ const turn = asRecord(params['turn']);
1528
+ // A helper refused by the plan limit is this ACCOUNT being refused: the
1529
+ // session's own next turn will be refused too, so the API is told and
1530
+ // the feed says why (#382). Only the announcement — no turn of this
1531
+ // session ended, so there is no ending to mark `limitBlocked`.
1532
+ if (str(turn['status']) === 'failed') {
1533
+ this.announceRateLimitRefusal(asRecord(turn['error']));
1534
+ }
1535
+ this.subagents.onHelperTurn(thread, 'completed', str(turn['status']));
1536
+ return;
1537
+ }
1538
+ case 'thread/closed':
1539
+ this.subagents.onThreadClosed(thread);
1540
+ return;
1541
+ case 'error':
1542
+ if (params['willRetry'] !== true && this.subagents.isHelper(thread)) {
1543
+ const detail = asRecord(params['error']);
1544
+ // The one failure of a helper that is not the helper's own business:
1545
+ // the plan limit is the account's, and the session is next (#382).
1546
+ if (!this.announceRateLimitRefusal(detail)) {
1547
+ log.warn('codex: a helper agent reported an error', {
1548
+ sessionId: this.spec.sessionId,
1549
+ message: maskString(str(detail['message']) ?? '').slice(0, 300),
1550
+ });
1551
+ }
1552
+ }
1553
+ return;
1554
+ default:
1555
+ return;
1556
+ }
1557
+ }
1558
+ /** Keep an item's last state – approval params alone are too thin. */
1559
+ rememberItem(item) {
1560
+ const id = str(item['id']);
1561
+ if (!id)
1562
+ return;
1563
+ this.items.set(id, item);
1564
+ // Bound the cache: a long session would otherwise hold every item.
1565
+ if (this.items.size > 400) {
1566
+ const oldest = this.items.keys().next().value;
1567
+ if (oldest !== undefined)
1568
+ this.items.delete(oldest);
1569
+ }
1570
+ }
1420
1571
  onItem(method, params) {
1421
1572
  const item = asRecord(params['item']);
1422
1573
  const type = str(item['type']);
1423
1574
  const id = str(item['id']);
1424
1575
  if (!type)
1425
1576
  return;
1426
- if (id) {
1427
- this.items.set(id, item);
1428
- // Bound the cache: a long session would otherwise hold every item.
1429
- if (this.items.size > 400) {
1430
- const oldest = this.items.keys().next().value;
1431
- if (oldest !== undefined)
1432
- this.items.delete(oldest);
1433
- }
1434
- }
1577
+ this.rememberItem(item);
1435
1578
  const done = method === 'item/completed';
1436
1579
  switch (type) {
1437
1580
  case 'agentMessage': {
@@ -1556,6 +1699,17 @@ class CodexSession {
1556
1699
  }
1557
1700
  return;
1558
1701
  }
1702
+ case 'collabAgentToolCall':
1703
+ case 'subAgentActivity': {
1704
+ // #382: helpers starting, being handed work and finishing. Counted, and
1705
+ // deliberately NOT a feed row: a helper's `completed` arrives after this
1706
+ // session's turn is over, and a tool event then would put a resting
1707
+ // session back to «working» with no turn to end it (see
1708
+ // `onHelperNotification`).
1709
+ if (this.threadId)
1710
+ this.subagents.onItem(this.threadId, item);
1711
+ return;
1712
+ }
1559
1713
  case 'webSearch':
1560
1714
  case 'dynamicToolCall': {
1561
1715
  if (!done) {
@@ -1741,6 +1895,29 @@ class CodexSession {
1741
1895
  * one turn.
1742
1896
  */
1743
1897
  static LIMIT_SETTLE_MS = 180_000;
1898
+ /**
1899
+ * The plan is spent: tell the API so it can arm its clock, and tell the
1900
+ * person in the feed. Says nothing about whose turn it was.
1901
+ *
1902
+ * Split out of `noteRateLimitRefusal` for the refusals that belong to no turn
1903
+ * of this session at all — a helper's (#382). The block is account-wide: the
1904
+ * same account, the same window, and the next turn of the session will be
1905
+ * refused too. Silence here was a false «your turn» over a plan that had run
1906
+ * out, with nothing in the feed to explain it and no pause armed.
1907
+ */
1908
+ announceRateLimitRefusal(error) {
1909
+ const blocked = this.rateLimitRefusal(error);
1910
+ if (!blocked)
1911
+ return false;
1912
+ this.emitRateLimits(blocked);
1913
+ this.emit({
1914
+ type: 'notice',
1915
+ level: 'warn',
1916
+ text: 'Codex refused the turn: the plan limit is spent' +
1917
+ (blocked.resetsAt ? `, and it lifts at ${describeResetTime(blocked.resetsAt)}.` : '.'),
1918
+ });
1919
+ return true;
1920
+ }
1744
1921
  /**
1745
1922
  * The plan is spent — say so, and keep the session alive to be woken.
1746
1923
  *
@@ -1758,16 +1935,8 @@ class CodexSession {
1758
1935
  * feed of the session this was found on.
1759
1936
  */
1760
1937
  noteRateLimitRefusal(error) {
1761
- const blocked = this.rateLimitRefusal(error);
1762
- if (!blocked)
1938
+ if (!this.announceRateLimitRefusal(error))
1763
1939
  return false;
1764
- this.emitRateLimits(blocked);
1765
- this.emit({
1766
- type: 'notice',
1767
- level: 'warn',
1768
- text: 'Codex refused the turn: the plan limit is spent' +
1769
- (blocked.resetsAt ? `, and it lifts at ${describeResetTime(blocked.resetsAt)}.` : '.'),
1770
- });
1771
1940
  /**
1772
1941
  * The flag is set ONLY while a turn is in flight, and that is not caution.
1773
1942
  *
@@ -1800,6 +1969,7 @@ class CodexSession {
1800
1969
  // is still working — a steer would be aimed at a turn that has ended,
1801
1970
  // and the next real turn would look like a turn already in flight.
1802
1971
  this.activeTurnId = null;
1972
+ this.subagents.endTurn();
1803
1973
  this.emit({
1804
1974
  type: 'turn_end',
1805
1975
  ok: false,
@@ -1864,6 +2034,10 @@ class CodexSession {
1864
2034
  return;
1865
2035
  }
1866
2036
  this.activeTurnId = null;
2037
+ // #382: what is still running, said BEFORE the ending – the supervisor
2038
+ // decides «is this the person's turn» on the count it holds when `turn_end`
2039
+ // arrives.
2040
+ this.subagents.endTurn();
1867
2041
  // A held plan means the turn ended by proposing, not by finishing the work.
1868
2042
  if (this.heldPlan)
1869
2043
  return;
@@ -2310,6 +2484,8 @@ class CodexSession {
2310
2484
  finish() {
2311
2485
  this.stopped = true;
2312
2486
  this.clearLimitSettle();
2487
+ this.subagents.close();
2488
+ this.tray.close();
2313
2489
  this.output.end();
2314
2490
  }
2315
2491
  }
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { promisify } from 'node:util';
5
5
  import { createHash } from 'node:crypto';
6
+ import { cleanGitEnv } from './environment.js';
6
7
  import { checkpointsDir } from './paths.js';
7
8
  import { EMPTY_TREE_SHA } from './gitops.js';
8
9
  import { isSecretPath } from './policy.js';
@@ -90,7 +91,7 @@ async function gitIn(cwd, ...args) {
90
91
  // The project's own environment must not leak in: a GIT_INDEX_FILE or
91
92
  // GIT_DIR inherited from a parent process would silently retarget every
92
93
  // command below at the wrong repository.
93
- env: cleanEnv(),
94
+ env: cleanGitEnv(),
94
95
  });
95
96
  return stdout.replace(/\n$/, '');
96
97
  }
@@ -107,7 +108,7 @@ async function gitStore(store, worktreePath, indexFile, ...args) {
107
108
  timeout: GIT_TIMEOUT_MS,
108
109
  maxBuffer: 32 * 1024 * 1024,
109
110
  env: {
110
- ...cleanEnv(),
111
+ ...cleanGitEnv(),
111
112
  GIT_DIR: store,
112
113
  GIT_WORK_TREE: worktreePath,
113
114
  GIT_INDEX_FILE: indexFile,
@@ -119,22 +120,13 @@ async function gitStore(store, worktreePath, indexFile, ...args) {
119
120
  });
120
121
  return stdout.replace(/\n$/, '');
121
122
  }
122
- function cleanEnv() {
123
- const env = { ...process.env };
124
- delete env['GIT_DIR'];
125
- delete env['GIT_WORK_TREE'];
126
- delete env['GIT_INDEX_FILE'];
127
- delete env['GIT_OBJECT_DIRECTORY'];
128
- delete env['GIT_ALTERNATE_OBJECT_DIRECTORIES'];
129
- return env;
130
- }
131
123
  async function ensureStore(worktreePath) {
132
124
  const { store, objectDir } = await storeFor(worktreePath);
133
125
  if (!fs.existsSync(store)) {
134
126
  fs.mkdirSync(path.dirname(store), { recursive: true, mode: 0o700 });
135
127
  await execFileAsync('git', ['init', '--quiet', '--bare', store], {
136
128
  timeout: GIT_TIMEOUT_MS,
137
- env: cleanEnv(),
129
+ env: cleanGitEnv(),
138
130
  });
139
131
  fs.chmodSync(store, 0o700);
140
132
  }
@@ -326,7 +318,7 @@ async function gitRefs(store, ...args) {
326
318
  const { stdout } = await execFileAsync('git', [...GIT_GLOBAL_ARGS, '--git-dir', store, ...args], {
327
319
  timeout: GIT_TIMEOUT_MS,
328
320
  maxBuffer: 32 * 1024 * 1024,
329
- env: cleanEnv(),
321
+ env: cleanGitEnv(),
330
322
  });
331
323
  return stdout.replace(/\n$/, '');
332
324
  }
@@ -20,6 +20,21 @@ export interface RunnerIdentity {
20
20
  isRoot: boolean;
21
21
  }
22
22
  export declare function runnerIdentity(): RunnerIdentity;
23
+ /**
24
+ * The environment git must be run in, with the caller's own repository scrubbed
25
+ * out of it.
26
+ *
27
+ * A `GIT_DIR` or `GIT_INDEX_FILE` inherited from a parent process silently
28
+ * retargets every git command at a DIFFERENT repository — the runner is started
29
+ * by systemd, but a session's agent is not, and neither is a test. The restore
30
+ * points have run this way since #126; #417 needs the same guarantee for a much
31
+ * blunter reason: `git init` under an inherited `GIT_DIR` initialises somewhere
32
+ * else entirely and reports success.
33
+ *
34
+ * Here rather than in `checkpoints.ts`, where it was written, because it is now
35
+ * the answer to «how does this package run git» and has two callers.
36
+ */
37
+ export declare function cleanGitEnv(): NodeJS.ProcessEnv;
23
38
  export interface PathAccess {
24
39
  path: string;
25
40
  exists: boolean;
@@ -19,6 +19,29 @@ export function runnerIdentity() {
19
19
  }
20
20
  return { user, uid, gid, home: os.homedir(), isRoot: uid === 0 };
21
21
  }
22
+ /**
23
+ * The environment git must be run in, with the caller's own repository scrubbed
24
+ * out of it.
25
+ *
26
+ * A `GIT_DIR` or `GIT_INDEX_FILE` inherited from a parent process silently
27
+ * retargets every git command at a DIFFERENT repository — the runner is started
28
+ * by systemd, but a session's agent is not, and neither is a test. The restore
29
+ * points have run this way since #126; #417 needs the same guarantee for a much
30
+ * blunter reason: `git init` under an inherited `GIT_DIR` initialises somewhere
31
+ * else entirely and reports success.
32
+ *
33
+ * Here rather than in `checkpoints.ts`, where it was written, because it is now
34
+ * the answer to «how does this package run git» and has two callers.
35
+ */
36
+ export function cleanGitEnv() {
37
+ const env = { ...process.env };
38
+ delete env['GIT_DIR'];
39
+ delete env['GIT_WORK_TREE'];
40
+ delete env['GIT_INDEX_FILE'];
41
+ delete env['GIT_OBJECT_DIRECTORY'];
42
+ delete env['GIT_ALTERNATE_OBJECT_DIRECTORIES'];
43
+ return env;
44
+ }
22
45
  /** As root every access check passes, which is true and worth saying out loud. */
23
46
  function canAccess(target, mode) {
24
47
  try {
package/dist/git.d.ts CHANGED
@@ -106,6 +106,42 @@ export interface PathValidation {
106
106
  * '/opt/ids'». That sentence is true and unactionable.
107
107
  */
108
108
  export declare function validateWorkspacePath(workspacePath: string): Promise<PathValidation>;
109
+ /**
110
+ * What became of «create this folder and put git in it» (#417).
111
+ *
112
+ * `created` and `exists` are separate answers on purpose: a folder that was
113
+ * already there is not a failure — the wizard simply goes on and binds it —
114
+ * while `created: false, exists: false` is never a success.
115
+ */
116
+ export interface ProjectDirInit {
117
+ ok: boolean;
118
+ /** This call made the directory. */
119
+ created: boolean;
120
+ /** There was already a directory at this path when we looked. */
121
+ exists: boolean;
122
+ /** The branch the new repository is on, read back rather than assumed. */
123
+ branch?: string;
124
+ error?: string;
125
+ }
126
+ /**
127
+ * Create the project folder and initialise git in it, from the binding window
128
+ * (#417).
129
+ *
130
+ * Two things and no more: ONE directory — the last segment of the path, never a
131
+ * chain of parents — and `git init` with `main` as the branch. No first commit:
132
+ * an empty repository is a legitimate state (#137, and the runner has known how
133
+ * to work in one since 0.63.0), while an «Initial commit» nobody asked for is
134
+ * the thing that makes `git pull` from an existing remote refuse with
135
+ * «unrelated histories» later on.
136
+ *
137
+ * Its own refusal list, because `validateWorkspacePath` has none to share: that
138
+ * function asks «can the runner work here», which is a question about
139
+ * permissions, and every answer it gives is about reaching, reading and writing.
140
+ * «Should anything be created here at all» is a different question and this is
141
+ * the only place that asks it. The API cannot ask it either — it sees the
142
+ * runner's verdict and nothing of the machine — so the list lives here.
143
+ */
144
+ export declare function initProjectDir(target: string): Promise<ProjectDirInit>;
109
145
  /**
110
146
  * The repository's main branch, read locally (ADR 0004).
111
147
  *