@bridge4dev/runner 0.51.0 → 0.53.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/policy.d.ts CHANGED
@@ -92,10 +92,40 @@ export interface PolicyContext extends AgentGitPolicy {
92
92
  * what makes a change visible when this cannot prevent it.
93
93
  */
94
94
  agentPromptFile?: string;
95
+ /**
96
+ * Does this session work in the project folder itself, or in a worktree of
97
+ * its own? (#361 п. 5, ADR 0004)
98
+ *
99
+ * Only DIRECT sessions share a folder — and therefore share ONE current
100
+ * branch with every other session and person working in it. `git checkout
101
+ * <branch>` there is not a local move: it rewrites the working tree under
102
+ * everybody at once, which is the same class of act as `git reset --hard`.
103
+ *
104
+ * **`undefined` means «apply the rule».** Deliberately the opposite polarity
105
+ * to `SessionDescriptor.workMode` in `protocol.ts`, where a missing value
106
+ * means BRANCH when the folder is prepared. Different field, different
107
+ * question: there the safe reading is «build a worktree», here it is «ask
108
+ * first». Gotcha 193 — resolve «unknown» once, in the safe direction, and say
109
+ * out loud when two neighbours resolve it in opposite ways.
110
+ */
111
+ workMode?: 'DIRECT' | 'BRANCH';
95
112
  }
96
113
  export interface PolicyDecision {
97
114
  decision: 'allow' | 'deny' | 'ask';
98
115
  reason: string;
116
+ /**
117
+ * A sentence written FOR THE PERSON answering the card (#361 п. 5).
118
+ *
119
+ * `reason` is a label — «strict mode», «command needs approval» — and it goes
120
+ * to the log and, on a denial, to the feed. A card carries neither: it shows
121
+ * a title, a description and the input, and the reason is dropped. So a rule
122
+ * that asks a question the human cannot answer without knowing WHY has to say
123
+ * why here, and the adapters put it on the card.
124
+ *
125
+ * Only set where there is something worth reading; absent means «the card's
126
+ * own title says enough».
127
+ */
128
+ explain?: string;
99
129
  }
100
130
  export declare function maskString(value: string): string;
101
131
  /** Deep-mask every string in a JSON-ish structure (payloads leaving the server). */
@@ -125,6 +155,14 @@ export declare function isGitInternalPath(p: string): boolean;
125
155
  * DELIBERATELY switched something on — with the shipped defaults the first
126
156
  * check refuses every push and the rest never run, which is byte for byte the
127
157
  * behaviour of the four regexes it replaces.
158
+ *
159
+ * **A `deny` anywhere beats an `ask` anywhere.** Until #361 every decision here
160
+ * was a refusal, so returning the first one found in the first segment was the
161
+ * same thing as returning the worst one. With an `ask` in the set that stopped
162
+ * being true: `git checkout main && git push origin main` would have shown a
163
+ * card about the checkout, and confirming it would have run the push this
164
+ * function exists to refuse. So `deny` returns at once, `ask` is remembered,
165
+ * and the loop reads to the end.
128
166
  */
129
167
  export declare function evaluateGitPolicy(command: string, ctx: PolicyContext): PolicyDecision | null;
130
168
  export interface RecipeCommandContext {
package/dist/policy.js CHANGED
@@ -517,6 +517,189 @@ function parsePush(segment) {
517
517
  remoteExec,
518
518
  };
519
519
  }
520
+ const GIT_CHECKOUT = String.raw `\bgit\s+${FLAGS}(?:checkout|switch)\b`;
521
+ /** A word whose meaning only the shell knows — `$BRANCH`, `` `cat .b` ``, `\x`. */
522
+ const SHELL_UNRESOLVED = /[$`\\]/;
523
+ /**
524
+ * A REVISION expression — `main~1`, `v2^`, `abc123^{commit}`.
525
+ *
526
+ * Not a branch name (git's own ref grammar forbids `~` and `^` in one) and
527
+ * emphatically not a pathspec: checking one out leaves the folder on no branch
528
+ * at all. Grouping it with the pathspecs below, on the grounds that it «cannot
529
+ * be a branch», was exactly backwards — `git checkout main~1` detaches the
530
+ * shared folder for everybody in it, silently.
531
+ */
532
+ const REVISION_EXPRESSION = /[~^]/;
533
+ /**
534
+ * An operand git could never read as a branch name — because it is a PATH.
535
+ *
536
+ * Not a guess about intent, a fact about the ref grammar: `*`, `?`, `[` and
537
+ * spaces are forbidden in a branch name, a leading `:` is git's own magic
538
+ * pathspec prefix, and `.`, `./x`, `../x`, `/x`, `x/` are paths by shape.
539
+ * A closing `]` is deliberately NOT here: `git check-ref-format --branch 'wip]1'`
540
+ * accepts it, so calling it impossible-in-a-branch would have waved through a
541
+ * real branch switch (QA #255).
542
+ * Everything else stays ambiguous, and git resolves ambiguity in favour of the
543
+ * BRANCH — which is why an ambiguous word asks rather than being waved through.
544
+ *
545
+ * Characters that are merely UNUSUAL in a branch name (a backslash, a control
546
+ * character, `@{`) are deliberately absent: they end up in the ambiguous pile,
547
+ * which asks. Every gap here has to fall on that side.
548
+ */
549
+ function cannotBeBranch(operand) {
550
+ if (operand === '.' || operand === '..')
551
+ return true;
552
+ if (/^\.{1,2}\//.test(operand) || operand.startsWith('/'))
553
+ return true;
554
+ if (operand.startsWith(':'))
555
+ return true;
556
+ if (operand.endsWith('/'))
557
+ return true;
558
+ return /[*?[ ]/.test(operand);
559
+ }
560
+ /**
561
+ * The agent chose this word, so it is quoted back at a length WE choose.
562
+ *
563
+ * This string becomes the description of a permission card and travels in an
564
+ * event payload the gateway caps; a single implausibly long operand should cost
565
+ * a few characters of the sentence, never the card.
566
+ */
567
+ function shortOperand(operand) {
568
+ return operand.length > 60 ? `${operand.slice(0, 60)}…` : operand;
569
+ }
570
+ const RESTORE_A_FILE = 'To put a file back use `git checkout -- <path>` or `git restore <path>`: those touch files only.';
571
+ const MOVES_THE_FOLDER = 'this session works in the project folder itself, so switching branches there changes the files under every other session and person working in it';
572
+ /**
573
+ * Does this segment move the shared folder onto another branch? (#361 п. 5)
574
+ *
575
+ * Parsed rather than pattern matched, for the same reason as `parsePush`: the
576
+ * difference between «restore this file» and «move everybody to another
577
+ * branch» is one `--` in the middle of the arguments, and no regex answers
578
+ * that. Synchronous and free of child processes — gotcha 196: this runs on
579
+ * every Bash call of every session on the machine.
580
+ *
581
+ * Returns null when the segment is not a checkout at all, or is one of the
582
+ * forms that provably only touches files.
583
+ */
584
+ function parseCheckout(segment) {
585
+ if (!new RegExp(GIT_CHECKOUT).test(segment))
586
+ return null;
587
+ const tokens = words(segment);
588
+ const at = tokens.findIndex((token) => token === 'checkout' || token === 'switch');
589
+ if (at === -1) {
590
+ // The regex matched but the verb is hidden by a form this tokenizer does
591
+ // not model. Report the moving reading — «I could not parse it» must never
592
+ // become «go ahead».
593
+ return {
594
+ reason: `This git command could not be read well enough to tell whether it moves the folder onto another branch, and ${MOVES_THE_FOLDER}. ${RESTORE_A_FILE}`,
595
+ };
596
+ }
597
+ if (tokens[at] === 'switch') {
598
+ // `git switch` has no file form at all — it exists to move a branch.
599
+ return {
600
+ reason: `\`git switch\` moves the folder onto another branch, and ${MOVES_THE_FOLDER}. ${RESTORE_A_FILE}`,
601
+ };
602
+ }
603
+ const rest = tokens.slice(at + 1);
604
+ /**
605
+ * `--` names paths only when there ARE paths after it.
606
+ *
607
+ * The obvious reading — «a `--` anywhere means this is a file command» — is
608
+ * wrong, and wrong in the direction that matters. Verified on git 2.43:
609
+ * `git checkout feature --` prints «Switched to branch 'feature'», and
610
+ * `git checkout -b tmp --` creates the branch and moves onto it. Git's own
611
+ * parser treats a trailing `--` as «the word before me is definitely a ref»,
612
+ * which is the opposite of what it looks like. So the separator is only a
613
+ * separator when it separates something.
614
+ */
615
+ const dashDash = rest.indexOf('--');
616
+ if (dashDash !== -1 && dashDash < rest.length - 1)
617
+ return null;
618
+ const head = dashDash === -1 ? rest : rest.slice(0, dashDash);
619
+ let fileOnly = false;
620
+ let movingFlag = null;
621
+ const operands = [];
622
+ for (const token of head) {
623
+ if (token.startsWith('--')) {
624
+ const name = token.split('=')[0] ?? token;
625
+ if (name === '--ours' ||
626
+ name === '--theirs' ||
627
+ name === '--patch' ||
628
+ name === '--pathspec-from-file' ||
629
+ name === '--pathspec-file-nul') {
630
+ fileOnly = true;
631
+ }
632
+ else if (name === '--orphan' ||
633
+ name === '--detach' ||
634
+ name === '--track' ||
635
+ name === '--merge' ||
636
+ name === '--force') {
637
+ movingFlag ??= name;
638
+ }
639
+ continue;
640
+ }
641
+ // A lone `-` is an operand: «the branch I was on before».
642
+ if (token.startsWith('-') && token.length > 1) {
643
+ for (const letter of token.slice(1)) {
644
+ if (letter === 'p')
645
+ fileOnly = true;
646
+ else if ('bBtmf'.includes(letter))
647
+ movingFlag ??= `-${letter}`;
648
+ }
649
+ continue;
650
+ }
651
+ operands.push(token);
652
+ }
653
+ if (fileOnly)
654
+ return null;
655
+ if (movingFlag) {
656
+ return {
657
+ reason: `\`git checkout ${shortOperand(movingFlag)}\` moves the folder onto another branch, and ${MOVES_THE_FOLDER}. ${RESTORE_A_FILE}`,
658
+ };
659
+ }
660
+ // `git checkout` on its own restores nothing and moves nothing — but only
661
+ // when the arguments really are all of them. `echo feature | xargs git
662
+ // checkout` is a checkout whose operand is handed to it by the program in
663
+ // front of it, and this parser cannot see it: the segment ends with a bare
664
+ // `git checkout` and looks harmless. So «no operands» is only an answer when
665
+ // git is the thing being run, not an argument to something else.
666
+ if (operands.length === 0) {
667
+ const first = tokens.find((token) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(token));
668
+ if (first === 'git')
669
+ return null;
670
+ return {
671
+ reason: `This \`git checkout\` gets its arguments from another program, so the runner cannot tell whether it moves the folder onto another branch, and ${MOVES_THE_FOLDER}. ${RESTORE_A_FILE}`,
672
+ };
673
+ }
674
+ for (const operand of operands) {
675
+ if (SHELL_UNRESOLVED.test(operand)) {
676
+ return {
677
+ reason: `This \`git checkout\` names its target through the shell, so the runner cannot tell whether it is a file or a branch — and ${MOVES_THE_FOLDER}. ${RESTORE_A_FILE}`,
678
+ };
679
+ }
680
+ if (operand === '-') {
681
+ return {
682
+ reason: `\`git checkout -\` moves the folder back onto the previous branch, and ${MOVES_THE_FOLDER}. ${RESTORE_A_FILE}`,
683
+ };
684
+ }
685
+ if (/^(HEAD|@)([~^].*)?$/.test(operand) ||
686
+ /^[0-9a-f]{7,40}$/i.test(operand) ||
687
+ REVISION_EXPRESSION.test(operand)) {
688
+ return {
689
+ reason: `\`git checkout ${shortOperand(operand)}\` leaves the folder on no branch at all (detached HEAD), and ${MOVES_THE_FOLDER}. ${RESTORE_A_FILE}`,
690
+ };
691
+ }
692
+ if (cannotBeBranch(operand))
693
+ continue;
694
+ // A bare word. Git reads it as a BRANCH first and only falls back to a
695
+ // path, so «I meant the file» is not something this can assume — and the
696
+ // unambiguous spelling costs the agent three characters.
697
+ return {
698
+ reason: `\`git checkout ${shortOperand(operand)}\` is read by git as a branch first, and ${MOVES_THE_FOLDER}. ${RESTORE_A_FILE}`,
699
+ };
700
+ }
701
+ return null;
702
+ }
520
703
  /**
521
704
  * The project's git rules, applied to one Bash command.
522
705
  *
@@ -525,9 +708,18 @@ function parsePush(segment) {
525
708
  * DELIBERATELY switched something on — with the shipped defaults the first
526
709
  * check refuses every push and the rest never run, which is byte for byte the
527
710
  * behaviour of the four regexes it replaces.
711
+ *
712
+ * **A `deny` anywhere beats an `ask` anywhere.** Until #361 every decision here
713
+ * was a refusal, so returning the first one found in the first segment was the
714
+ * same thing as returning the worst one. With an `ask` in the set that stopped
715
+ * being true: `git checkout main && git push origin main` would have shown a
716
+ * card about the checkout, and confirming it would have run the push this
717
+ * function exists to refuse. So `deny` returns at once, `ask` is remembered,
718
+ * and the loop reads to the end.
528
719
  */
529
720
  export function evaluateGitPolicy(command, ctx) {
530
721
  const policy = resolveGitPolicy(ctx);
722
+ let asked = null;
531
723
  for (const segment of commandSegments(command)) {
532
724
  if (!policy.allowDestructiveGit) {
533
725
  /**
@@ -539,10 +731,14 @@ export function evaluateGitPolicy(command, ctx) {
539
731
  * that went that way on 2026-07-28 existed nowhere else — and
540
732
  * `git reset --hard` throws away every uncommitted edit in the tree.
541
733
  *
542
- * Denied rather than turned into a permission card, because under AUTO
543
- * trust there is no card: Bash is allowed before the safe list is
544
- * consulted. An agent that needs to remove build output can name the
545
- * paths; one that wants to undo its own work has `git checkout -- <file>`.
734
+ * Denied rather than turned into a permission card because there is no
735
+ * safe version of them, not because a card was impossible: this whole
736
+ * function runs BEFORE the trust branches, so an `ask` returned from here
737
+ * does reach the person even under AUTO (that is how #361 п. 5 works two
738
+ * rules below). What is true is that the safe LIST never sees them — Bash
739
+ * is allowed before it is consulted. An agent that needs to remove build
740
+ * output can name the paths; one that wants to undo its own work has
741
+ * `git checkout -- <file>`.
546
742
  */
547
743
  if (new RegExp(String.raw `\bgit\s+${FLAGS}clean\b`).test(segment)) {
548
744
  return {
@@ -557,6 +753,16 @@ export function evaluateGitPolicy(command, ctx) {
557
753
  };
558
754
  }
559
755
  }
756
+ // #361 п. 5 — only where the folder is shared. In a BRANCH session the
757
+ // worktree belongs to this session alone and moving it costs nobody
758
+ // anything. See `PolicyContext.workMode` for why silence means «ask».
759
+ if (ctx.workMode !== 'BRANCH') {
760
+ const move = parseCheckout(segment);
761
+ // `reason` stays a LABEL — it goes to logs and, on a denial, to the feed.
762
+ // The sentence a person reads on the card travels in `explain`.
763
+ if (move)
764
+ asked ??= { decision: 'ask', reason: 'moves the shared folder', explain: move.reason };
765
+ }
560
766
  const push = parsePush(segment);
561
767
  if (!push)
562
768
  continue;
@@ -648,7 +854,7 @@ export function evaluateGitPolicy(command, ctx) {
648
854
  }
649
855
  }
650
856
  }
651
- return null;
857
+ return asked;
652
858
  }
653
859
  // Commands considered safe enough to run without asking under NORMAL trust.
654
860
  // Interpreters (node/python/…) and find (-exec) are NOT here: they execute
@@ -829,7 +1035,14 @@ export function evaluateRecipeCommand(command, ctx = {}) {
829
1035
  * approval screen, not a side effect of this one.
830
1036
  */
831
1037
  const git = evaluateGitPolicy(variant, { trustMode: 'STRICT', worktreePath: '' });
832
- if (git) {
1038
+ // Only a REFUSAL stops a recipe. Since #361 this function can also return
1039
+ // «ask», and the first line of this doc comment is the answer to it: an
1040
+ // approved recipe does not ask, because a human already read this exact
1041
+ // command on the approval screen. Turning that ask into `{allowed: false}`
1042
+ // would hard-refuse every build script that starts with `git checkout main`
1043
+ // — a refusal invented by a rule about AGENTS, applied to a text a person
1044
+ // wrote and approved.
1045
+ if (git?.decision === 'deny') {
833
1046
  // The sentence is rewritten, not passed through (QA-134 MINOR-2). The
834
1047
  // generic one says «this project has «Принудительно запретить push»
835
1048
  // switched on» — and a project that switched it OFF would be sent to look
@@ -1001,11 +1214,19 @@ export function evaluateToolUse(toolName, input, ctx) {
1001
1214
  // and nothing after this line is consulted. That is exactly how «agents
1002
1215
  // never push» quietly failed for a whole release (session 13), and it is
1003
1216
  // the reason these rules did not simply move onto the safe list.
1217
+ // Both spellings are read to the end before anything is returned: a `deny`
1218
+ // in the dequoted form must beat an `ask` in the raw one, for the same
1219
+ // reason a later segment beats an earlier one (see `evaluateGitPolicy`).
1220
+ let gitAsk = null;
1004
1221
  for (const variant of [command, dequote(command)]) {
1005
1222
  const git = evaluateGitPolicy(variant, ctx);
1006
- if (git)
1223
+ if (git?.decision === 'deny')
1007
1224
  return git;
1225
+ if (git)
1226
+ gitAsk ??= git;
1008
1227
  }
1228
+ if (gitAsk)
1229
+ return gitAsk;
1009
1230
  if (trust === 'STRICT')
1010
1231
  return { decision: 'ask', reason: 'strict mode' };
1011
1232
  if (trust === 'AUTO')
@@ -242,6 +242,7 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
242
242
  url: string;
243
243
  token: string;
244
244
  } | undefined;
245
+ workMode?: "DIRECT" | "BRANCH" | undefined;
245
246
  pausedUntil?: string | null | undefined;
246
247
  agentLatestVersion?: string | undefined;
247
248
  skipAgentPrompt?: boolean | undefined;
@@ -252,7 +253,6 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
252
253
  baseBranch?: string | undefined;
253
254
  baseSha?: string | undefined;
254
255
  } | undefined;
255
- workMode?: "DIRECT" | "BRANCH" | undefined;
256
256
  }, {
257
257
  agent: "CLAUDE" | "CODEX";
258
258
  id: string;
@@ -284,6 +284,7 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
284
284
  token: string;
285
285
  } | undefined;
286
286
  mode?: "ask" | "plan" | "auto" | "full" | undefined;
287
+ workMode?: unknown;
287
288
  model?: string | null | undefined;
288
289
  effort?: string | null | undefined;
289
290
  epoch?: number | undefined;
@@ -296,7 +297,6 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
296
297
  skipAgentPrompt?: unknown;
297
298
  branchHint?: unknown;
298
299
  branchPlan?: unknown;
299
- workMode?: unknown;
300
300
  }>;
301
301
  export type SessionDescriptor = z.infer<typeof SessionDescriptorSchema>;
302
302
  /**
@@ -660,6 +660,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
660
660
  url: string;
661
661
  token: string;
662
662
  } | undefined;
663
+ workMode?: "DIRECT" | "BRANCH" | undefined;
663
664
  pausedUntil?: string | null | undefined;
664
665
  agentLatestVersion?: string | undefined;
665
666
  skipAgentPrompt?: boolean | undefined;
@@ -670,7 +671,6 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
670
671
  baseBranch?: string | undefined;
671
672
  baseSha?: string | undefined;
672
673
  } | undefined;
673
- workMode?: "DIRECT" | "BRANCH" | undefined;
674
674
  }, {
675
675
  agent: "CLAUDE" | "CODEX";
676
676
  id: string;
@@ -702,6 +702,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
702
702
  token: string;
703
703
  } | undefined;
704
704
  mode?: "ask" | "plan" | "auto" | "full" | undefined;
705
+ workMode?: unknown;
705
706
  model?: string | null | undefined;
706
707
  effort?: string | null | undefined;
707
708
  epoch?: number | undefined;
@@ -714,12 +715,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
714
715
  skipAgentPrompt?: unknown;
715
716
  branchHint?: unknown;
716
717
  branchPlan?: unknown;
717
- workMode?: unknown;
718
718
  }>, "many">;
719
719
  }, "strip", z.ZodTypeAny, {
720
- type: "hello_ack";
721
- serverName: string;
722
- serverId: string;
723
720
  sessions: {
724
721
  mode: "ask" | "plan" | "auto" | "full";
725
722
  agent: "CLAUDE" | "CODEX";
@@ -758,6 +755,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
758
755
  url: string;
759
756
  token: string;
760
757
  } | undefined;
758
+ workMode?: "DIRECT" | "BRANCH" | undefined;
761
759
  pausedUntil?: string | null | undefined;
762
760
  agentLatestVersion?: string | undefined;
763
761
  skipAgentPrompt?: boolean | undefined;
@@ -768,13 +766,12 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
768
766
  baseBranch?: string | undefined;
769
767
  baseSha?: string | undefined;
770
768
  } | undefined;
771
- workMode?: "DIRECT" | "BRANCH" | undefined;
772
769
  }[];
773
- maxSessions?: number | undefined;
774
- }, {
775
770
  type: "hello_ack";
776
771
  serverName: string;
777
772
  serverId: string;
773
+ maxSessions?: number | undefined;
774
+ }, {
778
775
  sessions: {
779
776
  agent: "CLAUDE" | "CODEX";
780
777
  id: string;
@@ -806,6 +803,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
806
803
  token: string;
807
804
  } | undefined;
808
805
  mode?: "ask" | "plan" | "auto" | "full" | undefined;
806
+ workMode?: unknown;
809
807
  model?: string | null | undefined;
810
808
  effort?: string | null | undefined;
811
809
  epoch?: number | undefined;
@@ -818,8 +816,10 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
818
816
  skipAgentPrompt?: unknown;
819
817
  branchHint?: unknown;
820
818
  branchPlan?: unknown;
821
- workMode?: unknown;
822
819
  }[];
820
+ type: "hello_ack";
821
+ serverName: string;
822
+ serverId: string;
823
823
  maxSessions?: unknown;
824
824
  }>, z.ZodObject<{
825
825
  type: z.ZodLiteral<"event_ack">;
@@ -1093,6 +1093,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1093
1093
  url: string;
1094
1094
  token: string;
1095
1095
  } | undefined;
1096
+ workMode?: "DIRECT" | "BRANCH" | undefined;
1096
1097
  pausedUntil?: string | null | undefined;
1097
1098
  agentLatestVersion?: string | undefined;
1098
1099
  skipAgentPrompt?: boolean | undefined;
@@ -1103,7 +1104,6 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1103
1104
  baseBranch?: string | undefined;
1104
1105
  baseSha?: string | undefined;
1105
1106
  } | undefined;
1106
- workMode?: "DIRECT" | "BRANCH" | undefined;
1107
1107
  }, {
1108
1108
  agent: "CLAUDE" | "CODEX";
1109
1109
  id: string;
@@ -1135,6 +1135,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1135
1135
  token: string;
1136
1136
  } | undefined;
1137
1137
  mode?: "ask" | "plan" | "auto" | "full" | undefined;
1138
+ workMode?: unknown;
1138
1139
  model?: string | null | undefined;
1139
1140
  effort?: string | null | undefined;
1140
1141
  epoch?: number | undefined;
@@ -1147,7 +1148,6 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1147
1148
  skipAgentPrompt?: unknown;
1148
1149
  branchHint?: unknown;
1149
1150
  branchPlan?: unknown;
1150
- workMode?: unknown;
1151
1151
  }>;
1152
1152
  }, "strip", z.ZodTypeAny, {
1153
1153
  type: "session_start";
@@ -1189,6 +1189,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1189
1189
  url: string;
1190
1190
  token: string;
1191
1191
  } | undefined;
1192
+ workMode?: "DIRECT" | "BRANCH" | undefined;
1192
1193
  pausedUntil?: string | null | undefined;
1193
1194
  agentLatestVersion?: string | undefined;
1194
1195
  skipAgentPrompt?: boolean | undefined;
@@ -1199,7 +1200,6 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1199
1200
  baseBranch?: string | undefined;
1200
1201
  baseSha?: string | undefined;
1201
1202
  } | undefined;
1202
- workMode?: "DIRECT" | "BRANCH" | undefined;
1203
1203
  };
1204
1204
  }, {
1205
1205
  type: "session_start";
@@ -1234,6 +1234,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1234
1234
  token: string;
1235
1235
  } | undefined;
1236
1236
  mode?: "ask" | "plan" | "auto" | "full" | undefined;
1237
+ workMode?: unknown;
1237
1238
  model?: string | null | undefined;
1238
1239
  effort?: string | null | undefined;
1239
1240
  epoch?: number | undefined;
@@ -1246,7 +1247,6 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1246
1247
  skipAgentPrompt?: unknown;
1247
1248
  branchHint?: unknown;
1248
1249
  branchPlan?: unknown;
1249
- workMode?: unknown;
1250
1250
  };
1251
1251
  }>, z.ZodObject<{
1252
1252
  type: z.ZodLiteral<"session_message">;
@@ -1406,8 +1406,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1406
1406
  }, "strip", z.ZodTypeAny, {
1407
1407
  type: "workspace_settings";
1408
1408
  workspaceId: string;
1409
- trustMode?: "STRICT" | "NORMAL" | "AUTO" | undefined;
1410
1409
  agentAutoCommit?: boolean | undefined;
1410
+ trustMode?: "STRICT" | "NORMAL" | "AUTO" | undefined;
1411
1411
  agentPushBan?: boolean | undefined;
1412
1412
  agentProtectedBranches?: string[] | undefined;
1413
1413
  agentAllowForcePush?: boolean | undefined;
@@ -1415,8 +1415,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
1415
1415
  }, {
1416
1416
  type: "workspace_settings";
1417
1417
  workspaceId: string;
1418
- trustMode?: "STRICT" | "NORMAL" | "AUTO" | undefined;
1419
1418
  agentAutoCommit?: boolean | undefined;
1419
+ trustMode?: "STRICT" | "NORMAL" | "AUTO" | undefined;
1420
1420
  agentPushBan?: boolean | undefined;
1421
1421
  agentProtectedBranches?: string[] | undefined;
1422
1422
  agentAllowForcePush?: boolean | undefined;
@@ -359,6 +359,26 @@ export declare class Supervisor {
359
359
  * would be a lock bought for nothing.
360
360
  */
361
361
  private prepareWorkspace;
362
+ /**
363
+ * The working folder could not be built — say so in the feed, pin the fork
364
+ * point, and let the session go (#360).
365
+ *
366
+ * Both doors into `prepareWorkspace` end here. Three things happen in an
367
+ * order that is not free:
368
+ *
369
+ * 1. **The fork point is recorded first.** `git.ts` resolves it before it
370
+ * writes anything, precisely so that a failure still knows it. Without it
371
+ * the API keeps `base_sha = null` for the session's whole life, and
372
+ * «Continue» can never prove the leftover branch is the empty one this
373
+ * session created — which is the trap the ticket is about.
374
+ * 2. **The feed event goes out before the map entry is dropped**, because
375
+ * `sendEvent` needs it. Until now neither door sent one: a failed start
376
+ * was visible only as an `errorMessage` on the session row, and pressing
377
+ * «Continue» wipes that.
378
+ * 3. **Then the status, then the entry.** A FAILED session is over, and a
379
+ * leftover entry would hold one of the runner's few slots.
380
+ */
381
+ private workspacePrepareFailed;
362
382
  /**
363
383
  * Spin the adapter up — for a fresh session, a resume-on-next-message, or a
364
384
  * free CHAT session with no prompt at all (the agent boots, reports its
@@ -526,7 +546,8 @@ export declare class Supervisor {
526
546
  /**
527
547
  * Make room for one more agent process.
528
548
  *
529
- * Up to `maxSessions` agents run side by side, each in its own worktree. Over
549
+ * Up to `maxSessions` agents run side by side — since session 16 most of them
550
+ * share the project folder rather than each having a worktree of its own. Over
530
551
  * that, idle-but-resumable sessions (REVIEW / WAITING_INPUT) are parked —
531
552
  * their provider session survives on disk and relaunches on the next message,
532
553
  * so parking costs context nothing. Only mid-turn sessions (RUNNING /
@@ -754,10 +775,35 @@ export declare class Supervisor {
754
775
  * Three reasons it declines, and each of them is a state in which a snapshot
755
776
  * would be a lie rather than a restore point:
756
777
  * - the machine's owner switched checkpoints off;
757
- * - the agent is mid-turn, so the tree is being written to as we read it;
778
+ * - THIS session is mid-turn, so its own tree is being written as we read it;
758
779
  * - a repo-mutating command holds the repository.
780
+ *
781
+ * «A neighbour in the same folder is working» used to be a fourth reason, and
782
+ * it was the wrong one (#310): in DIRECT mode the folder is shared by design,
783
+ * so that rule silently switched restore points off for everybody the moment
784
+ * a second session opened. The neighbours are recorded on the point instead —
785
+ * the conversation can always be rewound to it, the files cannot.
786
+ *
787
+ * Every refusal is now audible. A restore point that was never taken is
788
+ * invisible until the day somebody reaches for it, and «the button is not
789
+ * there» is not a sentence anybody can act on.
759
790
  */
760
791
  private captureCheckpoint;
792
+ /**
793
+ * Say something once per BUSY PERIOD, not once per message (#310).
794
+ *
795
+ * A folder held by a neighbour stays held for minutes, and a session mid-turn
796
+ * can be sent three follow-up notes inside one answer. Keying this on the
797
+ * message seq would have counted each of those as its own turn and said the
798
+ * same sentence three times — the noise the frequency policy exists to
799
+ * prevent. The set is cleared when the session next comes to rest
800
+ * (`reportStatus`), which is exactly when the reason stops being true.
801
+ *
802
+ * A SET of keys, not the last one said: two different reasons can both come
803
+ * up inside one period, and remembering only the most recent would let them
804
+ * take turns re-announcing each other.
805
+ */
806
+ private noticeOncePerTurn;
761
807
  /**
762
808
  * Deliver messages that raced session start (already journaled).
763
809
  *
@@ -887,7 +933,15 @@ export declare class Supervisor {
887
933
  private stopWorkUnderPause;
888
934
  /** Live model / interaction-mode switch (persisted for the next relaunch). */
889
935
  private applySettings;
890
- /** Is a turn (or a question the agent is parked on) in flight right now? */
936
+ /**
937
+ * Is a turn (or a question the agent is parked on) in flight right now?
938
+ *
939
+ * The same status list as `holdsTheTree` plus one term: an open question
940
+ * parks the session without any of those statuses, and a live settings change
941
+ * must not land under it. Reading the shared array rather than spelling the
942
+ * statuses out again is the whole point of Р8 — a fourth waiting status has
943
+ * to arrive in one place, not four.
944
+ */
891
945
  private isMidTurn;
892
946
  /** The three live setters, in the order that lets an explicit pick win. */
893
947
  private applyLiveSettings;
@@ -976,8 +1030,50 @@ export declare class Supervisor {
976
1030
  * has to branch on the type, so "I did not check" cannot compile.
977
1031
  */
978
1032
  private requireSession;
1033
+ /**
1034
+ * The statuses in which a session is holding its working tree (#310).
1035
+ *
1036
+ * One array, read by all three predicates below. There used to be one
1037
+ * literal, then two would have been needed, and a third would have been
1038
+ * written the day somebody added a waiting state — which is how «the agent
1039
+ * is busy» and «the folder is busy» come to disagree about what busy means.
1040
+ * (`rewindActionsLive` in the dashboard is a fourth reader and a deliberate
1041
+ * mirror: the runner does not depend on `@devbridge/shared`.)
1042
+ */
1043
+ private static readonly MID_TURN_STATUSES;
1044
+ /**
1045
+ * Is a stop still in flight over this session's live process?
1046
+ *
1047
+ * Three terms, written out in three places before this: an open cycle, that
1048
+ * cycle owning the process that is running NOW, and the cycle not yet
1049
+ * settled. #373 is what makes it load-bearing — the resting status is
1050
+ * published before the process dies, so this is the difference between «the
1051
+ * session is quiet» and «the session is still writing files».
1052
+ */
1053
+ private static stopInFlight;
1054
+ private static holdsTheTree;
979
1055
  /** A session actively mid-turn in this worktree — git writes must wait. */
980
1056
  private isWorktreeBusy;
1057
+ /**
1058
+ * Is THIS session mid-turn? (#310)
1059
+ *
1060
+ * The question `isWorktreeBusy` was answering in three places where the right
1061
+ * question was this one. They are the same question only when a folder holds
1062
+ * exactly one session — and DIRECT mode, the default since session 16, is
1063
+ * precisely the arrangement in which it holds several. A restore point is a
1064
+ * snapshot of this session's own conversation; a neighbour typing in the same
1065
+ * folder is a reason to MARK it, not to refuse to take it.
1066
+ */
1067
+ private isSessionMidTurn;
1068
+ /**
1069
+ * Everybody else who is working in this folder right now (#310).
1070
+ *
1071
+ * Includes a neighbour whose stop is still running: after #373 the resting
1072
+ * status is published BEFORE the process is actually gone, so a session that
1073
+ * reports IDLE while its stop cycle still owns a live process is still
1074
+ * writing files. Reading `lastReported` alone would call that folder quiet.
1075
+ */
1076
+ private busyNeighbours;
981
1077
  private static readonly EVENT_PAYLOAD_CAP;
982
1078
  /**
983
1079
  * Journal an event, put it on the wire, and return it.