@gevezex/gdt 0.2.0 → 0.4.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/README.md CHANGED
@@ -121,8 +121,24 @@ evidence too.
121
121
  | Node 24 LTS | runs gdt |
122
122
  | `git` | the shared checkout the roles work in |
123
123
  | `gh`, logged in (`gh auth login`) | issues, pull requests, comments, checks |
124
- | at least one agent CLI | `claude`, `codex`, `opencode`, `mcode`, `pi` or `omp` |
125
- | [herdr](https://herdr.dev) 0.9.1+ (optional) | watch the roles live; otherwise use `terminal = "headless"` |
124
+ | at least one agent CLI | `claude`, `codex`, `opencode`, `mcode`, `pi` or `omp` (see below) |
125
+ | [herdr](https://herdr.dev) 0.9.1+ | the default way to watch the roles live, one tab per role; on machines without herdr set `workflow.terminal = "headless"` |
126
+
127
+ ### Agent prerequisites
128
+
129
+ Before the first `gdt start`, every agent CLI you name in the user config
130
+ (`~/.config/gdt/config.toml`) must already work on your machine with the exact
131
+ model id you set there. For each role:
132
+
133
+ 1. install the agent CLI,
134
+ 2. log in or configure its API key or subscription,
135
+ 3. run it once successfully with the model id from `roles.<role>.model` (for
136
+ example `opencode/deepseek/deepseek-v4-flash`).
137
+
138
+ gdt runs the agent CLI as is, so the agent uses its own login, tokens and
139
+ credits. gdt stores no credentials and does not log in, choose a plan, or track
140
+ token use or costs. `gdt doctor` only checks that the CLI is on `PATH`, not that
141
+ its login, API key or model works.
126
142
 
127
143
  ## Install
128
144
 
@@ -156,28 +172,25 @@ It copies `skill/SKILL.md` into the skill directory of every agent CLI it finds
156
172
 
157
173
  ### 1. Configure the target repository
158
174
 
159
- In the repository you want gdt to work on, commit `.gdt/config.toml`:
160
-
161
- ```toml
162
- language = "en" # language for issue and PR text: "en" or "nl"
163
-
164
- [roles.developer]
165
- agent = "opencode"
166
- model = "deepseek/deepseek-v4-flash"
175
+ In the repository you want gdt to work on, create `.gdt/config.toml` with
176
+ `gdt init` instead of writing TOML by hand. Ask your coding agent, or run it
177
+ yourself:
167
178
 
168
- [roles.tester]
169
- agent = "claude"
170
- model = "claude-sonnet-5"
171
-
172
- [roles.reviewer]
173
- agent = "codex"
174
- model = "gpt-5.6-luna"
175
-
176
- [workflow]
177
- required_checks = ["test"] # CI check names that must be green
178
- terminal = "herdr" # or "headless"
179
+ ```bash
180
+ gdt init --developer opencode/deepseek/deepseek-v4-flash \
181
+ --tester claude/claude-sonnet-5 \
182
+ --reviewer codex/gpt-5.6-luna
179
183
  ```
180
184
 
185
+ `gdt init` writes the roles to your user config (`~/.config/gdt/config.toml`,
186
+ or `$XDG_CONFIG_HOME/gdt/config.toml`), writes the project settings to
187
+ `.gdt/config.toml`, runs `gdt doctor` and installs the operator skill. Without the
188
+ three role options, it reuses the roles from your user config when they are all
189
+ there; otherwise it only reports what it found (agents on `PATH`, the terminal,
190
+ the detected CI checks) so your agent can discuss the roles with you first. It
191
+ never overwrites an existing config without `--force`, and it requires at least
192
+ one required check unless you pass `--allow-no-required-checks`.
193
+
181
194
  Then check your setup:
182
195
 
183
196
  ```bash
@@ -292,6 +305,7 @@ Every command supports `--help`; `status` and `wait` also support `--json`.
292
305
 
293
306
  | Command | Effect |
294
307
  |---|---|
308
+ | `gdt init` | Write the roles to the user config and `.gdt/config.toml`, install the operator skill |
295
309
  | `gdt doctor` | Check tools, GitHub login, agents, herdr and `.gdt/config.toml` |
296
310
  | `gdt check-issue <n>` | Validate an issue body against the contract |
297
311
  | `gdt start <n>` | Preflight, start the supervisor and workers, return |
@@ -308,22 +322,55 @@ Every command supports `--help`; `status` and `wait` also support `--json`.
308
322
 
309
323
  ## Configuration
310
324
 
311
- `.gdt/config.toml` is committed; `.gdt/config.local.toml` holds machine-local
312
- overrides (for example another model) and is not committed.
325
+ Roles belong to a person, not to a repository, so gdt keeps them in a per-user
326
+ config. gdt loads three files and merges them in this order, with the later file
327
+ winning per key:
328
+
329
+ 1. the **user config** — `$XDG_CONFIG_HOME/gdt/config.toml`, or
330
+ `~/.config/gdt/config.toml` when `XDG_CONFIG_HOME` is not set. It holds only
331
+ `[roles.*]`. A relative `XDG_CONFIG_HOME` is ignored.
332
+ 2. the **repository config** — `.gdt/config.toml`, committed. It holds the project
333
+ settings and must not contain `[roles.*]`.
334
+ 3. the **local config** — `.gdt/config.local.toml`, never committed. It overrides
335
+ any key, for example one role's model on this machine.
336
+
337
+ `gdt init` writes the roles to the user config and the project settings to
338
+ `.gdt/config.toml`. Ready-made files are in [`examples/`](examples/):
339
+ [`examples/user-config.toml`](examples/user-config.toml),
340
+ [`examples/config.toml`](examples/config.toml) and
341
+ [`examples/config.local.toml`](examples/config.local.toml).
342
+
343
+ | Key | File | Default | Meaning |
344
+ |---|---|---|---|
345
+ | `roles.<role>.agent` | user | required | `claude`, `codex`, `opencode`, `mcode`, `pi` or `omp` |
346
+ | `roles.<role>.model` | user | required | Model id for that agent |
347
+ | `language` | repository | `"en"` | Language of issue and PR text (`en`, `nl`) |
348
+ | `workflow.required_checks` | repository | required | CI checks that must be green before `ready_to_merge` |
349
+ | `workflow.allow_no_required_checks` | repository | `false` | Allow an empty `required_checks` list |
350
+ | `workflow.max_correction_rounds` | repository | `2` | Correction rounds after round 0 |
351
+ | `workflow.terminal` | repository | `"herdr"` | `"herdr"` or `"headless"` |
352
+ | `workflow.supervisor_pane` | repository | `false` | herdr: also show the supervisor in a pane |
353
+ | `workflow.herdr_layout` | repository | `tabs` | herdr: `"tabs"` (one tab per pane) or `"split"` (panes side by side in one tab) |
354
+ | `workflow.poll_seconds` | repository | `30` | How often the supervisor reads GitHub |
355
+ | `contract.max_acceptance_criteria` | repository | `8` | Maximum number of ACs per issue |
356
+ | `contract.extra_rules` | repository | none | File with project rules added to every role prompt |
357
+
358
+ ### Per-role rules
359
+
360
+ Each workflow role can have supplementary rules of its own, in the target repository:
361
+
362
+ | File | Role |
363
+ |---|---|
364
+ | `.gdt/roles/developer.md` | developer |
365
+ | `.gdt/roles/tester.md` | tester |
366
+ | `.gdt/roles/reviewer.md` | reviewer |
313
367
 
314
- | Key | Default | Meaning |
315
- |---|---|---|
316
- | `language` | `"en"` | Language of issue and PR text (`en`, `nl`) |
317
- | `roles.<role>.agent` | required | `claude`, `codex`, `opencode`, `mcode`, `pi` or `omp` |
318
- | `roles.<role>.model` | required | Model id for that agent |
319
- | `workflow.required_checks` | required | CI checks that must be green before `ready_to_merge` |
320
- | `workflow.allow_no_required_checks` | `false` | Allow an empty `required_checks` list |
321
- | `workflow.max_correction_rounds` | `2` | Correction rounds after round 0 |
322
- | `workflow.terminal` | `"herdr"` | `"herdr"` or `"headless"` |
323
- | `workflow.supervisor_pane` | `false` | herdr: also show the supervisor in a pane |
324
- | `workflow.poll_seconds` | `30` | How often the supervisor reads GitHub |
325
- | `contract.max_acceptance_criteria` | `8` | Maximum number of ACs per issue |
326
- | `contract.extra_rules` | none | File with project rules added to every role prompt |
368
+ When a file has content, gdt appends it to that role's prompt on every turn under a
369
+ `## Role rules` heading, after `## Project rules` when `contract.extra_rules` sets one.
370
+ An empty or missing file adds nothing and is not an error. This only adds: gdt's own role
371
+ files in the package are never replaced, so the protocol stays intact. `gdt init` creates
372
+ the three files empty so you can see where your rules go; commit them like
373
+ `.gdt/config.toml`.
327
374
 
328
375
  How each agent CLI is invoked is documented in [docs/agents.md](docs/agents.md).
329
376
 
@@ -331,11 +378,12 @@ How each agent CLI is invoked is documented in [docs/agents.md](docs/agents.md).
331
378
 
332
379
  ```text
333
380
  herdr workspace "gdt-251" headless
334
- ┌──────────────┬──────────────┬──────────────┐
335
- │ developer · │ tester · │ reviewer · │ detached processes,
336
- │ opencode · │ claude · │ codex · │ one log file each in
337
- │ RUNNING │ WAITING │ WAITING │ .git/gdt/issue-251/logs/
338
- └──────────────┴──────────────┴──────────────┘
381
+ ┌────────────┐ ┌────────────┐ ┌────────────┐
382
+ │ developer │ │ tester │ │ reviewer │ detached processes,
383
+ │ opencode · │ │ claude · │ │ codex · │ one log file each in
384
+ │ RUNNING │ │ WAITING │ │ WAITING │ .git/gdt/issue-251/logs/
385
+ └────────────┘ └────────────┘ └────────────┘
386
+ one tab per role, labelled developer, tester and reviewer
339
387
  supervisor runs detached → logs/supervisor.log
340
388
  ```
341
389
 
@@ -3,6 +3,8 @@ export const claude = {
3
3
  binary: "claude",
4
4
  title: "Claude Code",
5
5
  install: "Install Claude Code: https://docs.anthropic.com/en/docs/claude-code",
6
+ modelFormat: "<model>",
7
+ modelExample: "claude-sonnet-5",
6
8
  buildInvocation: (_role, model, promptFile) => ({
7
9
  argv: ["claude", "-p", "--model", model, "--permission-mode", "bypassPermissions", "--no-session-persistence"],
8
10
  env: {},
@@ -3,6 +3,8 @@ export const codex = {
3
3
  binary: "codex",
4
4
  title: "Codex",
5
5
  install: "Install Codex: npm i -g @openai/codex",
6
+ modelFormat: "<model>",
7
+ modelExample: "gpt-5.6-luna",
6
8
  buildInvocation: (_role, model, promptFile, cwd) => ({
7
9
  argv: ["codex", "exec", "--model", model, "--cd", cwd, "--dangerously-bypass-approvals-and-sandbox", "--ephemeral", "-"],
8
10
  env: {},
@@ -4,6 +4,8 @@ export const mcode = {
4
4
  binary: "mcode",
5
5
  title: "MCode",
6
6
  install: "Install MiniMax Code: npm i -g @minimax-ai/code",
7
+ modelFormat: "provider/model",
8
+ modelExample: "minimax/MiniMax-M3",
7
9
  buildInvocation: (_role, model, promptFile, cwd) => ({
8
10
  argv: ["mcode", "exec", "--model", model, "--cwd", cwd, "--permission", "full", "--input", "-"],
9
11
  env: {},
@@ -4,6 +4,8 @@ export const omp = {
4
4
  binary: "omp",
5
5
  title: "omp",
6
6
  install: "Install omp: https://omp.sh",
7
+ modelFormat: "provider/id",
8
+ modelExample: "openai/gpt-5.2",
7
9
  buildInvocation: (_role, model, promptFile) => ({
8
10
  argv: ["omp", "--print", "--model", model, "--no-session", "--auto-approve"],
9
11
  env: {},
@@ -5,6 +5,8 @@ export const opencode = {
5
5
  binary: "opencode",
6
6
  title: "OpenCode",
7
7
  install: "Install OpenCode: https://opencode.ai",
8
+ modelFormat: "provider/model",
9
+ modelExample: "deepseek/deepseek-v4-flash",
8
10
  buildInvocation: (_role, model, promptFile) => ({
9
11
  argv: ["opencode", "run", "--model", model, "--auto", "--file", promptFile, OPENCODE_MESSAGE],
10
12
  env: {},
package/dist/agents/pi.js CHANGED
@@ -4,6 +4,8 @@ export const pi = {
4
4
  binary: "pi",
5
5
  title: "pi",
6
6
  install: "Install pi: npm i -g --ignore-scripts @earendil-works/pi-coding-agent",
7
+ modelFormat: "provider/id",
8
+ modelExample: "anthropic/claude-sonnet-4",
7
9
  buildInvocation: (_role, model, promptFile) => ({
8
10
  argv: ["pi", "--print", "--model", model, "--no-session", "--no-approve"],
9
11
  env: {},
@@ -37,6 +37,9 @@ export function headless(logs, cwd, env) {
37
37
  setTitle: () => {
38
38
  // Headless panes have no title.
39
39
  },
40
+ setDisplayAgent: () => {
41
+ // Headless panes have no agents overview.
42
+ },
40
43
  reportState: () => {
41
44
  // AC-7: headless mode reports no agent state.
42
45
  },
@@ -45,6 +45,19 @@ function reportAgent(opts, paneId, label, state) {
45
45
  throw new Error(`herdr pane report-agent: ${reason}`);
46
46
  }
47
47
  }
48
+ /**
49
+ * AC-1: sets the display-only agent label of a pane. herdr 0.9.1 requires the pane id before the
50
+ * options here (`herdr pane report-metadata <pane> --source gdt --display-agent <label>`); with the
51
+ * options first it exits 2 with `unknown option: gdt`.
52
+ */
53
+ function reportDisplayAgent(opts, paneId, label) {
54
+ const args = ["pane", "report-metadata", paneId, "--source", "gdt", "--display-agent", label];
55
+ const result = spawnSync(bin(opts.env), args, { cwd: opts.root, env: opts.env, encoding: "utf8" });
56
+ if (result.status !== 0) {
57
+ const reason = (result.stderr ?? "").trim() || (result.stdout ?? "").trim() || `herdr exited with ${result.status ?? result.signal}`;
58
+ throw new Error(`herdr pane report-metadata: ${reason}`);
59
+ }
60
+ }
48
61
  /** Parses the `{ "result": ... }` envelope; a herdr `error` becomes a thrown Error. */
49
62
  function resultOf(out, command) {
50
63
  let data;
@@ -69,12 +82,59 @@ function workspaceList(opts) {
69
82
  return [];
70
83
  return list.filter((w) => isRecord(w) && typeof w.workspace_id === "string");
71
84
  }
72
- function paneIds(opts, workspaceId) {
85
+ /** `pane list --workspace`, with the tab each pane currently lives in. */
86
+ function paneLocations(opts, workspaceId) {
73
87
  const result = resultOf(call(["pane", "list", "--workspace", workspaceId], opts), "pane list");
74
88
  const list = result.panes;
75
89
  if (!Array.isArray(list))
76
90
  return [];
77
- return list.flatMap((p) => (isRecord(p) && typeof p.pane_id === "string" ? [p.pane_id] : []));
91
+ return list.flatMap((p) => isRecord(p) && typeof p.pane_id === "string"
92
+ ? [{ pane_id: p.pane_id, ...(typeof p.tab_id === "string" ? { tab_id: p.tab_id } : {}) }]
93
+ : []);
94
+ }
95
+ /** `tab list --workspace`, in herdr's order. */
96
+ function tabLocations(opts, workspaceId) {
97
+ const result = resultOf(call(["tab", "list", "--workspace", workspaceId], opts), "tab list");
98
+ const list = result.tabs;
99
+ if (!Array.isArray(list))
100
+ return [];
101
+ return list.flatMap((tab) => isRecord(tab) && typeof tab.tab_id === "string"
102
+ ? [
103
+ {
104
+ tab_id: tab.tab_id,
105
+ label: typeof tab.label === "string" ? tab.label : "",
106
+ pane_count: typeof tab.pane_count === "number" ? tab.pane_count : 0,
107
+ },
108
+ ]
109
+ : []);
110
+ }
111
+ /** AC-3: creates a tab labelled with a pane name; its root pane becomes the managed pane. */
112
+ function createTab(opts, workspaceId, label) {
113
+ const args = ["tab", "create", "--workspace", workspaceId, "--cwd", opts.root, "--label", label, "--no-focus"];
114
+ const result = resultOf(call(args, opts), "tab create");
115
+ const pane = result.root_pane;
116
+ if (!isRecord(pane) || typeof pane.pane_id !== "string")
117
+ throw new Error("herdr tab create did not return a root pane");
118
+ return pane.pane_id;
119
+ }
120
+ /** AC-5: moves an existing pane into a tab of its own, labelled with the pane name. */
121
+ function movePaneToNewTab(opts, pane, label) {
122
+ call(["pane", "move", pane, "--new-tab", "--label", label, "--no-focus"], opts);
123
+ }
124
+ /** AC-5: moves an existing pane next to `anchor` in `tab`, keeping `ratio` of the anchor's width. */
125
+ function movePaneIntoTab(opts, pane, tab, anchor, ratio) {
126
+ const args = ["pane", "move", pane, "--tab", tab, "--split", "right", "--target-pane", anchor, "--ratio", ratio.toFixed(4), "--no-focus"];
127
+ call(args, opts);
128
+ }
129
+ function renameTab(opts, tab, label) {
130
+ call(["tab", "rename", tab, label], opts);
131
+ }
132
+ /** AC-5: a tab that lost its last pane during a move must not remain. */
133
+ function closeEmptyTabs(opts, workspaceId, keep) {
134
+ for (const tab of tabLocations(opts, workspaceId)) {
135
+ if (tab.pane_count === 0 && !keep.has(tab.tab_id))
136
+ call(["tab", "close", tab.tab_id], opts);
137
+ }
78
138
  }
79
139
  function createWorkspace(opts) {
80
140
  const label = `gdt-${opts.issue}`;
@@ -110,6 +170,68 @@ function writePanes(file, state) {
110
170
  function roleTitle(role, agent, state) {
111
171
  return role === "supervisor" ? `supervisor · ${state}` : `${role} · ${agent} · ${state}`;
112
172
  }
173
+ /**
174
+ * AC-4/AC-5: all managed panes in one tab, left to right in `names` order. This is a no-op when the
175
+ * panes already share a tab, so a workspace created in split layout is left exactly as before.
176
+ */
177
+ function arrangeSplit(opts, workspaceId, names, mapping, paneTab) {
178
+ const paneOf = (name) => mapping[name]?.pane_id;
179
+ const first = names[0];
180
+ if (first === undefined)
181
+ return;
182
+ const targetPane = paneOf(first);
183
+ if (targetPane === undefined)
184
+ return;
185
+ const tabs = new Set(names.flatMap((name) => {
186
+ const pane = paneOf(name);
187
+ if (pane === undefined)
188
+ return [];
189
+ const tab = paneTab.get(pane);
190
+ return tab === undefined ? [] : [tab];
191
+ }));
192
+ if (tabs.size <= 1)
193
+ return;
194
+ const target = paneTab.get(targetPane);
195
+ if (target === undefined)
196
+ return;
197
+ let anchor = targetPane;
198
+ names.slice(1).forEach((name, index) => {
199
+ const pane = paneOf(name);
200
+ if (pane === undefined)
201
+ return;
202
+ if (paneTab.get(pane) !== target)
203
+ movePaneIntoTab(opts, pane, target, anchor, 1 / (names.length - (index + 1) + 1));
204
+ anchor = pane;
205
+ });
206
+ }
207
+ /** AC-3/AC-5: one tab per managed pane, labelled with the pane name, in `names` order. */
208
+ function arrangeTabs(opts, workspaceId, names, mapping) {
209
+ const tabByPane = new Map();
210
+ const countByTab = new Map();
211
+ for (const pane of paneLocations(opts, workspaceId)) {
212
+ if (pane.tab_id === undefined)
213
+ continue;
214
+ tabByPane.set(pane.pane_id, pane.tab_id);
215
+ countByTab.set(pane.tab_id, (countByTab.get(pane.tab_id) ?? 0) + 1);
216
+ }
217
+ const labels = new Map(tabLocations(opts, workspaceId).map((tab) => [tab.tab_id, tab.label]));
218
+ const kept = new Set();
219
+ for (const name of names) {
220
+ const pane = mapping[name]?.pane_id;
221
+ if (pane === undefined)
222
+ continue;
223
+ const tab = tabByPane.get(pane);
224
+ // Keep a pane that is already alone in a tab of its own; only relabel that tab when needed.
225
+ if (tab !== undefined && !kept.has(tab) && countByTab.get(tab) === 1) {
226
+ kept.add(tab);
227
+ if (labels.get(tab) !== name)
228
+ renameTab(opts, tab, name);
229
+ continue;
230
+ }
231
+ movePaneToNewTab(opts, pane, name);
232
+ }
233
+ closeEmptyTabs(opts, workspaceId, kept);
234
+ }
113
235
  /**
114
236
  * One herdr workspace per issue, with four panes for supervisor, developer, tester and reviewer.
115
237
  * Panes are reused across `start` calls; their ids live in `panes.json` so a restarted process can
@@ -145,7 +267,12 @@ export function herdr(opts) {
145
267
  else
146
268
  workspaceId = createWorkspace(opts).workspace_id;
147
269
  }
148
- const live = new Set(paneIds(opts, workspaceId));
270
+ const locations = paneLocations(opts, workspaceId);
271
+ const live = new Set(locations.map((pane) => pane.pane_id));
272
+ const paneTab = new Map();
273
+ for (const pane of locations)
274
+ if (pane.tab_id !== undefined)
275
+ paneTab.set(pane.pane_id, pane.tab_id);
149
276
  // AC-4: a supervisor pane from an earlier run (while `supervisor_pane` was true) is closed; its
150
277
  // entry never enters the new panes.json, so no role can adopt the pane.
151
278
  if (!opts.supervisorPane && known?.workspace_id === workspaceId) {
@@ -153,6 +280,7 @@ export function herdr(opts) {
153
280
  if (leftover !== undefined && live.has(leftover.pane_id)) {
154
281
  call(["pane", "close", leftover.pane_id], opts);
155
282
  live.delete(leftover.pane_id);
283
+ paneTab.delete(leftover.pane_id);
156
284
  }
157
285
  }
158
286
  const names = paneNames(opts);
@@ -172,23 +300,41 @@ export function herdr(opts) {
172
300
  mapping[name] = { pane_id: reuse };
173
301
  }
174
302
  }
175
- // Each new pane is split off the right of the previous one, so panes run left to right in
176
- // `names` order. The anchor keeps 1/(panes still to fill), which makes a new workspace's panes
177
- // equally wide.
178
- const first = Object.values(mapping).flatMap((p) => (p === undefined ? [] : [p.pane_id])).find((id) => id !== "");
179
- let previous;
180
- names.forEach((name, index) => {
181
- const record = mapping[name];
182
- if (record !== undefined) {
183
- previous = record.pane_id;
184
- return;
303
+ // Provide a pane for every name that still lacks one, in `names` order.
304
+ if (opts.layout === "tabs") {
305
+ // AC-3: each missing pane gets its own tab, labelled with the pane name.
306
+ for (const name of names) {
307
+ if (mapping[name] === undefined)
308
+ mapping[name] = { pane_id: createTab(opts, workspaceId, name) };
185
309
  }
186
- const anchor = previous ?? first;
187
- if (anchor === undefined)
188
- throw new Error(`herdr workspace ${workspaceId} has no pane to split`);
189
- previous = splitPane(opts, anchor, 1 / (names.length - index + 1));
190
- mapping[name] = { pane_id: previous };
191
- });
310
+ }
311
+ else {
312
+ // AC-4: each new pane is split off the right of the previous one, so panes run left to right
313
+ // in `names` order. The anchor keeps 1/(panes still to fill), which makes a new workspace's
314
+ // panes equally wide.
315
+ const first = Object.values(mapping).flatMap((p) => (p === undefined ? [] : [p.pane_id])).find((id) => id !== "");
316
+ let previous;
317
+ names.forEach((name, index) => {
318
+ const record = mapping[name];
319
+ if (record !== undefined) {
320
+ previous = record.pane_id;
321
+ return;
322
+ }
323
+ const anchor = previous ?? first;
324
+ if (anchor === undefined)
325
+ throw new Error(`herdr workspace ${workspaceId} has no pane to split`);
326
+ previous = splitPane(opts, anchor, 1 / (names.length - index + 1));
327
+ mapping[name] = { pane_id: previous };
328
+ const anchorTab = paneTab.get(anchor);
329
+ if (anchorTab !== undefined)
330
+ paneTab.set(previous, anchorTab);
331
+ });
332
+ }
333
+ // AC-3/AC-5: place the panes according to the configured layout.
334
+ if (opts.layout === "tabs")
335
+ arrangeTabs(opts, workspaceId, names, mapping);
336
+ else
337
+ arrangeSplit(opts, workspaceId, names, mapping, paneTab);
192
338
  writePanes(opts.panesFile, { workspace_id: workspaceId, panes: mapping });
193
339
  if (opts.supervisorPane)
194
340
  rename("supervisor", roleTitle("supervisor", "", "starting"));
@@ -223,6 +369,15 @@ export function herdr(opts) {
223
369
  return;
224
370
  rename(name, title);
225
371
  },
372
+ setDisplayAgent(name, label) {
373
+ // Without a supervisor pane there is no supervisor label to set.
374
+ if (name === "supervisor" && !opts.supervisorPane)
375
+ return;
376
+ const paneId = findPane(name);
377
+ if (paneId === null)
378
+ return;
379
+ reportDisplayAgent(opts, paneId, label);
380
+ },
226
381
  reportState(name, state) {
227
382
  // AC-6: without a supervisor pane no supervisor state is reported to herdr.
228
383
  if (name === "supervisor" && !opts.supervisorPane)
@@ -14,6 +14,7 @@ export function backendFor(config, root, issue, env, p) {
14
14
  logs: p.logs,
15
15
  agents,
16
16
  supervisorPane: config.workflow.supervisor_pane,
17
+ layout: config.workflow.herdr_layout,
17
18
  });
18
19
  }
19
20
  return headless(p.logs, root, env);