@zgeoff/atc 0.1.8 → 0.1.10

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/src/index.ts CHANGED
@@ -10,29 +10,39 @@ import { countSessionStates, sortGroupedSessionViews, sortSessionViews } from '.
10
10
  import type { SessionState } from './sessions';
11
11
  import { ansi, cols, drawHelp, drawHome, drawOverlay, drawPicker, drawStatusBar, rows } from './ui';
12
12
 
13
+ type AgentKind = 'claude' | 'grok';
14
+
13
15
  type Mode =
14
16
  | 'home'
15
17
  | 'attached'
16
18
  | 'overlay'
17
19
  | 'help'
20
+ | 'picker-agent'
18
21
  | 'picker-dir'
19
22
  | 'picker-name'
20
- | 'picker-group'
21
23
  | 'picker-prompt'
22
24
  | 'picker-eject';
23
25
 
26
+ const AGENT_PICKS = [
27
+ { agent: 'claude' as const, label: 'Claude' },
28
+ { agent: 'grok' as const, label: 'Grok' },
29
+ ];
30
+
24
31
  interface MirrorSession {
25
32
  id: string;
26
33
  name: string;
27
34
  cwd: string;
28
- group?: string;
35
+ pinned: boolean;
36
+ lastAttachedAt: number;
37
+ repoRoot: string;
29
38
  state: SessionState;
30
39
  unread: boolean;
31
40
  lastMsg: string;
32
41
  createdAt: number;
33
- kind: 'pty' | 'jsonl';
42
+ kind: 'pty' | 'headless';
34
43
  alive: boolean;
35
44
  resumable: boolean;
45
+ agent: AgentKind;
36
46
  }
37
47
 
38
48
  let mode: Mode = 'home';
@@ -56,8 +66,9 @@ let pickerInput = '';
56
66
  let pickerSelected = 0;
57
67
  let spawnDir = '';
58
68
  let spawnName = '';
59
- let spawnGroup = '';
60
69
  let spawnResume = false;
70
+ let spawnAgent: AgentKind = 'claude';
71
+ let lastUsedAgent: AgentKind = 'claude';
61
72
  const stdout = process.stdout;
62
73
 
63
74
  // Full height: the atc status bar only exists on home/overlay screens;
@@ -202,8 +213,11 @@ function applyHelpKey(buf: Buffer) {
202
213
  // fzf-style overlay search: null when inactive, the pattern while active.
203
214
  let overlayFilter: string | null = null;
204
215
 
216
+ // Flat list by default; g toggles clustering under repository headers.
217
+ let overlayGrouped = false;
218
+
205
219
  function pickOverlaySessions(): MirrorSession[] {
206
- const sorted = sortGroupedSessionViews(fleet);
220
+ const sorted = overlayGrouped ? sortGroupedSessionViews(fleet) : sortSessionViews(fleet);
207
221
 
208
222
  if (overlayFilter === null || overlayFilter === '') {
209
223
  return sorted;
@@ -211,9 +225,7 @@ function pickOverlaySessions(): MirrorSession[] {
211
225
 
212
226
  const f = overlayFilter;
213
227
 
214
- return sorted.filter(
215
- (s) => findFuzzyScore(`${s.name} ${s.group ?? ''} ${formatDir(s.cwd)}`, f) !== null,
216
- );
228
+ return sorted.filter((s) => findFuzzyScore(`${s.name} ${formatDir(s.cwd)}`, f) !== null);
217
229
  }
218
230
 
219
231
  function renderOverlay() {
@@ -227,6 +239,7 @@ function renderOverlay() {
227
239
  confirmKill,
228
240
  filter: overlayFilter,
229
241
  stale: daemonStale,
242
+ grouped: overlayGrouped,
230
243
  });
231
244
 
232
245
  scheduleStatus();
@@ -237,7 +250,7 @@ function openOverlay() {
237
250
  confirmKill = false;
238
251
  overlayFilter = null;
239
252
 
240
- const focusedIndex = sortGroupedSessionViews(fleet).findIndex((s) => s.id === focusedID);
253
+ const focusedIndex = pickOverlaySessions().findIndex((s) => s.id === focusedID);
241
254
 
242
255
  overlaySelected = Math.max(0, focusedIndex);
243
256
 
@@ -249,7 +262,17 @@ function openOverlay() {
249
262
  function renderPicker() {
250
263
  const verb = spawnResume ? 'adopt' : 'spawn';
251
264
 
252
- if (mode === 'picker-dir') {
265
+ if (mode === 'picker-agent') {
266
+ pickerSelected = Math.min(pickerSelected, AGENT_PICKS.length - 1);
267
+
268
+ drawPicker({
269
+ title: `${verb}: agent`,
270
+ items: AGENT_PICKS.map((pick) => pick.label),
271
+ selected: pickerSelected,
272
+ input: pickerInput,
273
+ hint: '↑↓ move · ⏎ select · esc cancel',
274
+ });
275
+ } else if (mode === 'picker-dir') {
253
276
  const items = pickMatches(allDirs, pickerInput).map((d) => formatDir(d));
254
277
 
255
278
  pickerSelected = Math.min(pickerSelected, Math.max(0, Math.min(items.length, 10) - 1));
@@ -270,18 +293,6 @@ function renderPicker() {
270
293
  placeholder: basename(spawnDir),
271
294
  hint: `session name for ${formatDir(spawnDir)} · ⏎ accept · esc back`,
272
295
  });
273
- } else if (mode === 'picker-group') {
274
- const groups = [...new Set(fleet.map((s) => s.group).filter((g) => g !== undefined))];
275
- const known = groups.length === 0 ? '' : ` · existing: ${groups.slice(0, 4).join(', ')}`;
276
-
277
- drawPicker({
278
- title: `${verb}: group`,
279
- items: [],
280
- selected: -1,
281
- input: pickerInput,
282
- placeholder: 'optional — ⏎ to skip',
283
- hint: `group for the overlay hierarchy${known} · ⏎ accept · esc back`,
284
- });
285
296
  } else if (mode === 'picker-eject') {
286
297
  drawPicker({
287
298
  title: 'eject: headless instruction',
@@ -305,10 +316,19 @@ function renderPicker() {
305
316
  scheduleStatus();
306
317
  }
307
318
 
308
- async function openDirPicker(resume = false) {
319
+ function openAgentPicker(resume = false) {
309
320
  spawnResume = resume;
310
- spawnGroup = '';
321
+ spawnAgent = lastUsedAgent;
322
+ pickerInput = '';
323
+ pickerSelected = lastUsedAgent === 'grok' ? 1 : 0;
324
+ mode = 'picker-agent';
325
+
326
+ stdout.write(ansi.clear);
327
+
328
+ renderPicker();
329
+ }
311
330
 
331
+ async function openDirPicker() {
312
332
  let recent: string[] = [];
313
333
 
314
334
  try {
@@ -355,7 +375,7 @@ async function spawnFromPicker(prompt: string) {
355
375
  cols: cols(),
356
376
  rows: ptyRows(),
357
377
  ...(spawnResume ? { resume: true } : {}),
358
- ...(spawnGroup === '' ? {} : { group: spawnGroup }),
378
+ agent: spawnAgent,
359
379
  });
360
380
 
361
381
  const spawned = toMirrorSession(ok['session']);
@@ -411,14 +431,18 @@ function toMirrorSession(value: unknown): MirrorSession | null {
411
431
  id: value['id'],
412
432
  name: value['name'],
413
433
  cwd: value['cwd'],
414
- ...(typeof value['group'] === 'string' ? { group: value['group'] } : {}),
434
+ pinned: value['pinned'] === true,
435
+ lastAttachedAt:
436
+ typeof value['lastAttachedAt'] === 'number' ? value['lastAttachedAt'] : value['createdAt'],
437
+ repoRoot: typeof value['repoRoot'] === 'string' ? value['repoRoot'] : value['cwd'],
415
438
  state,
416
439
  unread: value['unread'],
417
440
  lastMsg: value['lastMsg'],
418
441
  createdAt: value['createdAt'],
419
- kind: value['kind'] === 'jsonl' ? 'jsonl' : 'pty',
442
+ kind: value['kind'] === 'headless' ? 'headless' : 'pty',
420
443
  alive: value['alive'],
421
- resumable: typeof value['claudeId'] === 'string',
444
+ resumable: typeof value['agentSessionID'] === 'string',
445
+ agent: value['agent'] === 'grok' ? 'grok' : 'claude',
422
446
  };
423
447
  }
424
448
 
@@ -429,12 +453,16 @@ function upsertMirror(d: Readonly<MirrorSession>) {
429
453
  fleet.push({ ...d });
430
454
  } else {
431
455
  existing.name = d.name;
456
+ existing.pinned = d.pinned;
457
+ existing.lastAttachedAt = d.lastAttachedAt;
458
+ existing.repoRoot = d.repoRoot;
432
459
  existing.state = d.state;
433
460
  existing.unread = d.unread;
434
461
  existing.lastMsg = d.lastMsg;
435
462
  existing.kind = d.kind;
436
463
  existing.alive = d.alive;
437
464
  existing.resumable = d.resumable;
465
+ existing.agent = d.agent;
438
466
  }
439
467
  }
440
468
 
@@ -493,16 +521,16 @@ function applyDaemonEvent(e: EventMsg) {
493
521
  s.alive = false;
494
522
  }
495
523
 
496
- if (e['kind'] === 'pty' || e['kind'] === 'jsonl') {
524
+ if (e['kind'] === 'pty' || e['kind'] === 'headless') {
497
525
  s.kind = e['kind'];
498
526
  }
499
527
 
500
- if (typeof e['group'] === 'string') {
501
- if (e['group'] === '') {
502
- delete s.group;
503
- } else {
504
- s.group = e['group'];
505
- }
528
+ if (typeof e['pinned'] === 'boolean') {
529
+ s.pinned = e['pinned'];
530
+ }
531
+
532
+ if (typeof e['lastAttachedAt'] === 'number') {
533
+ s.lastAttachedAt = e['lastAttachedAt'];
506
534
  }
507
535
 
508
536
  if (typeof e['unread'] === 'boolean') {
@@ -513,7 +541,15 @@ function applyDaemonEvent(e: EventMsg) {
513
541
  s.lastMsg = e['lastMsg'];
514
542
  }
515
543
 
516
- if (typeof e['claudeId'] === 'string') {
544
+ if (e['agent'] === 'grok' || e['agent'] === 'claude') {
545
+ s.agent = e['agent'];
546
+ }
547
+
548
+ if (typeof e['agentSessionID'] === 'string') {
549
+ if (!s.resumable) {
550
+ lastUsedAgent = s.agent;
551
+ }
552
+
517
553
  s.resumable = true;
518
554
  }
519
555
  }
@@ -741,7 +777,27 @@ function applyOverlayKey(buf: Buffer) {
741
777
  }
742
778
 
743
779
  if (ch === 'n') {
744
- void openDirPicker();
780
+ openAgentPicker();
781
+
782
+ return;
783
+ }
784
+
785
+ if (ch === 'g') {
786
+ overlayGrouped = !overlayGrouped;
787
+
788
+ stdout.write(ansi.clear);
789
+
790
+ renderOverlay();
791
+
792
+ return;
793
+ }
794
+
795
+ if (ch === 'p' && sel !== undefined) {
796
+ // Flipped locally too so the repaint is immediate; the daemon's state
797
+ // event confirms it.
798
+ sel.pinned = !sel.pinned;
799
+ void sendQuiet('session.update', { session: sel.id, pinned: sel.pinned });
800
+ renderOverlay();
745
801
 
746
802
  return;
747
803
  }
@@ -758,7 +814,7 @@ function applyOverlayKey(buf: Buffer) {
758
814
  return;
759
815
  }
760
816
 
761
- if (ch === 'H' && sel !== undefined && sel.kind === 'pty' && sel.alive) {
817
+ if (ch === 'H' && sel !== undefined && sel.kind === 'pty' && sel.alive && sel.agent !== 'grok') {
762
818
  ejectTarget = sel.id;
763
819
  pickerInput = '';
764
820
  mode = 'picker-eject';
@@ -770,14 +826,14 @@ function applyOverlayKey(buf: Buffer) {
770
826
  return;
771
827
  }
772
828
 
773
- if (ch === 'P' && sel !== undefined && sel.resumable && (sel.kind === 'jsonl' || !sel.alive)) {
829
+ if (ch === 'P' && sel !== undefined && sel.resumable && (sel.kind === 'headless' || !sel.alive)) {
774
830
  void adoptSession(sel.id);
775
831
 
776
832
  return;
777
833
  }
778
834
 
779
835
  if (ch === 'r') {
780
- void openDirPicker(true);
836
+ openAgentPicker(true);
781
837
 
782
838
  return;
783
839
  }
@@ -879,7 +935,7 @@ function applyTextKey(buf: Buffer, onSubmit: () => void, onCancel: () => void) {
879
935
  return;
880
936
  }
881
937
 
882
- if (mode === 'picker-dir') {
938
+ if (mode === 'picker-dir' || mode === 'picker-agent') {
883
939
  if (isDown(buf)) {
884
940
  pickerSelected++;
885
941
 
@@ -919,6 +975,7 @@ const boot = await bootDaemonClient();
919
975
  let client = boot.client;
920
976
  let daemonStale = boot.stale;
921
977
 
978
+ lastUsedAgent = boot.lastUsedAgent;
922
979
  client.onEvent = applyDaemonEvent;
923
980
 
924
981
  await refreshMirror();
@@ -975,6 +1032,7 @@ async function restartDaemon() {
975
1032
 
976
1033
  client = next.client;
977
1034
  daemonStale = next.stale;
1035
+ lastUsedAgent = next.lastUsedAgent;
978
1036
  client.onEvent = applyDaemonEvent;
979
1037
 
980
1038
  await sendQuiet('fleet.restore', { cols: cols(), rows: ptyRows() });
@@ -1018,13 +1076,13 @@ process.stdin.on('data', (buf: Buffer) => {
1018
1076
  const ch = buf.toString();
1019
1077
 
1020
1078
  if (ch === 'n') {
1021
- void openDirPicker();
1079
+ openAgentPicker();
1022
1080
 
1023
1081
  return;
1024
1082
  }
1025
1083
 
1026
1084
  if (ch === 'r') {
1027
- void openDirPicker(true);
1085
+ openAgentPicker(true);
1028
1086
 
1029
1087
  return;
1030
1088
  }
@@ -1051,6 +1109,20 @@ process.stdin.on('data', (buf: Buffer) => {
1051
1109
 
1052
1110
  return;
1053
1111
  }
1112
+ case 'picker-agent': {
1113
+ applyTextKey(
1114
+ buf,
1115
+ () => {
1116
+ spawnAgent = AGENT_PICKS[pickerSelected]?.agent ?? 'claude';
1117
+ void openDirPicker();
1118
+ },
1119
+ () => {
1120
+ toBase();
1121
+ },
1122
+ );
1123
+
1124
+ return;
1125
+ }
1054
1126
  case 'picker-dir': {
1055
1127
  applyTextKey(
1056
1128
  buf,
@@ -1075,28 +1147,10 @@ process.stdin.on('data', (buf: Buffer) => {
1075
1147
 
1076
1148
  renderPicker();
1077
1149
  },
1078
- () => {
1079
- toBase();
1080
- },
1081
- );
1082
-
1083
- return;
1084
- }
1085
- case 'picker-name': {
1086
- applyTextKey(
1087
- buf,
1088
- () => {
1089
- spawnName = pickerInput.trim();
1090
- pickerInput = '';
1091
- mode = 'picker-group';
1092
-
1093
- stdout.write(ansi.clear);
1094
-
1095
- renderPicker();
1096
- },
1097
1150
  () => {
1098
1151
  pickerInput = '';
1099
- mode = 'picker-dir';
1152
+ pickerSelected = spawnAgent === 'grok' ? 1 : 0;
1153
+ mode = 'picker-agent';
1100
1154
 
1101
1155
  stdout.write(ansi.clear);
1102
1156
 
@@ -1106,11 +1160,11 @@ process.stdin.on('data', (buf: Buffer) => {
1106
1160
 
1107
1161
  return;
1108
1162
  }
1109
- case 'picker-group': {
1163
+ case 'picker-name': {
1110
1164
  applyTextKey(
1111
1165
  buf,
1112
1166
  () => {
1113
- spawnGroup = pickerInput.trim();
1167
+ spawnName = pickerInput.trim();
1114
1168
  pickerInput = '';
1115
1169
 
1116
1170
  // Adopt skips the prompt step: claude --resume opens its own
@@ -1129,7 +1183,7 @@ process.stdin.on('data', (buf: Buffer) => {
1129
1183
  },
1130
1184
  () => {
1131
1185
  pickerInput = '';
1132
- mode = 'picker-name';
1186
+ mode = 'picker-dir';
1133
1187
 
1134
1188
  stdout.write(ansi.clear);
1135
1189
 
@@ -1147,7 +1201,7 @@ process.stdin.on('data', (buf: Buffer) => {
1147
1201
  },
1148
1202
  () => {
1149
1203
  pickerInput = '';
1150
- mode = 'picker-group';
1204
+ mode = 'picker-name';
1151
1205
 
1152
1206
  stdout.write(ansi.clear);
1153
1207
 
package/src/mcp-server.ts CHANGED
@@ -43,13 +43,18 @@ const TOOLS: readonly MCPTool[] = [
43
43
  {
44
44
  name: 'atc_session_spawn',
45
45
  description:
46
- 'Spawn a new Claude Code session in a directory. Returns the new session descriptor. Give it a prompt to start it working immediately.',
46
+ 'Spawn a new session in a directory. Optional agent is claude or grok; omitted agent is always Claude, never the TUI last-used value. Returns the new session descriptor. Give it a prompt to start it working immediately.',
47
47
  inputSchema: {
48
48
  type: 'object',
49
49
  properties: {
50
50
  cwd: { type: 'string', description: 'Absolute path of the working directory' },
51
51
  name: { type: 'string', description: 'Session name; defaults to the directory basename' },
52
52
  prompt: { type: 'string', description: 'First message for the session' },
53
+ agent: {
54
+ type: 'string',
55
+ enum: ['claude', 'grok'],
56
+ description: 'Which agent CLI to spawn; defaults to claude',
57
+ },
53
58
  },
54
59
  required: ['cwd'],
55
60
  additionalProperties: false,
@@ -72,13 +77,13 @@ const TOOLS: readonly MCPTool[] = [
72
77
  {
73
78
  name: 'atc_session_update',
74
79
  description:
75
- 'Rename and/or regroup a session. Renames stick against auto-summaries; an empty group clears the grouping. Use this to organise the fleet: name sessions after their task and group related work.',
80
+ 'Rename and/or pin a session. Renames stick against auto-summaries; pinned sessions lead every list. Use this to organise the fleet: name sessions after their task.',
76
81
  inputSchema: {
77
82
  type: 'object',
78
83
  properties: {
79
84
  session: { type: 'string', description: 'The atc session id' },
80
85
  name: { type: 'string', description: 'New display name; omit to keep' },
81
- group: { type: 'string', description: 'New group; empty string clears, omit to keep' },
86
+ pinned: { type: 'boolean', description: 'Pin or unpin; omit to keep' },
82
87
  },
83
88
  required: ['session'],
84
89
  additionalProperties: false,
@@ -236,10 +241,17 @@ async function runTool(
236
241
  return JSON.stringify(ok['sessions'], null, 2);
237
242
  }
238
243
  case 'atc_session_spawn': {
244
+ const rawAgent = args['agent'];
245
+
246
+ if (rawAgent !== undefined && rawAgent !== 'claude' && rawAgent !== 'grok') {
247
+ throw new Error("agent must be 'claude' or 'grok'");
248
+ }
249
+
239
250
  const ok = await client.sendRequest('session.spawn', {
240
251
  cwd: args['cwd'],
241
252
  ...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
242
253
  ...(typeof args['prompt'] === 'string' ? { prompt: args['prompt'] } : {}),
254
+ ...(rawAgent === 'claude' || rawAgent === 'grok' ? { agent: rawAgent } : {}),
243
255
  cols: 100,
244
256
  rows: 30,
245
257
  });
@@ -258,7 +270,7 @@ async function runTool(
258
270
  await client.sendRequest('session.update', {
259
271
  session: args['session'],
260
272
  ...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
261
- ...(typeof args['group'] === 'string' ? { group: args['group'] } : {}),
273
+ ...(typeof args['pinned'] === 'boolean' ? { pinned: args['pinned'] } : {}),
262
274
  });
263
275
 
264
276
  return 'updated';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Canonicalize a hook event name to PascalCase. Claude already sends
3
+ * PascalCase (`SessionStart`); Grok sends snake_case (`session_start`).
4
+ * Restore stagger and the adapter switch compare the PascalCase form.
5
+ */
6
+ export function normalizeHookEventName(raw: string): string {
7
+ return raw
8
+ .split('_')
9
+ .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
10
+ .join('');
11
+ }
@@ -4,9 +4,11 @@ import { sortSessionViews } from './sessions';
4
4
  interface TabCandidate {
5
5
  readonly id: string;
6
6
  readonly state: SessionState;
7
+ readonly pinned: boolean;
8
+ readonly lastAttachedAt: number;
7
9
  readonly createdAt: number;
8
10
  readonly alive: boolean;
9
- readonly kind: 'pty' | 'jsonl';
11
+ readonly kind: 'pty' | 'headless';
10
12
  }
11
13
 
12
14
  /**
@@ -0,0 +1,27 @@
1
+ import { buildCLICommand } from './build-cli-command';
2
+
3
+ const GROK_HOOK_EVENTS = [
4
+ 'SessionStart',
5
+ 'SessionEnd',
6
+ 'UserPromptSubmit',
7
+ 'Stop',
8
+ 'StopFailure',
9
+ 'StopCancelled',
10
+ 'Notification',
11
+ ] as const;
12
+
13
+ /**
14
+ * Print the Grok hook file to stdout. The operator copies it to
15
+ * `$GROK_HOME/hooks/atc-reporter.json`; atc never writes that path.
16
+ */
17
+ export function printGrokHookFile(): void {
18
+ process.stdout.write(buildGrokHookFile());
19
+ }
20
+
21
+ function buildGrokHookFile(): string {
22
+ const cmd = buildCLICommand('hook-report');
23
+ const entry = [{ hooks: [{ type: 'command', command: cmd, timeout: 5 }] }];
24
+ const hooks = Object.fromEntries(GROK_HOOK_EVENTS.map((event) => [event, entry]));
25
+
26
+ return `${JSON.stringify({ hooks }, null, 2)}\n`;
27
+ }
package/src/protocol.ts CHANGED
@@ -5,7 +5,7 @@ import { isRecord } from './report';
5
5
  * per line. Three message kinds, distinguished by which fields are present —
6
6
  * request (id + m), response (id + ok or err), event (ev).
7
7
  */
8
- export const PROTOCOL_V = 1;
8
+ export const PROTOCOL_V = 2;
9
9
 
10
10
  // Control lines are capped before buffering; PTY output is split into chunks
11
11
  // so a queued response is delayed by at most one chunk.
@@ -0,0 +1,51 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+
4
+ /**
5
+ * The repository a directory belongs to, for clustering sessions in the
6
+ * overlay. Walks toward the filesystem root looking for a `.git` entry: a
7
+ * directory marks the repository root itself, and a linked worktree's `.git`
8
+ * file points back at the main repository, so worktrees cluster with it. A
9
+ * directory outside any repository resolves to itself.
10
+ */
11
+ export function resolveRepoRoot(cwd: string): string {
12
+ let dir = cwd;
13
+
14
+ while (true) {
15
+ const marker = join(dir, '.git');
16
+ const stat = statSync(marker, { throwIfNoEntry: false });
17
+
18
+ if (stat !== undefined && stat.isDirectory()) {
19
+ return dir;
20
+ }
21
+
22
+ if (stat !== undefined && stat.isFile()) {
23
+ return findMainRoot(marker) ?? dir;
24
+ }
25
+
26
+ const parent = dirname(dir);
27
+
28
+ if (parent === dir) {
29
+ return cwd;
30
+ }
31
+
32
+ dir = parent;
33
+ }
34
+ }
35
+
36
+ // A linked worktree's `.git` file reads `gitdir: <main>/.git/worktrees/<name>`.
37
+ function findMainRoot(gitFile: string): string | null {
38
+ try {
39
+ const gitdir = /^gitdir:\s*(?<dir>.+)$/mu
40
+ .exec(readFileSync(gitFile, 'utf8'))
41
+ ?.groups?.['dir']?.trim();
42
+
43
+ if (gitdir === undefined) {
44
+ return null;
45
+ }
46
+
47
+ return /^(?<root>.+)\/\.git\/worktrees\/[^/]+$/u.exec(gitdir)?.groups?.['root'] ?? null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }