omp-conductor 0.15.11 → 0.15.13

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.
Files changed (51) hide show
  1. package/REFERENCE.md +107 -60
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +3 -0
  4. package/src/briefs/orchestrator.md +64 -11
  5. package/src/briefs/policy.md +19 -3
  6. package/src/briefs/worker.md +11 -8
  7. package/src/cli.ts +41 -21
  8. package/src/commands/context.ts +102 -1
  9. package/src/commands/doctor.ts +4 -2
  10. package/src/commands/intake.ts +26 -5
  11. package/src/commands/message.ts +80 -32
  12. package/src/commands/report.ts +38 -2
  13. package/src/commands/restart.ts +81 -54
  14. package/src/commands/setup.ts +61 -11
  15. package/src/commands/stop.ts +45 -22
  16. package/src/commands/upgrade-rollback.ts +9 -0
  17. package/src/config-schema.ts +9 -0
  18. package/src/config.ts +35 -1
  19. package/src/daemon.ts +588 -37
  20. package/src/dashboard/app.js +398 -59
  21. package/src/dashboard/index.html +27 -0
  22. package/src/dashboard/server.ts +219 -5
  23. package/src/dashboard/style.css +169 -1
  24. package/src/doctor.ts +419 -45
  25. package/src/escalate.ts +8 -0
  26. package/src/failure-class.ts +37 -0
  27. package/src/fleet.ts +49 -2
  28. package/src/gitops.ts +157 -0
  29. package/src/lifecycle.ts +113 -2
  30. package/src/model-fallback.ts +177 -0
  31. package/src/omp.ts +115 -13
  32. package/src/orchestrator-down.ts +231 -0
  33. package/src/orchestrator-tick.ts +108 -5
  34. package/src/orchestrator.ts +18 -4
  35. package/src/privileged.ts +10 -0
  36. package/src/release-policy.ts +373 -28
  37. package/src/session-host.ts +11 -5
  38. package/src/setup-host.ts +665 -70
  39. package/src/setup-install.ts +275 -28
  40. package/src/setup-wizard.ts +339 -126
  41. package/src/setup.ts +25 -0
  42. package/src/stop-provenance.ts +66 -0
  43. package/src/store.ts +194 -1
  44. package/src/tracker/github.ts +47 -0
  45. package/src/types.ts +182 -0
  46. package/src/upgrade.ts +110 -32
  47. package/src/verbs/protocol.ts +16 -3
  48. package/src/verbs/server.ts +27 -1
  49. package/src/wizard-ui.ts +261 -46
  50. package/src/worker.ts +24 -3
  51. package/systemd/omp-conductor.service.example +7 -3
package/src/setup-host.ts CHANGED
@@ -23,10 +23,13 @@ import {
23
23
  import {
24
24
  legacyArmedMarkerPath,
25
25
  readTickConfig,
26
+ parseHerdrAgents,
26
27
  TICK_CONFIG_FILE,
27
28
  tickConfigMatchesProject,
29
+ type HerdrAgentList,
28
30
  type TickConfig,
29
31
  } from "./orchestrator-tick.ts";
32
+ import { formatStep, type PrivilegedStep } from "./privileged.ts";
30
33
  import type { Caps, ConductorConfig, ProjectConfig } from "./types.ts";
31
34
 
32
35
  export const DEFAULT_TICK_INTERVAL_SECONDS = 900;
@@ -90,6 +93,17 @@ export interface BriefLinkPlan {
90
93
  skippedReason?: string;
91
94
  }
92
95
 
96
+ /**
97
+ * Why a host-global install (no `--project`) wrote no per-project tail, and
98
+ * what the operator does about it.
99
+ */
100
+ export interface HostRuntimeNoProject {
101
+ /** The project-scoped files the host-global install deliberately left unwritten. */
102
+ skipped: readonly string[];
103
+ /** The command that writes them: `setup host` naming one project. */
104
+ how: string;
105
+ }
106
+
93
107
  export interface HostRuntimePlan {
94
108
  service: PlannedWrite<string>;
95
109
  /**
@@ -115,12 +129,71 @@ export interface HostRuntimePlan {
115
129
  /**
116
130
  * `[terminal] default_shell` staged into the session's herdr config, the
117
131
  * belt-and-braces behind the unit's `SHELL=` (see {@link renderHerdrConfig}).
118
- * Present exactly when {@link herdrUnit} is.
132
+ * Present exactly when {@link herdrUnit} is, unless the login shell is not a
133
+ * usable path ({@link usableHerdrShell}) or the rendered config would not
134
+ * parse — either one leaves {@link herdrConfigProblem} set and writes
135
+ * nothing.
136
+ *
137
+ * This is the **staged** copy, under the state directory like every other
138
+ * file the plan writes: herdr's live config is a file conductor does not own,
139
+ * so it is only replaced post-consent by the install step naming
140
+ * {@link herdrConfigTarget} (#513).
119
141
  */
120
142
  herdrConfig?: PlannedWrite<string>;
143
+ /** The live herdr config {@link herdrConfig} merges into, post-consent. */
144
+ herdrConfigTarget?: string;
145
+ /** Why no pane-shell key is planned (unusable login shell, …). */
146
+ herdrConfigProblem?: string;
147
+ /**
148
+ * A loud note when the pane-shell merge kept an explicit
149
+ * `resume_agents_on_restore = true` — {@link renderHerdrConfig} preserves
150
+ * that value on purpose (it may be a desktop host), and the plan says so
151
+ * rather than silently overriding a deliberate choice.
152
+ */
153
+ herdrConfigWarning?: string;
154
+ /**
155
+ * The herdr-conductor plugin's `config.env`, staged like the pane-shell
156
+ * config and merged into the live plugin config dir post-consent: the file
157
+ * recover.sh sources (`herdr/bin/recover.sh`) — TARGET_SESSION, FLEET_CWDS
158
+ * (derived from every configured project), TELEGRAM_ENV, ACCESS_JSON.
159
+ * Present exactly when {@link herdrUnit} is; conductor-owned keys are
160
+ * patched in place, operator keys survive untouched.
161
+ */
162
+ herdrEnv?: PlannedWrite<string>;
163
+ /** The live plugin config.env {@link herdrEnv} merges into, post-consent. */
164
+ herdrEnvTarget?: string;
165
+ /**
166
+ * When {@link planTick} rewrote a shared-default `agentName`, the live herdr
167
+ * pane still carries the old identity — the ticking decline that names the
168
+ * fix (`herdr agent rename <pane> <name>`). Setup performs the rename after
169
+ * the install when the session is reachable and exactly one pane matches;
170
+ * anything else surfaces the exact command (never guesses a pane).
171
+ */
172
+ agentRename?: { session: string; from: string; to: string };
121
173
  tick?: PlannedWrite<TickConfig>;
122
174
  /** The `AGENTS.md` — composed-brief symlink — placed in the fleet cwd. */
123
- briefLink: BriefLinkPlan;
175
+ briefLink?: BriefLinkPlan;
176
+ /**
177
+ * Set when an install ran with no project named (a host-global install):
178
+ * the per-project tail was deliberately not written. Names the files and
179
+ * how to write them, so the operator is told what is missing and how to get
180
+ * it rather than silently installing host-global units that leave a project
181
+ * brief-less and tick-less.
182
+ */
183
+ noProject?: HostRuntimeNoProject;
184
+ /**
185
+ * The exact privileged steps this plan will run, in order — recovery first,
186
+ * so the daemon unit that follows names a recovery unit systemd already has.
187
+ * This is the single list: {@link installCommands} and `runHostInstall` are
188
+ * both derived from it, so a step can never appear in the printed plan and
189
+ * be absent from the run (#509).
190
+ */
191
+ steps: readonly PrivilegedStep[];
192
+ /**
193
+ * The same steps as `sudo …` shell lines, for humans to read. A projection
194
+ * of {@link steps} rather than a parallel list, so nothing executes a step
195
+ * the printed plan does not show.
196
+ */
124
197
  installCommands: readonly string[];
125
198
  cliSource: "global" | "plugin";
126
199
  /** Absolute path of the unit systemd actually reads. */
@@ -138,6 +211,12 @@ export interface HostRuntimePlan {
138
211
  * the installed unit matched.
139
212
  */
140
213
  installedAction: PlannedWrite<string>["action"];
214
+ /**
215
+ * True when every file the privileged install steps would write is already
216
+ * at its destination with the current bytes. `runHostInstall` uses it to
217
+ * make a no-op re-run of `setup host` neither restage nor restart anything.
218
+ */
219
+ currentInstall: boolean;
141
220
  }
142
221
 
143
222
  export interface ServiceRuntime {
@@ -192,10 +271,6 @@ function systemdPath(value: string): string {
192
271
  return value.replaceAll("%", "%%");
193
272
  }
194
273
 
195
- function shellQuote(value: string): string {
196
- return `'${value.replaceAll("'", "'\\''")}'`;
197
- }
198
-
199
274
  function actionFor(path: string, content: string): PlannedWrite<string>["action"] {
200
275
  if (!existsSync(path)) return "create";
201
276
  try {
@@ -413,7 +488,12 @@ export function renderDaemonService(runtime: ServiceRuntime, totalWorkers: numbe
413
488
  : [`Environment=${systemdQuote(`HERDR_SESSION=${runtime.herdrSession}`)}`]),
414
489
  `WorkingDirectory=${systemdPath(stateDir())}`,
415
490
  `ExecStart=${command.map(systemdQuote).join(" ")}`,
416
- "Restart=on-failure",
491
+ // #546: restart on any exit — clean, signalled or crashed — except an
492
+ // explicit `systemctl stop`, which systemd records as intentional and does
493
+ // not undo. A stray SIGTERM (a worker's `bun test`) then costs seconds of
494
+ // downtime instead of leaving the fleet down until a human. A crash loop
495
+ // still trips the start-limit burst and reaches `OnFailure=` above.
496
+ "Restart=always",
417
497
  "SuccessExitStatus=0 143",
418
498
  "MemoryAccounting=yes",
419
499
  `MemoryMax=${memoryMax}`,
@@ -461,7 +541,9 @@ export function renderHerdrUnit(runtime: ServiceRuntime): string {
461
541
  `Environment=${systemdQuote(`SHELL=${runtime.shell}`)}`,
462
542
  `WorkingDirectory=${systemdPath(runtime.conductorHome)}`,
463
543
  `ExecStart=${systemdQuote(runtime.herdr)} --session ${systemdQuote(session)} server`,
464
- "Restart=on-failure",
544
+ // #546: same exposure as the daemon — restart on any exit except an
545
+ // explicit `systemctl stop`; a crash loop still trips the start limit.
546
+ "Restart=always",
465
547
  "RestartSec=5",
466
548
  "",
467
549
  "[Install]",
@@ -487,7 +569,12 @@ export function renderHerdrUnit(runtime: ServiceRuntime): string {
487
569
  * except the two fleet units' failure, so recovery can never re-trigger
488
570
  * recovery.
489
571
  */
490
- export function renderRecoverUnit(runtime: ServiceRuntime, projectName: string): string {
572
+ export function renderRecoverUnit(runtime: ServiceRuntime, projectName: string | undefined): string {
573
+ // `RECOVER_PROJECT` is how the playbook addresses the tier-2 escalation it
574
+ // enqueues. It is a host-global unit installed once for the whole box, so a
575
+ // static per-project value is only unambiguous on a single-project host — a
576
+ // multi-project host (or a no-project, host-global install) leaves it unset
577
+ // and reports without a `--project` rather than guessing one (#510/#530).
491
578
  return [
492
579
  "[Unit]",
493
580
  "Description=omp-conductor fleet recovery (OnFailure handler)",
@@ -502,12 +589,31 @@ export function renderRecoverUnit(runtime: ServiceRuntime, projectName: string):
502
589
  `Environment=${systemdQuote(`PATH=${runtime.path}`)}`,
503
590
  `Environment=${systemdQuote(`OMP_CONDUCTOR_HOME=${runtime.conductorHome}`)}`,
504
591
  `Environment=${systemdQuote(`RECOVER_STATE_DIR=${stateDir()}`)}`,
505
- `Environment=${systemdQuote(`RECOVER_PROJECT=${projectName}`)}`,
592
+ ...(projectName === undefined ? [] : [`Environment=${systemdQuote(`RECOVER_PROJECT=${projectName}`)}`]),
506
593
  `ExecStart=${RECOVER_SCRIPT_INSTALL_PATH}`,
507
594
  "",
508
595
  ].join("\n");
509
596
  }
510
597
 
598
+ /**
599
+ * Whether a login-shell value may be pinned into herdr's config at all.
600
+ *
601
+ * `userInfo().shell` degrades to the literal `unknown` where the passwd entry
602
+ * cannot be read (the same root as #511's drift check), and `unknown` is not a
603
+ * path — a pane told to exec it dies on start, which is a stopped fleet. Only
604
+ * an absolute path that exists on this host qualifies; anything else must mean
605
+ * the caller writes **no** key and lets herdr's own `$SHELL → /bin/sh →
606
+ * passwd` fallback apply.
607
+ */
608
+ export function usableHerdrShell(shell: string): boolean {
609
+ if (!shell.startsWith("/")) return false;
610
+ try {
611
+ return existsSync(shell);
612
+ } catch {
613
+ return false;
614
+ }
615
+ }
616
+
511
617
  /**
512
618
  * Idempotently put `[terminal] default_shell` into a herdr config, preserving
513
619
  * everything else verbatim.
@@ -516,28 +622,280 @@ export function renderRecoverUnit(runtime: ServiceRuntime, projectName: string):
516
622
  * account's login shell even if an operator later edits the unit and drops the
517
623
  * `SHELL` line. Herdr falls back `$SHELL → /bin/sh` and skips the passwd entry,
518
624
  * so without this the only guarantee would be the unit's environment.
625
+ *
626
+ * A shell that is not a usable executable path ({@link usableHerdrShell})
627
+ * writes **no** key at all — the file passes through byte-identical, because
628
+ * "pin the shell" must never mean "write the word `unknown`".
519
629
  */
520
- export function renderHerdrConfig(shell: string, existing?: string): string {
521
- const quoted = `"${shell.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
630
+ export interface HerdrConfigRender {
631
+ content: string;
632
+ /**
633
+ * Set when the merged config kept an explicit `resume_agents_on_restore =
634
+ * true` — preserved on purpose (it may be a deliberate desktop config), but
635
+ * loudly flagged because the headless shape herdr's own README calls
636
+ * recoverable requires `false`. When absent, the value is `false` (written
637
+ * or already present) and nothing needs saying.
638
+ */
639
+ warning?: string;
640
+ }
641
+
642
+ export function renderHerdrConfig(shell: string, existing?: string): HerdrConfigRender {
522
643
  const text = existing ?? "";
644
+ // Unusable login shell → byte-identical pass-through, exactly as before: the
645
+ // "pin the shell" guarantee is all-or-nothing, and the fleet docs'
646
+ // `resume_agents_on_restore` key rides in on the same config merge, so an
647
+ // operator whose passwd entry cannot be resolved still gets nothing written.
648
+ if (!usableHerdrShell(shell)) return { content: text };
649
+
650
+ const quoted = `"${shell.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
523
651
  const line = `default_shell = ${quoted}`;
524
652
 
525
- // Any existing `default_shell = …` gets the new value in place — even when the
526
- // value already matches, so a re-run stays idempotent instead of duplicating
527
- // the key.
528
- if (/\s*default_shell\s*=/.test(text)) {
529
- return text.replace(/^(\s*default_shell\s*=\s*).*$/m, `$1${quoted}`);
653
+ // One anchored, comment-aware match, used for both the guard and the replace
654
+ // so they can never disagree: a *real* key starts its line (after whitespace),
655
+ // while `# default_shell = …` starts with a comment and is not a key. The
656
+ // old guard was unanchored, so it matched a commented line the anchored
657
+ // replace did not — a config whose only mention was a comment took the
658
+ // "replace" branch, replaced nothing, and silently wrote nothing at all.
659
+ // Any existing key keeps its position and gets the new value in place —
660
+ // even when the value already matches, so a re-run stays idempotent instead
661
+ // of duplicating the key.
662
+ const keyLine = /^([ \t]*default_shell[ \t]*=\s*).*$/m;
663
+ let content: string;
664
+ if (keyLine.test(text)) {
665
+ content = text.replace(keyLine, `$1${quoted}`);
666
+ } else {
667
+ const lines = text.split("\n");
668
+ const table = lines.findIndex((l) => /^\[terminal\]\s*$/.test(l));
669
+ if (table === -1) {
670
+ const body = text.replace(/\s+$/, "");
671
+ content = `${body.length > 0 ? body + "\n" : ""}[terminal]\n${line}\n`;
672
+ } else {
673
+ // The table exists but has no default_shell; drop the key right under it.
674
+ lines.splice(table + 1, 0, line);
675
+ content = lines.join("\n");
676
+ }
530
677
  }
531
678
 
532
- const lines = text.split("\n");
533
- const table = lines.findIndex((l) => /^\[terminal\]\s*$/.test(l));
534
- if (table === -1) {
535
- const body = text.replace(/\s+$/, "");
536
- return `${body.length > 0 ? body + "\n" : ""}[terminal]\n${line}\n`;
679
+ // [session] resume_agents_on_restore = false, the same anchored, comment-aware
680
+ // merge as default_shell: a real key is kept at its position, a commented-out
681
+ // mention is not a key. The one difference is explicit `true` (the value an
682
+ // operator pinned on a desktop host): it is preserved deliberately, and the
683
+ // caller warns about it rather than silently flipping a deliberate choice.
684
+ const resumeLine = "resume_agents_on_restore = false";
685
+ const resumeKey = /^[ \t]*resume_agents_on_restore[ \t]*=\s*([^#\n]*)/m;
686
+ const resumeMatch = resumeKey.exec(content);
687
+ const resumeValue = resumeMatch?.[1]?.trim().split(/\s+/)[0];
688
+ if (resumeMatch !== null) {
689
+ if (resumeValue === "false") {
690
+ content = content.replace(/^([ \t]*resume_agents_on_restore[ \t]*=\s*)[^#\n]*/m, `$1false`);
691
+ } else if (resumeValue !== undefined && resumeValue.length > 0) {
692
+ return {
693
+ content,
694
+ warning:
695
+ `explicit resume_agents_on_restore = ${resumeValue} kept as-is (could be a deliberate desktop config); ` +
696
+ "a headless fleet host leaves no live terminal for restored panes unless it is false",
697
+ };
698
+ }
699
+ } else {
700
+ const lines = content.split("\n");
701
+ const sessionTable = lines.findIndex((l) => /^\[session\]\s*$/.test(l));
702
+ if (sessionTable === -1) {
703
+ const body = content.replace(/\s+$/, "");
704
+ content = `${body.length > 0 ? body + "\n" : ""}[session]\n${resumeLine}\n`;
705
+ } else {
706
+ // The table exists but has no resume key; drop the key right under it.
707
+ lines.splice(sessionTable + 1, 0, resumeLine);
708
+ content = lines.join("\n");
709
+ }
710
+ }
711
+ return { content };
712
+ }
713
+
714
+ /**
715
+ * The values `setup host` owns in the herdr-conductor plugin's `config.env`.
716
+ *
717
+ * recover.sh sources the file (`herdr/bin/recover.sh`), and conductor writes
718
+ * exactly the keys that file reads to resolve a fleet. `FLEET_CWDS` is the
719
+ * multi-fleet form of the legacy single `FLEET_CWD`, so the renderer always
720
+ * writes the former; the legacy key is an operator key and survives untouched.
721
+ */
722
+ export interface HerdrConductorEnvValues {
723
+ /** The Herdr session that owns the fleet: TARGET_SESSION. */
724
+ targetSession: string;
725
+ /** Every configured project's fleet cwd, in config order (FLEET_CWDS). */
726
+ fleetCwds: readonly string[];
727
+ /** Absolute path of omp-telegram's `.env` (TELEGRAM_ENV). */
728
+ telegramEnv: string;
729
+ /** Absolute path of omp-telegram's `access.json` (ACCESS_JSON). */
730
+ accessJson: string;
731
+ }
732
+
733
+ /** The keys `setup` patches; every other line in the file is operator-owned. */
734
+ const CONDUCTOR_ENV_KEYS = ["TARGET_SESSION", "FLEET_CWDS", "TELEGRAM_ENV", "ACCESS_JSON"] as const;
735
+
736
+ const ENV_VALUE_ESCAPE = /[\\"$]/g;
737
+
738
+ /** One `KEY=value` line, with the value shell-quoted so recover.sh's `.`
739
+ * sourcing reads paths with spaces or `$` literally. */
740
+ function envLine(key: string, value: string): string {
741
+ return `${key}="${value.replace(ENV_VALUE_ESCAPE, "\\$&")}"`;
742
+ }
743
+
744
+ /**
745
+ * Render herdr-conductor's `config.env` — create it when missing; when present
746
+ * patch only conductor-owned keys and pass every other line through verbatim
747
+ * (an operator's `RECOVER_RECHECK_SECONDS`, `BOOTSTRAP_RESUME`, AGENT_NAME,
748
+ * FLEET_CWD … must survive a re-run).
749
+ *
750
+ * A conductor-owned key that already exists keeps its position and gets the new
751
+ * value; a missing one is appended at the end. The anchored `^[ \t]*KEY=` guard
752
+ * mirrors the TOML merge's comment-awareness: a line starting with `#` is a
753
+ * comment, not a key, so a commented draft is neither patched nor mistaken for
754
+ * an existing real key (which would block the append).
755
+ */
756
+ export function renderHerdrConductorEnv(values: HerdrConductorEnvValues, existing?: string): string {
757
+ const lines = (existing ?? "").split("\n");
758
+ // The split of a file ending in a newline leaves one empty trailing element
759
+ // (and a fresh file has exactly one empty element, nothing more); both are
760
+ // the join's terminator, not file content — the single join below re-adds it.
761
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
762
+ const headers: Record<(typeof CONDUCTOR_ENV_KEYS)[number], string> = {
763
+ TARGET_SESSION: values.targetSession,
764
+ FLEET_CWDS: values.fleetCwds.join(":"),
765
+ TELEGRAM_ENV: values.telegramEnv,
766
+ ACCESS_JSON: values.accessJson,
767
+ };
768
+ const seen = new Set<string>();
769
+ for (const index of lines.keys()) {
770
+ const line = lines[index]!;
771
+ const match = /^([ \t]*)([A-Za-z_][A-Za-z0-9_]*)([ \t]*=[ \t]*).*$/.exec(line);
772
+ if (match === null) continue;
773
+ const key = match[2] as (typeof CONDUCTOR_ENV_KEYS)[number];
774
+ if (!(key in headers)) continue;
775
+ seen.add(key);
776
+ lines[index] = `${match[1]}${envLine(key, headers[key])}`;
777
+ }
778
+ for (const key of CONDUCTOR_ENV_KEYS) {
779
+ if (!seen.has(key)) lines.push(envLine(key, headers[key]));
780
+ }
781
+ return `${lines.join("\n")}\n`;
782
+ }
783
+
784
+ /**
785
+ * Decide whether the fleet pane may be renamed, and if so what to run.
786
+ *
787
+ * The contract is written for the one-identity case and refuses to guess:
788
+ * - exactly one pane carries `from` (the restamped-away identity) → rename it;
789
+ * - zero, several, or an unreadable agent list → surface the exact command
790
+ * and the reason; never invent a pane.
791
+ *
792
+ * `command` is always the one-line remedy an operator can paste; on ambiguity
793
+ * it carries the generic pane placeholder because no specific pane may be
794
+ * guessed. It is the same shape the tick's decline path prints (`herdr agent
795
+ * rename <pane> <name>`), born from the session so the same command works from
796
+ * outside herdr too.
797
+ */
798
+ export type AgentRenameVerdict =
799
+ | { kind: "rename"; pane: string; command: string }
800
+ | { kind: "flag"; command: string; reason: string };
801
+
802
+ export function agentRenameVerdict(
803
+ list: HerdrAgentList,
804
+ from: string,
805
+ to: string,
806
+ session: string,
807
+ ): AgentRenameVerdict {
808
+ const commandFor = (pane: string): string => `herdr --session ${session} agent rename ${pane} ${to}`;
809
+ if (list.kind !== "ok") {
810
+ return {
811
+ kind: "flag",
812
+ command: commandFor("<pane>"),
813
+ reason: `herdr agent list is unreadable (${list.problem}) — cannot verify which pane to rename`,
814
+ };
815
+ }
816
+ const matches = list.agents.filter((a) => a.name === from);
817
+ if (matches.length === 1) {
818
+ return { kind: "rename", pane: matches[0]!.paneId, command: commandFor(matches[0]!.paneId) };
819
+ }
820
+ if (matches.length === 0) {
821
+ return {
822
+ kind: "flag",
823
+ command: commandFor("<pane>"),
824
+ reason: `no live pane is registered as agent "${from}" — nothing was renamed (an unregistered fleet pane must be named by hand)`,
825
+ };
826
+ }
827
+ return {
828
+ kind: "flag",
829
+ command: commandFor("<pane>"),
830
+ reason: `${matches.length} panes (${matches.map((a) => a.paneId).join(", ")}) are registered as "${from}" — refusing to guess which is the fleet pane`,
831
+ };
832
+ }
833
+
834
+ /** The live herdr CLI seam setup-host shells out for the one-shot rename. */
835
+ export interface AgentRenameDeps {
836
+ /** `herdr --session <s> agent list`'s one JSON line. */
837
+ list(session: string): HerdrAgentList;
838
+ /** `herdr --session <s> agent rename <pane> <name>`. */
839
+ rename(session: string, pane: string, name: string): { ok: boolean; problem?: string };
840
+ }
841
+
842
+ /** The production wiring: the same CLI `recover.sh` and the tick both use. */
843
+ export const DEFAULT_AGENT_RENAME_DEPS: AgentRenameDeps = {
844
+ list: (session) => {
845
+ const bin = Bun.which("herdr") ?? "herdr";
846
+ try {
847
+ const run = spawnSync(bin, ["--session", session, "agent", "list"], {
848
+ encoding: "utf8",
849
+ timeout: 3000,
850
+ stdio: ["ignore", "pipe", "pipe"],
851
+ });
852
+ if (run.error !== undefined) return { kind: "unavailable", problem: run.error.message };
853
+ if (run.status !== 0) return { kind: "unavailable", problem: `exit ${String(run.status)}: ${(run.stderr ?? "").trim().split("\n")[0] ?? ""}` };
854
+ return parseHerdrAgents(run.stdout ?? "");
855
+ } catch (err) {
856
+ return { kind: "unavailable", problem: err instanceof Error ? err.message : String(err) };
857
+ }
858
+ },
859
+ rename: (session, pane, name) => {
860
+ const bin = Bun.which("herdr") ?? "herdr";
861
+ try {
862
+ const run = spawnSync(bin, ["--session", session, "agent", "rename", pane, name], {
863
+ encoding: "utf8",
864
+ timeout: 3000,
865
+ stdio: ["ignore", "pipe", "pipe"],
866
+ });
867
+ if (run.error !== undefined) return { ok: false, problem: run.error.message };
868
+ if (run.status !== 0) {
869
+ return { ok: false, problem: `exit ${String(run.status)}: ${(run.stderr ?? "").trim().split("\n")[0] ?? ""}` };
870
+ }
871
+ return { ok: true };
872
+ } catch (err) {
873
+ return { ok: false, problem: err instanceof Error ? err.message : String(err) };
874
+ }
875
+ },
876
+ };
877
+
878
+ /** The directory `herdr plugin config-dir herdr-conductor` prints. */
879
+ export function herdrConductorPluginConfigDir(home: string): string {
880
+ return join(home, ".config", "herdr", "plugins", "config", "herdr-conductor");
881
+ }
882
+
883
+ /**
884
+ * The fleet cwd of every configured project, in config order — the FLEET_CWDS
885
+ * value recover.sh walks to find each fleet's `.conductor-tick.json`.
886
+ *
887
+ * Tries the real config first; when it cannot be read (a not-yet-configured
888
+ * box, install tests staging before a config exists), the named project's own
889
+ * cwd stands in — the same guarded fallback shape as `hostInstallWorkers` and
890
+ * `hostMultiProject`. Multi-project hosts get the full list when the config
891
+ * loads, which is the case the recovery path exists for.
892
+ */
893
+ export function configuredFleetCwds(project: ProjectConfig | undefined): string[] {
894
+ try {
895
+ return loadConfig().projects.map((p) => tickCwdForProject(p));
896
+ } catch {
897
+ return project === undefined ? [] : [tickCwdForProject(project)];
537
898
  }
538
- // The table exists but has no default_shell; drop the key right under it.
539
- lines.splice(table + 1, 0, line);
540
- return lines.join("\n");
541
899
  }
542
900
 
543
901
  function tickSearchRoots(project: ProjectConfig): string[] {
@@ -639,7 +997,13 @@ function planBriefLink(project: ProjectConfig): BriefLinkPlan {
639
997
  };
640
998
  }
641
999
 
642
- function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrite<TickConfig> {
1000
+ function planTick(project: ProjectConfig, telegramStateDir: string): {
1001
+ write: PlannedWrite<TickConfig>;
1002
+ /** Set when the config was restamped from the shared default: the live herdr
1003
+ * pane still carries that old identity, and the plan must rename it
1004
+ * (or flag the exact command) after the tick lands. */
1005
+ previousAgentName?: string;
1006
+ } {
643
1007
  const found = findProjectTick(project);
644
1008
  const existing = found === undefined ? undefined : { path: found.path, config: found.config };
645
1009
 
@@ -667,11 +1031,30 @@ function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrit
667
1031
  : existing.config.agentName,
668
1032
  };
669
1033
  const content = `${JSON.stringify(config, null, 2)}\n`;
670
- return { path, action: actionFor(path, content), content, value: config };
1034
+ return {
1035
+ write: { path, action: actionFor(path, content), content, value: config },
1036
+ // The rename only fires for a *re*-stamp: the old agentName was the shared
1037
+ // default (or absent, which every reader resolves to the same default), and
1038
+ // the new one is the project's own name. An already-project-stamped config
1039
+ // is not a collision and needs no rename. A brand-new config (no existing
1040
+ // tick at all) is not a restamp either: there was no previous identity for
1041
+ // a pane to be stuck with.
1042
+ ...(existing !== undefined &&
1043
+ (existing.config.agentName === undefined || existing.config.agentName === DEFAULT_FLEET_AGENT_NAME)
1044
+ ? { previousAgentName: DEFAULT_FLEET_AGENT_NAME }
1045
+ : {}),
1046
+ };
671
1047
  }
672
1048
 
673
1049
  export function planHostRuntime(
674
- project: ProjectConfig,
1050
+ /**
1051
+ * The project the per-project tail (tick config, brief link, the recovery
1052
+ * unit's RECOVER_PROJECT) is planned for. `undefined` plans the host-global
1053
+ * units only — the same staged paths and systemd destinations, one shared
1054
+ * daemon — and a `noProject` note naming the per-project files it left for
1055
+ * a named run (#530).
1056
+ */
1057
+ project: ProjectConfig | undefined,
675
1058
  _caps: Caps,
676
1059
  telegramStateDir: string,
677
1060
  runtime: ServiceRuntime = defaultServiceRuntime(telegramStateDir),
@@ -679,6 +1062,17 @@ export function planHostRuntime(
679
1062
  // Defaulting through loadConfig() would throw in install tests that stage
680
1063
  // files before a config exists, and would hide a missing total at the call site.
681
1064
  totalWorkers: number = 1,
1065
+ // The directory the privileged steps install units into. `runHostInstall`
1066
+ // injects a test-hermetic directory so the executed argv is assertable; the
1067
+ // real install always resolves to `/etc/systemd/system`.
1068
+ unitDir: string = SYSTEMD_UNIT_DIR,
1069
+ // Where the recovery playbook installs; injectable like {@link unitDir} so
1070
+ // the idempotency gate is testable without touching the real `/usr/local/sbin`.
1071
+ recoverScriptInstallPath: string = RECOVER_SCRIPT_INSTALL_PATH,
1072
+ // True when the host configures more than one project. The recovery unit is
1073
+ // host-global, so on a multi-project host it must not encode one project's
1074
+ // name — and a no-project install never does (#510/#530).
1075
+ multiProject: boolean = false,
682
1076
  ): HostRuntimePlan {
683
1077
  const servicePath = join(stateDir(), STAGED_SERVICE_NAME);
684
1078
  const serviceContent = renderDaemonService(runtime, totalWorkers);
@@ -690,8 +1084,20 @@ export function planHostRuntime(
690
1084
  };
691
1085
  const herdrUnitPath = join(stateDir(), DEFAULT_HERDR_UNIT);
692
1086
  const herdrConfigPath = join(runtime.home, ".config", "herdr", "config.toml");
1087
+ // The pane-shell config is staged like every other file; the live herdr
1088
+ // config — a file conductor does not own — is only written by the install
1089
+ // step, after consent (#513). A declined `setup host` must leave herdr's
1090
+ // config byte-for-byte untouched, and the merge must land before the unit
1091
+ // restart that accompanies it.
1092
+ const herdrConfigStagedPath = join(stateDir(), "herdr-config.toml");
1093
+ const herdrEnvStagedPath = join(stateDir(), "herdr-conductor.env");
1094
+ const herdrEnvTarget = join(herdrConductorPluginConfigDir(runtime.home), "config.env");
693
1095
  let herdrUnit: PlannedWrite<string> | undefined;
694
1096
  let herdrConfig: PlannedWrite<string> | undefined;
1097
+ let herdrConfigTarget: string | undefined;
1098
+ let herdrConfigProblem: string | undefined;
1099
+ let herdrConfigWarning: string | undefined;
1100
+ let herdrEnv: PlannedWrite<string> | undefined;
695
1101
  // The recovery playbook ships in this package's systemd/ dir; the same
696
1102
  // bytes go into the staged copy, so version control is the single source.
697
1103
  const recoverScriptPath = join(stateDir(), RECOVER_SCRIPT_FILE);
@@ -720,18 +1126,85 @@ export function planHostRuntime(
720
1126
  } catch {
721
1127
  existing = undefined;
722
1128
  }
723
- const configContent = renderHerdrConfig(runtime.shell, existing);
724
- herdrConfig = {
725
- path: herdrConfigPath,
726
- action: actionFor(herdrConfigPath, configContent),
727
- content: configContent,
728
- value: configContent,
1129
+ // An unresolvable login shell (userInfo() renders the literal "unknown")
1130
+ // plans nothing and says why: never a placeholder, and never a value that
1131
+ // is not an executable path — herdr's own `$SHELL → /bin/sh → passwd`
1132
+ // fallback applies instead.
1133
+ if (!usableHerdrShell(runtime.shell)) {
1134
+ herdrConfigProblem =
1135
+ `login shell ${JSON.stringify(runtime.shell)} is not an absolute path that exists on this host — ` +
1136
+ "no [terminal] default_shell is written, so herdr's own $SHELL → /bin/sh → passwd fallback applies";
1137
+ } else {
1138
+ const rendered = renderHerdrConfig(runtime.shell, existing);
1139
+ const configContent = rendered.content;
1140
+ if (rendered.warning !== undefined) herdrConfigWarning = rendered.warning;
1141
+ // The rendered file is parse-checked before anything may replace herdr's
1142
+ // live config. An input that is already broken (a duplicated key, say)
1143
+ // renders broken — the replace touches every real key, so two keys stay
1144
+ // two keys — and writing that file is exactly the stopped-fleet outcome
1145
+ // this must prevent. A parse failure aborts the whole plan here, before
1146
+ // a single file has been staged.
1147
+ if (actionFor(herdrConfigPath, configContent) !== "keep") {
1148
+ try {
1149
+ Bun.TOML.parse(configContent);
1150
+ } catch (err) {
1151
+ throw new Error(
1152
+ `refusing to write herdr's config at ${herdrConfigPath}: it would not parse ` +
1153
+ `(${err instanceof Error ? err.message : String(err)}). Nothing has been staged — ` +
1154
+ "fix the file by hand (herdr's `config check` names the line), then re-run setup host.",
1155
+ );
1156
+ }
1157
+ }
1158
+ herdrConfig = {
1159
+ path: herdrConfigStagedPath,
1160
+ action: actionFor(herdrConfigStagedPath, configContent),
1161
+ content: configContent,
1162
+ value: configContent,
1163
+ };
1164
+ herdrConfigTarget = herdrConfigPath;
1165
+ }
1166
+ // The herdr-conductor plugin's config.env (#541): recover.sh sources
1167
+ // `$HERDR_PLUGIN_CONFIG_DIR/config.env` and shrinks to legacy /root paths
1168
+ // when it is missing — on a host whose fleet lives elsewhere the recovery
1169
+ // still runs, only the page silently goes nowhere. Setup owns
1170
+ // TARGET_SESSION/FLEET_CWDS/TELEGRAM_ENV/ACCESS_JSON; the renderer patches
1171
+ // only those keys, so an operator's BOOTSTRAP_RESUME or
1172
+ // RECOVER_RECHECK_SECONDS survive a re-run.
1173
+ let existingEnv: string | undefined;
1174
+ try {
1175
+ existingEnv = readFileSync(herdrEnvTarget, "utf8");
1176
+ } catch {
1177
+ existingEnv = undefined;
1178
+ }
1179
+ const envContent = renderHerdrConductorEnv(
1180
+ {
1181
+ targetSession: runtime.herdrSession ?? DEFAULT_HERDR_SESSION,
1182
+ // Derived from every configured project's tick cwd, not from the
1183
+ // host-global unit the install runs for: FLEET_CWDS is what recover.sh
1184
+ // walks to find each fleet's `.conductor-tick.json`.
1185
+ fleetCwds: configuredFleetCwds(project),
1186
+ telegramEnv: join(telegramStateDir, ".env"),
1187
+ accessJson: join(telegramStateDir, "access.json"),
1188
+ },
1189
+ existingEnv,
1190
+ );
1191
+ herdrEnv = {
1192
+ path: herdrEnvStagedPath,
1193
+ action: actionFor(herdrEnvStagedPath, envContent),
1194
+ content: envContent,
1195
+ value: envContent,
729
1196
  };
730
1197
  }
731
- const installedPath = join(SYSTEMD_UNIT_DIR, STAGED_SERVICE_NAME);
732
- const installedHerdr = join(SYSTEMD_UNIT_DIR, DEFAULT_HERDR_UNIT);
1198
+ const installedPath = join(unitDir, STAGED_SERVICE_NAME);
1199
+ const installedHerdr = join(unitDir, DEFAULT_HERDR_UNIT);
733
1200
  const recoverUnitPath = join(stateDir(), RECOVER_SERVICE_NAME);
734
- const recoverUnitContent = renderRecoverUnit(runtime, project.name);
1201
+ // The recovery unit is host-global: one shared unit, installed once. A
1202
+ // static RECOVER_PROJECT is only legitimate when there is exactly one
1203
+ // project to be unambiguous about — a multi-project host (or a no-project
1204
+ // install) leaves it unset so the escalation reports without attributing a
1205
+ // sibling's crash to one project (#510/#530).
1206
+ const recoverProject = project === undefined || multiProject ? undefined : project.name;
1207
+ const recoverUnitContent = renderRecoverUnit(runtime, recoverProject);
735
1208
  const recoverUnit: PlannedWrite<string> = {
736
1209
  path: recoverUnitPath,
737
1210
  action: actionFor(recoverUnitPath, recoverUnitContent),
@@ -744,39 +1217,139 @@ export function planHostRuntime(
744
1217
  content: recoverScriptContent,
745
1218
  value: recoverScriptContent,
746
1219
  };
1220
+ // Recovery first: the daemon unit that follows names it in OnFailure=, so
1221
+ // the restart below must never point at a unit systemd cannot load. This is
1222
+ // the single list `runHostInstall` executes and {@link installCommands}
1223
+ // renders from, so no step can be in one and missing from the other (#509).
1224
+ const installSteps: PrivilegedStep[] = [
1225
+ {
1226
+ title: "install the recovery playbook",
1227
+ argv: ["install", "-m", "0755", recoverScriptPath, recoverScriptInstallPath],
1228
+ },
1229
+ {
1230
+ title: `install ${RECOVER_SERVICE_NAME}`,
1231
+ argv: ["install", "-m", "0644", recoverUnitPath, join(unitDir, RECOVER_SERVICE_NAME)],
1232
+ },
1233
+ { title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
1234
+ {
1235
+ title: `install ${STAGED_SERVICE_NAME}`,
1236
+ argv: ["install", "-m", "0644", servicePath, installedPath],
1237
+ },
1238
+ { title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
1239
+ { title: `enable ${STAGED_SERVICE_NAME}`, argv: ["systemctl", "enable", STAGED_SERVICE_NAME] },
1240
+ { title: `restart ${STAGED_SERVICE_NAME}`, argv: ["systemctl", "restart", STAGED_SERVICE_NAME] },
1241
+ // The herdr session server, provisioned alongside the daemon (#456). Only
1242
+ // when herdr is installed and the plan therefore staged a unit.
1243
+ ...(herdrUnit === undefined
1244
+ ? []
1245
+ : [
1246
+ {
1247
+ title: `install ${DEFAULT_HERDR_UNIT}`,
1248
+ argv: ["install", "-m", "0644", herdrUnitPath, installedHerdr],
1249
+ },
1250
+ { title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
1251
+ { title: `enable ${DEFAULT_HERDR_UNIT}`, argv: ["systemctl", "enable", DEFAULT_HERDR_UNIT] },
1252
+ // The pane-shell merge lands *before* the restart it accompanies, so
1253
+ // the session server comes up with the pinned shell, not the stale
1254
+ // config. It runs without sudo on purpose: the file is the fleet
1255
+ // account's own (the escalation guard guarantees setup runs as that
1256
+ // account), and a root-owned copy would break herdr's next rewrite.
1257
+ // Nothing writes herdr's live config until this consent-gated step.
1258
+ ...(herdrConfig === undefined || actionFor(herdrConfigPath, herdrConfig.content) === "keep"
1259
+ ? []
1260
+ : [
1261
+ {
1262
+ title: `write the pane shell into ${herdrConfigPath}`,
1263
+ argv: ["install", "-m", "0644", herdrConfigStagedPath, herdrConfigPath],
1264
+ unprivileged: true,
1265
+ },
1266
+ ]),
1267
+ // The herdr-conductor config.env (#541) rides the same consent-gated,
1268
+ // unprivileged, pre-restart lane: recover.sh sources it on every
1269
+ // startup, so the session server must come up already pointing at
1270
+ // this host's fleet cwds and telegram files.
1271
+ ...(herdrEnv === undefined || actionFor(herdrEnvTarget, herdrEnv.content) === "keep"
1272
+ ? []
1273
+ : [
1274
+ {
1275
+ title: `write the herdr-conductor config.env into ${herdrEnvTarget}`,
1276
+ argv: ["install", "-m", "0644", herdrEnvStagedPath, herdrEnvTarget],
1277
+ unprivileged: true,
1278
+ },
1279
+ ]),
1280
+ { title: `restart ${DEFAULT_HERDR_UNIT}`, argv: ["systemctl", "restart", DEFAULT_HERDR_UNIT] },
1281
+ ]),
1282
+ ];
1283
+ // Everything the privileged steps install is already at its destination with
1284
+ // the current bytes, so a re-run of `setup host` has nothing to install and
1285
+ // nothing to restart. The herdr unit is absent on a host without herdr, and
1286
+ // an absent unit that would not be provisioned is nothing to do.
1287
+ const installedAction = actionFor(installedPath, serviceContent);
1288
+ const currentInstall =
1289
+ installedAction === "keep" &&
1290
+ actionFor(join(unitDir, RECOVER_SERVICE_NAME), recoverUnitContent) === "keep" &&
1291
+ actionFor(recoverScriptInstallPath, recoverScriptContent) === "keep" &&
1292
+ (herdrUnit === undefined || actionFor(installedHerdr, herdrUnit.content) === "keep") &&
1293
+ // The pane-shell file is a destination like the units: a re-run with all
1294
+ // units current but the config merge still pending must not report
1295
+ // "nothing to install" and skip the very write the plan exists to make.
1296
+ (herdrConfig === undefined || actionFor(herdrConfigPath, herdrConfig.content) === "keep") &&
1297
+ // Same for the herdr-conductor config.env: pending env merge is pending
1298
+ // work, not an already-current install.
1299
+ (herdrEnv === undefined || actionFor(herdrEnvTarget, herdrEnv.content) === "keep");
747
1300
  return {
748
1301
  service,
749
1302
  // The herdr unit and pane-shell config stand and fall together: no herdr, no
750
- // session to supervise, nothing for a pane shell to belong to.
751
- ...(herdrUnit === undefined ? {} : { herdrUnit, herdrConfig }),
1303
+ // session to supervise, nothing for a pane shell to belong to. (The pane
1304
+ // shell is omitted too when the login shell is unusable or the rendered
1305
+ // config would not parse — those plans carry {@link herdrConfigProblem}.)
1306
+ ...(herdrUnit === undefined ? {} : { herdrUnit }),
1307
+ ...(herdrConfig === undefined ? {} : { herdrConfig, herdrConfigTarget }),
1308
+ ...(herdrConfigProblem === undefined ? {} : { herdrConfigProblem }),
1309
+ ...(herdrConfigWarning === undefined ? {} : { herdrConfigWarning }),
1310
+ ...(herdrEnv === undefined ? {} : { herdrEnv, herdrEnvTarget }),
752
1311
  recoverUnit,
753
1312
  recoverScript,
754
- briefLink: planBriefLink(project),
755
- ...(project.escalation.orchestrator === "external"
756
- ? { tick: planTick(project, telegramStateDir) }
757
- : {}),
758
- installCommands: [
759
- // Recovery first: the daemon unit that follows names it in OnFailure=,
760
- // so the restart below must never point at a unit systemd cannot load.
761
- `sudo install -m 0755 ${shellQuote(recoverScriptPath)} ${shellQuote(RECOVER_SCRIPT_INSTALL_PATH)}`,
762
- `sudo install -m 0644 ${shellQuote(recoverUnitPath)} ${shellQuote(join(SYSTEMD_UNIT_DIR, RECOVER_SERVICE_NAME))}`,
763
- "sudo systemctl daemon-reload",
764
- `sudo install -m 0644 ${shellQuote(servicePath)} ${shellQuote(installedPath)}`,
765
- "sudo systemctl daemon-reload",
766
- `sudo systemctl enable ${STAGED_SERVICE_NAME}`,
767
- `sudo systemctl restart ${STAGED_SERVICE_NAME}`,
768
- ...(herdrUnit === undefined
769
- ? []
770
- : [
771
- `sudo install -m 0644 ${shellQuote(herdrUnitPath)} ${shellQuote(installedHerdr)}`,
772
- "sudo systemctl daemon-reload",
773
- `sudo systemctl enable ${DEFAULT_HERDR_UNIT}`,
774
- `sudo systemctl restart ${DEFAULT_HERDR_UNIT}`,
775
- ]),
776
- ],
1313
+ ...(project === undefined
1314
+ ? {
1315
+ // Host-global install: the per-project tail entities are not written,
1316
+ // and the plan says exactly which files and how to write them.
1317
+ noProject: {
1318
+ skipped: [TICK_CONFIG_FILE, AGENTS_BRIEF_NAME],
1319
+ how: "re-run `omp-conductor setup host <NAME>` (or --project NAME) to write them for one project",
1320
+ },
1321
+ }
1322
+ : {
1323
+ briefLink: planBriefLink(project),
1324
+ ...(project.escalation.orchestrator === "external"
1325
+ ? (() => {
1326
+ const planned = planTick(project, telegramStateDir);
1327
+ return {
1328
+ ...(planned.previousAgentName === undefined || runtime.herdr === undefined
1329
+ ? {}
1330
+ : {
1331
+ // The restamp that made the tick's agentName no longer
1332
+ // the shared default: the live pane (if any) still
1333
+ // carries the old name, and setup will rename it after
1334
+ // the install — or print the exact command.
1335
+ agentRename: {
1336
+ session: runtime.herdrSession ?? DEFAULT_HERDR_SESSION,
1337
+ from: planned.previousAgentName,
1338
+ to: project.name,
1339
+ },
1340
+ }),
1341
+ tick: planned.write,
1342
+ };
1343
+ })()
1344
+ : {}),
1345
+ }),
1346
+ steps: installSteps,
1347
+ // For humans to read; the executed form is {@link steps}, in argv.
1348
+ installCommands: installSteps.map((s) => formatStep(s)),
777
1349
  cliSource: runtime.cli === undefined ? "plugin" : "global",
778
1350
  installedPath,
779
- installedAction: actionFor(installedPath, serviceContent),
1351
+ installedAction,
1352
+ currentInstall,
780
1353
  };
781
1354
  }
782
1355
 
@@ -791,7 +1364,16 @@ export function formatHostRuntimePlan(plan: HostRuntimePlan): string {
791
1364
  ? [" herdr session skipped — herdr not installed on this host"]
792
1365
  : [
793
1366
  ` herdr session ${plan.herdrUnit.action} ${plan.herdrUnit.path}`,
794
- ` pane shell ${plan.herdrConfig!.action} ${plan.herdrConfig!.path} ([terminal] default_shell)`,
1367
+ ...(plan.herdrConfig === undefined
1368
+ ? [` pane shell skipped — ${plan.herdrConfigProblem ?? "login shell unusable"}`]
1369
+ : [` pane shell ${plan.herdrConfig.action} ${plan.herdrConfig.path} ([terminal] default_shell)`]),
1370
+ ...(plan.herdrConfigWarning === undefined ? [] : [` pane resume warn — ${plan.herdrConfigWarning}`]),
1371
+ ...(plan.herdrEnv === undefined
1372
+ ? []
1373
+ : [` herdr env ${plan.herdrEnv.action} ${plan.herdrEnv.path} -> ${plan.herdrEnvTarget}`]),
1374
+ ...(plan.agentRename === undefined
1375
+ ? []
1376
+ : [` agent rename ${plan.agentRename.from} -> ${plan.agentRename.to} in session ${plan.agentRename.session}`]),
795
1377
  ]),
796
1378
  ];
797
1379
  if (plan.tick !== undefined) {
@@ -804,11 +1386,19 @@ export function formatHostRuntimePlan(plan: HostRuntimePlan): string {
804
1386
  } else {
805
1387
  lines.push(" heartbeat embedded orchestrator — no external tick config");
806
1388
  }
807
- lines.push(
808
- plan.briefLink.action === "skip"
809
- ? ` brief link ${plan.briefLink.path} ${plan.briefLink.skippedReason}`
810
- : ` brief link ${plan.briefLink.action} ${plan.briefLink.path} -> ${plan.briefLink.target}`,
811
- );
1389
+ if (plan.noProject !== undefined) {
1390
+ lines.push(
1391
+ ` per-project skipped (no project named): ${plan.noProject.skipped.join(", ")}`,
1392
+ ` ${plan.noProject.how}`,
1393
+ );
1394
+ }
1395
+ if (plan.briefLink !== undefined) {
1396
+ lines.push(
1397
+ plan.briefLink.action === "skip"
1398
+ ? ` brief link ${plan.briefLink.path} — ${plan.briefLink.skippedReason}`
1399
+ : ` brief link ${plan.briefLink.action} ${plan.briefLink.path} -> ${plan.briefLink.target}`,
1400
+ );
1401
+ }
812
1402
  lines.push(" install staged only; the final result prints the systemd install commands");
813
1403
  return lines.join("\n");
814
1404
  }
@@ -850,6 +1440,10 @@ export function writeHostRuntime(plan: HostRuntimePlan): HostRuntimeWrite {
850
1440
  atomicWrite(plan.herdrConfig.path, plan.herdrConfig.content, 0o644);
851
1441
  wrote.push(plan.herdrConfig.path);
852
1442
  }
1443
+ if (plan.herdrEnv !== undefined && plan.herdrEnv.action !== "keep") {
1444
+ atomicWrite(plan.herdrEnv.path, plan.herdrEnv.content, 0o644);
1445
+ wrote.push(plan.herdrEnv.path);
1446
+ }
853
1447
  if (plan.recoverUnit.action !== "keep") {
854
1448
  atomicWrite(plan.recoverUnit.path, plan.recoverUnit.content, 0o644);
855
1449
  wrote.push(plan.recoverUnit.path);
@@ -868,7 +1462,8 @@ export function writeHostRuntime(plan: HostRuntimePlan): HostRuntimeWrite {
868
1462
  // (an operator may drop a regular file where a stale symlink was planned for
869
1463
  // replacement). Re-lstat at write time and act on *current* state, so a file
870
1464
  // that appears mid-flight is preserved, never unlinked by a stale plan.
871
- if (plan.briefLink.action === "create" || plan.briefLink.action === "update") {
1465
+ // A host-global plan (no project) has no briefLink to write at all.
1466
+ if (plan.briefLink !== undefined && (plan.briefLink.action === "create" || plan.briefLink.action === "update")) {
872
1467
  const { path, target } = plan.briefLink;
873
1468
  try {
874
1469
  mkdirSync(dirname(path), { recursive: true });