@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.
package/dist/git.js CHANGED
@@ -2,8 +2,10 @@ import { execFile } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { promisify } from 'node:util';
5
- import { previewsDir, worktreesDir } from './paths.js';
6
- import { firstUnreachableAncestor, inspectPath, looksLikeDubiousOwnership, runnerIdentity, safeDirectoryCommand, } from './environment.js';
5
+ import { previewsDir, stateDir, worktreesDir } from './paths.js';
6
+ import { cleanGitEnv, firstUnreachableAncestor, inspectPath, looksLikeDubiousOwnership, runnerIdentity, safeDirectoryCommand, } from './environment.js';
7
+ import { isGitInternalPath, isInsideWorktree, isSecretPath } from './policy.js';
8
+ import { log } from './log.js';
7
9
  const execFileAsync = promisify(execFile);
8
10
  const GIT_TIMEOUT_MS = 30_000;
9
11
  /**
@@ -14,12 +16,27 @@ const GIT_TIMEOUT_MS = 30_000;
14
16
  * Ninety seconds is the owner's number (plan §9, 06.09.2026).
15
17
  */
16
18
  const WORKTREE_ADD_TIMEOUT_MS = 90_000;
19
+ /**
20
+ * Every git call in this file names its repository with `cwd` and nothing else,
21
+ * so an inherited `GIT_DIR` (or index, or object store) can only ever point
22
+ * somewhere we did not mean — see `cleanGitEnv`. Free everywhere; load-bearing
23
+ * in `initProjectDir`, where `git init` under an inherited `GIT_DIR` would
24
+ * initialise a different directory and report success (#417).
25
+ */
17
26
  async function git(cwd, ...args) {
18
- const { stdout } = await execFileAsync('git', args, { cwd, timeout: GIT_TIMEOUT_MS });
27
+ const { stdout } = await execFileAsync('git', args, {
28
+ cwd,
29
+ timeout: GIT_TIMEOUT_MS,
30
+ env: cleanGitEnv(),
31
+ });
19
32
  return stdout.trim();
20
33
  }
21
34
  async function gitSlow(cwd, timeoutMs, ...args) {
22
- const { stdout } = await execFileAsync('git', args, { cwd, timeout: timeoutMs });
35
+ const { stdout } = await execFileAsync('git', args, {
36
+ cwd,
37
+ timeout: timeoutMs,
38
+ env: cleanGitEnv(),
39
+ });
23
40
  return stdout.trim();
24
41
  }
25
42
  /**
@@ -252,6 +269,311 @@ export async function validateWorkspacePath(workspacePath) {
252
269
  ...(defaultBranch ? { defaultBranch } : {}),
253
270
  };
254
271
  }
272
+ /**
273
+ * The branch a repository created from the binding window starts on (#417).
274
+ *
275
+ * One name, here, because two calls ask for it — `git init --initial-branch`
276
+ * and the `symbolic-ref` that stands in for it on a git older than 2.28 — and a
277
+ * folder that came out on `master` because the two disagreed would be a bug
278
+ * nobody sees until a push.
279
+ */
280
+ const INITIAL_BRANCH = 'main';
281
+ /**
282
+ * Directories a project folder is never created inside (#417).
283
+ *
284
+ * The root itself and the trees a Linux system is assembled from. Deliberately
285
+ * a short, literal list rather than a clever rule: everything here is a place
286
+ * where `mkdir` succeeding is worse than it failing, and a person who typed one
287
+ * of them meant something else. `/opt`, `/srv`, `/home`, `/var/www` are NOT on
288
+ * it — those are exactly where projects live.
289
+ *
290
+ * `/usr/local` and friends are covered by their parents: the test is «is this
291
+ * path at or below one of these».
292
+ */
293
+ const SYSTEM_DIRECTORIES = [
294
+ '/etc',
295
+ '/usr',
296
+ '/bin',
297
+ '/sbin',
298
+ '/lib',
299
+ '/lib32',
300
+ '/lib64',
301
+ '/libx32',
302
+ '/boot',
303
+ '/proc',
304
+ '/sys',
305
+ '/dev',
306
+ '/run',
307
+ ];
308
+ function refuseInit(error) {
309
+ return { ok: false, created: false, exists: false, error };
310
+ }
311
+ /**
312
+ * Why this path may not be created, or `null` — asked of a STRING (#417).
313
+ *
314
+ * Pulled out of `initProjectDir` because it has to be asked twice: once about
315
+ * what the person typed, and again about where that path really lands once the
316
+ * filesystem has had its say. See `realLandingPlace`.
317
+ */
318
+ function pathClassRefusal(candidate) {
319
+ if (isGitInternalPath(candidate)) {
320
+ return `${candidate} is inside a .git directory, which belongs to git itself — pick a folder for the project instead`;
321
+ }
322
+ if (candidate === path.sep || SYSTEM_DIRECTORIES.some((dir) => isAtOrBelow(candidate, dir))) {
323
+ return `${candidate} is a system directory on this server — pick a folder for the project instead`;
324
+ }
325
+ // Said separately from the line above, because it would not be true there:
326
+ // `~/.ssh` is not a system directory, and a refusal that misnames what it is
327
+ // refusing teaches the person the wrong thing about their own machine.
328
+ if (isSecretPath(candidate)) {
329
+ return `${candidate} is on this server's protected list — pick a folder for the project instead`;
330
+ }
331
+ // `isSecretPath` cannot answer this one: the runner tree is on its list, but
332
+ // `worktrees/` and `previews/` are excused from it so an agent can read its
333
+ // own workspace. Everything under the state directory is the runner's own
334
+ // bookkeeping either way, and a project living inside it would be rewritten by
335
+ // the next session cleanup. Both sides go through the real filesystem, because
336
+ // `stateDir()` is built from `os.homedir()` and a symlinked home would
337
+ // otherwise make the two paths look unrelated.
338
+ const state = realPathOf(stateDir());
339
+ if (isAtOrBelow(candidate, state)) {
340
+ return `${candidate} is inside the runner's own state directory — pick a folder for the project instead`;
341
+ }
342
+ return null;
343
+ }
344
+ /** `realpathSync` where it works, the path itself where it does not. */
345
+ function realPathOf(target) {
346
+ try {
347
+ return fs.realpathSync(target);
348
+ }
349
+ catch {
350
+ return path.resolve(target);
351
+ }
352
+ }
353
+ /**
354
+ * Where `mkdir` would actually land, symlinks and all (#417).
355
+ *
356
+ * Every refusal above is string work — `path.resolve` does not follow symlinks,
357
+ * `isSecretPath` normalises nothing, and containment is `path.relative`. The
358
+ * kernel disagrees: on a stock Debian `/var/run` IS `/run`, so `/var/run/shop`
359
+ * passes a list that exists to refuse exactly that, and the repository ends up
360
+ * on tmpfs and is gone at the next reboot.
361
+ *
362
+ * `policy.ts` solves the same problem in `normalize()` by resolving the parent
363
+ * and putting the last segment back on. That is not enough here, because this
364
+ * is asked BEFORE anything about the parent is known: with `/tmp/link -> /etc`
365
+ * and the path `/tmp/link/new/project`, the parent does not exist, its
366
+ * `realpath` fails, and the fallback is the unresolved string — which would then
367
+ * earn the advice «create /tmp/link/new first», i.e. make a folder in `/etc`. So
368
+ * the walk goes up to the deepest ancestor that DOES exist, resolves that, and
369
+ * puts every missing segment back on.
370
+ */
371
+ function realLandingPlace(resolved) {
372
+ const missing = [];
373
+ let existing = resolved;
374
+ for (;;) {
375
+ try {
376
+ const real = fs.realpathSync(existing);
377
+ return path.join(real, ...missing.reverse());
378
+ }
379
+ catch {
380
+ const up = path.dirname(existing);
381
+ if (up === existing)
382
+ return resolved;
383
+ missing.push(path.basename(existing));
384
+ existing = up;
385
+ }
386
+ }
387
+ }
388
+ /**
389
+ * Is `target` at or below `root`?
390
+ *
391
+ * `isInsideWorktree` is the containment helper this package already has, and it
392
+ * answers exactly this question — the name says worktree because that was its
393
+ * first caller, not because it knows anything about worktrees.
394
+ */
395
+ function isAtOrBelow(target, root) {
396
+ return isInsideWorktree(target, root);
397
+ }
398
+ /**
399
+ * Create the project folder and initialise git in it, from the binding window
400
+ * (#417).
401
+ *
402
+ * Two things and no more: ONE directory — the last segment of the path, never a
403
+ * chain of parents — and `git init` with `main` as the branch. No first commit:
404
+ * an empty repository is a legitimate state (#137, and the runner has known how
405
+ * to work in one since 0.63.0), while an «Initial commit» nobody asked for is
406
+ * the thing that makes `git pull` from an existing remote refuse with
407
+ * «unrelated histories» later on.
408
+ *
409
+ * Its own refusal list, because `validateWorkspacePath` has none to share: that
410
+ * function asks «can the runner work here», which is a question about
411
+ * permissions, and every answer it gives is about reaching, reading and writing.
412
+ * «Should anything be created here at all» is a different question and this is
413
+ * the only place that asks it. The API cannot ask it either — it sees the
414
+ * runner's verdict and nothing of the machine — so the list lives here.
415
+ */
416
+ export async function initProjectDir(target) {
417
+ const me = runnerIdentity();
418
+ // The same rule the API's `WorkspacePathSchema` applies, re-derived here: the
419
+ // runner is the process that actually calls `mkdir`, and it must not trust the
420
+ // wire (QA-99 MAJOR-4 is the same argument for `validate_path`).
421
+ if (!target.startsWith('/') || target.split('/').includes('..')) {
422
+ return refuseInit('The project folder must be an absolute path with no ".." segments');
423
+ }
424
+ const resolved = path.resolve(target);
425
+ // The cheap pass, on what the person typed: nothing touches the disk for a
426
+ // path that is obviously not a project folder.
427
+ const typedRefusal = pathClassRefusal(resolved);
428
+ if (typedRefusal)
429
+ return refuseInit(typedRefusal);
430
+ // The second pass, and the one that decides: where would `mkdir` LAND? A
431
+ // symlinked ancestor otherwise walks straight through every refusal above,
432
+ // and `/var/run/shop` on a stock Debian is not a hypothetical: that directory
433
+ // IS `/run`, and the repository would sit on tmpfs until the next reboot.
434
+ //
435
+ // Asked BEFORE any permission check, and that order is the point (the 0.64.0
436
+ // build had it the other way round and was never published): for a runner that is
437
+ // not root, `/tmp/link -> /etc` failed «can the runner write to the parent»
438
+ // first — and the refusal then advised `chown <runner> /tmp/link`, which is
439
+ // `chown` on `/etc`. «This is a system directory» must win over «grant
440
+ // yourself access to it».
441
+ const landing = realLandingPlace(resolved);
442
+ if (landing !== resolved) {
443
+ const realRefusal = pathClassRefusal(landing);
444
+ // Both paths named, in that order: the person recognises what they typed,
445
+ // and then learns where it actually goes — which is the fact they have to
446
+ // act on.
447
+ if (realRefusal)
448
+ return refuseInit(`${resolved} leads to ${realRefusal}`);
449
+ }
450
+ const here = inspectPath(resolved);
451
+ if (here.unreachable) {
452
+ const blocked = firstUnreachableAncestor(resolved) ?? resolved;
453
+ return refuseInit(`The runner runs as ${me.user} and is not allowed into ${blocked}, so it cannot create ${resolved}. ` +
454
+ `Grant that user access to the directory (for example \`chmod o+x ${blocked}\`, ` +
455
+ `or \`setfacl -m u:${me.user}:x ${blocked}\`), then try again.`);
456
+ }
457
+ if (here.exists) {
458
+ // Not a refusal, and deliberately not a `mkdir` either: a directory that is
459
+ // already there is the ordinary case of pressing the button twice, and the
460
+ // window's next move — bind it — is the right one.
461
+ if (here.isDirectory)
462
+ return { ok: true, created: false, exists: true };
463
+ return refuseInit(`${resolved} already exists and is not a directory`);
464
+ }
465
+ const parent = path.dirname(resolved);
466
+ const above = inspectPath(parent);
467
+ if (above.unreachable) {
468
+ const blocked = firstUnreachableAncestor(parent) ?? parent;
469
+ return refuseInit(`The runner runs as ${me.user} and is not allowed into ${blocked}, so it cannot create ${resolved}. ` +
470
+ `Grant that user access to the directory (for example \`chmod o+x ${blocked}\`, ` +
471
+ `or \`setfacl -m u:${me.user}:x ${blocked}\`), then try again.`);
472
+ }
473
+ if (!above.exists || !above.isDirectory) {
474
+ return refuseInit(`${parent} does not exist on this server, and the runner creates only the last folder of the path. ` +
475
+ `Create ${parent} there first (\`mkdir -p ${parent}\` as a user who may write to it), then try again.`);
476
+ }
477
+ if (!above.writable) {
478
+ return refuseInit(`The runner runs as ${me.user} and cannot write to ${parent}, so it cannot create ${resolved} there. ` +
479
+ `Give that user write access (for example \`setfacl -m u:${me.user}:rwx ${parent}\`, ` +
480
+ `or \`chown ${me.user} ${parent}\` if the directory is meant to be theirs), then try again.`);
481
+ }
482
+ try {
483
+ // Deliberately NOT `recursive: true`: the chain of parents is somebody
484
+ // else's decision about the layout of their machine, and a button that
485
+ // quietly makes `/opt/a/b/c` out of a typo is worse than one that says the
486
+ // folder above is missing.
487
+ fs.mkdirSync(resolved);
488
+ }
489
+ catch (error) {
490
+ const code = error.code;
491
+ if (code === 'EEXIST') {
492
+ // Somebody else got there between `inspectPath` and here — or the name was
493
+ // never free at all: `mkdir(2)` answers EEXIST for ANY existing name, and
494
+ // a dangling symlink is invisible to the `statSync` above, which follows
495
+ // it and reports «not there». Look again rather than assume: «a directory
496
+ // is there» is what the window binds on next.
497
+ const now = inspectPath(resolved);
498
+ if (now.exists && now.isDirectory)
499
+ return { ok: true, created: false, exists: true };
500
+ return refuseInit(`${resolved} already exists and is not a directory`);
501
+ }
502
+ return refuseInit(`The runner runs as ${me.user} and could not create ${resolved} (${code ?? 'unknown error'}). ` +
503
+ `Check that ${parent} is writable by that user, then try again.`);
504
+ }
505
+ try {
506
+ await initRepositoryOnMain(resolved);
507
+ }
508
+ catch (error) {
509
+ // All-or-nothing, like laying out a worktree (#360): a folder that exists
510
+ // but is not a repository binds and then fails on «Not a git work tree»,
511
+ // which is a worse place to learn about it. `rmdirSync` refuses a directory
512
+ // with anything in it, which is exactly the safety wanted here — we are only
513
+ // ever removing the empty one made three lines up.
514
+ let takenBack = true;
515
+ try {
516
+ fs.rmdirSync(resolved);
517
+ }
518
+ catch {
519
+ // It is not empty, so it is not only ours to remove. Leave it — and say
520
+ // so, because the two outcomes want opposite things from the person.
521
+ takenBack = false;
522
+ }
523
+ // Git's own words go to the runner's log, where the person who can act on
524
+ // them is; the sentence that travels names the folder and what to do next.
525
+ // `describeGitFailure` would have carried `fatal: …` into a browser, and
526
+ // the API's net for that (`actionableRunnerRefusal`) rewrites the WHOLE
527
+ // text — so forwarding it would have cost this sentence as well.
528
+ log.warn('init_project_dir: git init failed', {
529
+ path: resolved,
530
+ takenBack,
531
+ error: describeGitFailure(error),
532
+ });
533
+ const said = takenBack
534
+ ? `Git would not start in ${resolved}, so the folder was removed again and nothing was left behind. ` +
535
+ `The runner's log on this server has git's own words — check that git is installed and that the disk is writable, then try again.`
536
+ : `${resolved} was created, but git would not start in it, and the folder is still there. ` +
537
+ `The runner's log on this server has git's own words. Run \`git init -b main\` in that folder yourself, or remove it and try again.`;
538
+ return { ok: false, created: !takenBack, exists: !takenBack, error: said };
539
+ }
540
+ // Read back rather than reported from intent: on a git old enough to need the
541
+ // fallback below, «main» is something we asked for, not something we saw.
542
+ const head = await headState(resolved);
543
+ return {
544
+ ok: true,
545
+ created: true,
546
+ exists: true,
547
+ ...(head.branch ? { branch: head.branch } : {}),
548
+ };
549
+ }
550
+ /**
551
+ * `git init` with `main` as the initial branch, on every git the fleet has.
552
+ *
553
+ * `--initial-branch` arrived in git 2.28 (2020); Debian 10 and CentOS 7 ship
554
+ * older ones and are still out there. The fallback is not a lesser outcome —
555
+ * `symbolic-ref` on a repository with no commits in it is exactly what `-b`
556
+ * does — it just takes two calls.
557
+ *
558
+ * Tried rather than version-parsed on purpose: the question is whether THIS git
559
+ * accepts the flag, and the flag itself answers it in one cheap call.
560
+ */
561
+ async function initRepositoryOnMain(repoPath) {
562
+ try {
563
+ await git(repoPath, 'init', '--quiet', '--initial-branch', INITIAL_BRANCH);
564
+ return;
565
+ }
566
+ catch {
567
+ // Falls through: either the flag is unknown, or `git init` itself failed —
568
+ // and the plain form below tells the two apart by either working or
569
+ // throwing the real reason to the caller.
570
+ }
571
+ await git(repoPath, 'init', '--quiet');
572
+ // Best effort, and deliberately not fatal: `git init` has already made a
573
+ // working repository, and the caller reads the branch back off it rather than
574
+ // trusting this call. Throwing here would throw away a folder that is fine.
575
+ await git(repoPath, 'symbolic-ref', 'HEAD', `refs/heads/${INITIAL_BRANCH}`).catch(() => undefined);
576
+ }
255
577
  /**
256
578
  * The repository's main branch, read locally (ADR 0004).
257
579
  *
package/dist/index.js CHANGED
@@ -566,6 +566,11 @@ function runnerCapabilities(apiUrlOverride) {
566
566
  // это чтение, а не установка, и машину, чей владелец запретил ставить из
567
567
  // дашборда, спросить о том, что на ней стоит, по-прежнему можно.
568
568
  'agent_versions_refresh',
569
+ // #417: create the project folder and `git init` in it, from the
570
+ // binding window. Announced here and nowhere else — the API refuses the
571
+ // button on a machine that does not name this command, because an older
572
+ // runner drops a command it cannot parse and never answers.
573
+ 'init_project_dir',
569
574
  // #396. Listed unconditionally, unlike the `restart` flag above: the
570
575
  // flag is what the dashboard draws a button from, and this list is what
571
576
  // `runCommand` dispatches on. A machine that cannot restart still
@@ -1076,6 +1076,26 @@ export declare class Supervisor {
1076
1076
  */
1077
1077
  private armCompactionWatchdog;
1078
1078
  private clearCompactionWatchdog;
1079
+ /**
1080
+ * A card that interrupted a RESTING session has been answered: go back to
1081
+ * rest instead of reporting a turn (#382).
1082
+ *
1083
+ * Which card it was does not matter — a Codex helper's approval, a Claude
1084
+ * background subagent's, an ask either of them parked while the session was
1085
+ * already the person's. What matters is that no turn of this session was
1086
+ * running when the card went out, so there is no turn to go back to and
1087
+ * nothing that would end one: on Codex the helper's own ending is explicitly
1088
+ * not the session's, so «Working» stood until somebody typed.
1089
+ *
1090
+ * Only when the burst is over — both card sets empty — because answering one
1091
+ * of three still leaves the session parked on the other two. The frame
1092
+ * carries the background count like every other status report, so the badge
1093
+ * and the Inbox see «resting, with helpers» rather than «your turn».
1094
+ *
1095
+ * Returns true when it handled the resolution, so the callers' «the human
1096
+ * answered, bill again» branches stay out of it.
1097
+ */
1098
+ private restAfterCard;
1079
1099
  /**
1080
1100
  * Record how many subagents are alive, and say so when it matters (#236).
1081
1101
  *
@@ -9,7 +9,7 @@ import { classifyFailure, isRepeatOfSameFailure, MAX_RETRIES_PER_SESSION, retryD
9
9
  import { evaluateRecipeCommand, maskSecrets, maskString } from './policy.js';
10
10
  import { agentPromptSizeLabel, inspectAgentPrompt, quotePath, readAgentPrompt, } from './agent-prompt.js';
11
11
  import { JournalStore } from './journal.js';
12
- import { deleteSessionBranch, ensurePreviewWorktree, ensureSessionWorktree, prepareDirectWorkspace, previewWorktreePath, removePreviewWorktree, removeSessionWorktree, repoKeyFor, sessionWorktreePath, validateWorkspacePath, WorktreePrepareError, } from './git.js';
12
+ import { deleteSessionBranch, ensurePreviewWorktree, ensureSessionWorktree, initProjectDir, prepareDirectWorkspace, previewWorktreePath, removePreviewWorktree, removeSessionWorktree, repoKeyFor, sessionWorktreePath, validateWorkspacePath, WorktreePrepareError, } from './git.js';
13
13
  import { readRecipeProposal } from './recipe.js';
14
14
  import { RECIPE_STEP_NAMES, parseProjectRecipe } from './recipe-schema.js';
15
15
  import { proposeCommitMessage } from './commit-message.js';
@@ -3404,6 +3404,39 @@ export class Supervisor {
3404
3404
  clearTimeout(running.compactionWatchdog);
3405
3405
  delete running.compactionWatchdog;
3406
3406
  }
3407
+ /**
3408
+ * A card that interrupted a RESTING session has been answered: go back to
3409
+ * rest instead of reporting a turn (#382).
3410
+ *
3411
+ * Which card it was does not matter — a Codex helper's approval, a Claude
3412
+ * background subagent's, an ask either of them parked while the session was
3413
+ * already the person's. What matters is that no turn of this session was
3414
+ * running when the card went out, so there is no turn to go back to and
3415
+ * nothing that would end one: on Codex the helper's own ending is explicitly
3416
+ * not the session's, so «Working» stood until somebody typed.
3417
+ *
3418
+ * Only when the burst is over — both card sets empty — because answering one
3419
+ * of three still leaves the session parked on the other two. The frame
3420
+ * carries the background count like every other status report, so the badge
3421
+ * and the Inbox see «resting, with helpers» rather than «your turn».
3422
+ *
3423
+ * Returns true when it handled the resolution, so the callers' «the human
3424
+ * answered, bill again» branches stay out of it.
3425
+ */
3426
+ restAfterCard(running) {
3427
+ const back = running.restBeforeCard;
3428
+ if (!back)
3429
+ return false;
3430
+ if (running.openPermissions.size > 0 || running.openQuestions.size > 0)
3431
+ return true;
3432
+ // `reportStatus` clears the memory — the status it sends is the newest
3433
+ // truth about this session, whatever it is.
3434
+ this.reportStatus(running.descriptor.id, back, {
3435
+ costUsd: running.costUsd,
3436
+ activeMs: Supervisor.spentMs(running),
3437
+ });
3438
+ return true;
3439
+ }
3407
3440
  /**
3408
3441
  * Record how many subagents are alive, and say so when it matters (#236).
3409
3442
  *
@@ -3817,6 +3850,8 @@ export class Supervisor {
3817
3850
  source: event.source,
3818
3851
  reason: event.reason,
3819
3852
  });
3853
+ if (this.restAfterCard(running))
3854
+ return;
3820
3855
  if (event.source === 'user' &&
3821
3856
  event.allow &&
3822
3857
  running.lastReported === 'WAITING_PERMISSION') {
@@ -3844,6 +3879,11 @@ export class Supervisor {
3844
3879
  // is the bug session 7 removed (five of the first twelve prod sessions
3845
3880
  // died having spent their budget waiting for a human).
3846
3881
  running.openQuestions.add(event.askId);
3882
+ // #382, the same as a permission card: an ask from work that outlives
3883
+ // the turn finds the session at rest, and the answer must put it back.
3884
+ if (running.lastReported === 'WAITING_INPUT' || running.lastReported === 'REVIEW') {
3885
+ running.restBeforeCard = running.lastReported;
3886
+ }
3847
3887
  this.sendEvent(running, 'question', {
3848
3888
  askId: event.askId,
3849
3889
  questions: event.questions,
@@ -3872,6 +3912,8 @@ export class Supervisor {
3872
3912
  // `openQuestions.size` matters: both agents can park several asks at
3873
3913
  // once, and reporting RUNNING while another card is still waiting would
3874
3914
  // bill a human's thinking time all over again (QA-106 M4).
3915
+ if (this.restAfterCard(running))
3916
+ return;
3875
3917
  if (event.source === 'user' &&
3876
3918
  running.openQuestions.size === 0 &&
3877
3919
  running.lastReported === 'WAITING_INPUT') {
@@ -6140,6 +6182,37 @@ export class Supervisor {
6140
6182
  ...(validation.ok ? {} : { error: validation.error }),
6141
6183
  });
6142
6184
  }
6185
+ /**
6186
+ * «Create this folder and put git in it», from the binding window
6187
+ * (#417).
6188
+ *
6189
+ * Next to `validate_path` because it is the same conversation: that
6190
+ * command answers «the folder is not there», and this one is what the
6191
+ * person presses next. Everything it is allowed and not allowed to do
6192
+ * is decided in `initProjectDir` — the API sees a verdict and never the
6193
+ * machine.
6194
+ *
6195
+ * Under the repository lock like every other operation on a folder,
6196
+ * even though there is no repository yet: `repoKeyFor` falls back to the
6197
+ * path itself when git cannot answer, so two presses of the button
6198
+ * serialise against each other rather than racing into one `mkdir`.
6199
+ */
6200
+ case 'init_project_dir': {
6201
+ const path = typeof frame.args?.['path'] === 'string' ? frame.args['path'] : null;
6202
+ if (!path)
6203
+ return void reply({ ok: false, error: 'path argument is required' });
6204
+ const init = await this.withRepoLockFor(path, () => initProjectDir(path));
6205
+ // Remembered for the same reason `validate_path` remembers it: this
6206
+ // is a real project directory now, and `devbridge-runner doctor`
6207
+ // checks the permissions of the ones it knows about.
6208
+ if (init.ok)
6209
+ rememberWorkspacePath(path);
6210
+ return void reply({
6211
+ ok: init.ok,
6212
+ result: init,
6213
+ ...(init.ok ? {} : { error: init.error }),
6214
+ });
6215
+ }
6143
6216
  case 'clean': {
6144
6217
  const sessionId = frame.sessionId;
6145
6218
  if (!sessionId)
@@ -7532,6 +7605,13 @@ export class Supervisor {
7532
7605
  // The API infers WAITING_PERMISSION from the event itself, so this never
7533
7606
  // goes through reportStatus — but the budget clock still has to stop, or
7534
7607
  // an ask-mode session bills every second the human spends reading the card.
7608
+ //
7609
+ // #382: remember what the card interrupted. A card from work that
7610
+ // outlives the turn finds the session at rest, and the answer must put it
7611
+ // back there rather than into a turn nobody is running.
7612
+ if (running.lastReported === 'WAITING_INPUT' || running.lastReported === 'REVIEW') {
7613
+ running.restBeforeCard = running.lastReported;
7614
+ }
7535
7615
  running.lastReported = 'WAITING_PERMISSION';
7536
7616
  this.syncBudgetClock(running);
7537
7617
  }
@@ -7541,6 +7621,8 @@ export class Supervisor {
7541
7621
  const running = this.sessions.get(sessionId);
7542
7622
  if (running) {
7543
7623
  running.lastReported = status;
7624
+ // #382: any status report is newer than the rest a card interrupted.
7625
+ delete running.restBeforeCard;
7544
7626
  // Coming to rest ends the busy period the once-per-turn notices were
7545
7627
  // limited to: the next one is about a new answer and deserves saying.
7546
7628
  if (!Supervisor.MID_TURN_STATUSES.includes(status)) {
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.63.0";
1
+ export declare const RUNNER_VERSION = "0.65.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in sync with package.json by the release script (manual for now).
2
- export const RUNNER_VERSION = '0.63.0';
2
+ export const RUNNER_VERSION = '0.65.0';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.63.0",
3
+ "version": "0.65.0",
4
4
  "description": "DevBridge dev runner — connects a dev server to DevBridge and runs agent sessions (Claude Code / Codex)",
5
5
  "homepage": "https://bridge4.dev",
6
6
  "license": "MIT",