omp-conductor 0.15.12 → 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.
package/src/setup-host.ts CHANGED
@@ -23,8 +23,10 @@ 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";
30
32
  import { formatStep, type PrivilegedStep } from "./privileged.ts";
@@ -142,6 +144,32 @@ export interface HostRuntimePlan {
142
144
  herdrConfigTarget?: string;
143
145
  /** Why no pane-shell key is planned (unusable login shell, …). */
144
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 };
145
173
  tick?: PlannedWrite<TickConfig>;
146
174
  /** The `AGENTS.md` — composed-brief symlink — placed in the fleet cwd. */
147
175
  briefLink?: BriefLinkPlan;
@@ -599,9 +627,25 @@ export function usableHerdrShell(shell: string): boolean {
599
627
  * writes **no** key at all — the file passes through byte-identical, because
600
628
  * "pin the shell" must never mean "write the word `unknown`".
601
629
  */
602
- export function renderHerdrConfig(shell: string, existing?: string): string {
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 {
603
643
  const text = existing ?? "";
604
- if (!usableHerdrShell(shell)) return text;
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 };
605
649
 
606
650
  const quoted = `"${shell.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
607
651
  const line = `default_shell = ${quoted}`;
@@ -612,23 +656,246 @@ export function renderHerdrConfig(shell: string, existing?: string): string {
612
656
  // old guard was unanchored, so it matched a commented line the anchored
613
657
  // replace did not — a config whose only mention was a comment took the
614
658
  // "replace" branch, replaced nothing, and silently wrote nothing at all.
615
- // Any existing real key keeps its position and gets the new value in place —
659
+ // Any existing key keeps its position and gets the new value in place —
616
660
  // even when the value already matches, so a re-run stays idempotent instead
617
661
  // of duplicating the key.
618
662
  const keyLine = /^([ \t]*default_shell[ \t]*=\s*).*$/m;
663
+ let content: string;
619
664
  if (keyLine.test(text)) {
620
- return text.replace(keyLine, `$1${quoted}`);
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
+ }
621
677
  }
622
678
 
623
- const lines = text.split("\n");
624
- const table = lines.findIndex((l) => /^\[terminal\]\s*$/.test(l));
625
- if (table === -1) {
626
- const body = text.replace(/\s+$/, "");
627
- 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)];
628
898
  }
629
- // The table exists but has no default_shell; drop the key right under it.
630
- lines.splice(table + 1, 0, line);
631
- return lines.join("\n");
632
899
  }
633
900
 
634
901
  function tickSearchRoots(project: ProjectConfig): string[] {
@@ -730,7 +997,13 @@ function planBriefLink(project: ProjectConfig): BriefLinkPlan {
730
997
  };
731
998
  }
732
999
 
733
- 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
+ } {
734
1007
  const found = findProjectTick(project);
735
1008
  const existing = found === undefined ? undefined : { path: found.path, config: found.config };
736
1009
 
@@ -758,7 +1031,19 @@ function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrit
758
1031
  : existing.config.agentName,
759
1032
  };
760
1033
  const content = `${JSON.stringify(config, null, 2)}\n`;
761
- 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
+ };
762
1047
  }
763
1048
 
764
1049
  export function planHostRuntime(
@@ -805,10 +1090,14 @@ export function planHostRuntime(
805
1090
  // config byte-for-byte untouched, and the merge must land before the unit
806
1091
  // restart that accompanies it.
807
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");
808
1095
  let herdrUnit: PlannedWrite<string> | undefined;
809
1096
  let herdrConfig: PlannedWrite<string> | undefined;
810
1097
  let herdrConfigTarget: string | undefined;
811
1098
  let herdrConfigProblem: string | undefined;
1099
+ let herdrConfigWarning: string | undefined;
1100
+ let herdrEnv: PlannedWrite<string> | undefined;
812
1101
  // The recovery playbook ships in this package's systemd/ dir; the same
813
1102
  // bytes go into the staged copy, so version control is the single source.
814
1103
  const recoverScriptPath = join(stateDir(), RECOVER_SCRIPT_FILE);
@@ -846,7 +1135,9 @@ export function planHostRuntime(
846
1135
  `login shell ${JSON.stringify(runtime.shell)} is not an absolute path that exists on this host — ` +
847
1136
  "no [terminal] default_shell is written, so herdr's own $SHELL → /bin/sh → passwd fallback applies";
848
1137
  } else {
849
- const configContent = renderHerdrConfig(runtime.shell, existing);
1138
+ const rendered = renderHerdrConfig(runtime.shell, existing);
1139
+ const configContent = rendered.content;
1140
+ if (rendered.warning !== undefined) herdrConfigWarning = rendered.warning;
850
1141
  // The rendered file is parse-checked before anything may replace herdr's
851
1142
  // live config. An input that is already broken (a duplicated key, say)
852
1143
  // renders broken — the replace touches every real key, so two keys stay
@@ -872,6 +1163,37 @@ export function planHostRuntime(
872
1163
  };
873
1164
  herdrConfigTarget = herdrConfigPath;
874
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,
1196
+ };
875
1197
  }
876
1198
  const installedPath = join(unitDir, STAGED_SERVICE_NAME);
877
1199
  const installedHerdr = join(unitDir, DEFAULT_HERDR_UNIT);
@@ -942,6 +1264,19 @@ export function planHostRuntime(
942
1264
  unprivileged: true,
943
1265
  },
944
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
+ ]),
945
1280
  { title: `restart ${DEFAULT_HERDR_UNIT}`, argv: ["systemctl", "restart", DEFAULT_HERDR_UNIT] },
946
1281
  ]),
947
1282
  ];
@@ -958,7 +1293,10 @@ export function planHostRuntime(
958
1293
  // The pane-shell file is a destination like the units: a re-run with all
959
1294
  // units current but the config merge still pending must not report
960
1295
  // "nothing to install" and skip the very write the plan exists to make.
961
- (herdrConfig === undefined || actionFor(herdrConfigPath, herdrConfig.content) === "keep");
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");
962
1300
  return {
963
1301
  service,
964
1302
  // The herdr unit and pane-shell config stand and fall together: no herdr, no
@@ -968,6 +1306,8 @@ export function planHostRuntime(
968
1306
  ...(herdrUnit === undefined ? {} : { herdrUnit }),
969
1307
  ...(herdrConfig === undefined ? {} : { herdrConfig, herdrConfigTarget }),
970
1308
  ...(herdrConfigProblem === undefined ? {} : { herdrConfigProblem }),
1309
+ ...(herdrConfigWarning === undefined ? {} : { herdrConfigWarning }),
1310
+ ...(herdrEnv === undefined ? {} : { herdrEnv, herdrEnvTarget }),
971
1311
  recoverUnit,
972
1312
  recoverScript,
973
1313
  ...(project === undefined
@@ -982,7 +1322,25 @@ export function planHostRuntime(
982
1322
  : {
983
1323
  briefLink: planBriefLink(project),
984
1324
  ...(project.escalation.orchestrator === "external"
985
- ? { tick: planTick(project, telegramStateDir) }
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
+ })()
986
1344
  : {}),
987
1345
  }),
988
1346
  steps: installSteps,
@@ -1009,6 +1367,13 @@ export function formatHostRuntimePlan(plan: HostRuntimePlan): string {
1009
1367
  ...(plan.herdrConfig === undefined
1010
1368
  ? [` pane shell skipped — ${plan.herdrConfigProblem ?? "login shell unusable"}`]
1011
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}`]),
1012
1377
  ]),
1013
1378
  ];
1014
1379
  if (plan.tick !== undefined) {
@@ -1075,6 +1440,10 @@ export function writeHostRuntime(plan: HostRuntimePlan): HostRuntimeWrite {
1075
1440
  atomicWrite(plan.herdrConfig.path, plan.herdrConfig.content, 0o644);
1076
1441
  wrote.push(plan.herdrConfig.path);
1077
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
+ }
1078
1447
  if (plan.recoverUnit.action !== "keep") {
1079
1448
  atomicWrite(plan.recoverUnit.path, plan.recoverUnit.content, 0o644);
1080
1449
  wrote.push(plan.recoverUnit.path);
@@ -19,7 +19,7 @@ import { existsSync, readFileSync } from "node:fs";
19
19
  import { platform } from "node:os";
20
20
  import { join } from "node:path";
21
21
  import { loadConfig, stateDir } from "./config.ts";
22
- import { pauseInstance, setPaused, statusSnapshot } from "./daemon.ts";
22
+ import { pauseInstance, pauseSourceToken, setPaused, statusSnapshot } from "./daemon.ts";
23
23
  import { fleetLayers, DEFAULT_HERDR_UNIT } from "./fleet.ts";
24
24
  import { livingDaemon } from "./lifecycle.ts";
25
25
  import { dbPath, openStore } from "./store.ts";
@@ -44,13 +44,16 @@ import {
44
44
  import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
45
45
  import { runPrivileged, type PrivilegedDeps, type PrivilegedStep } from "./privileged.ts";
46
46
  import {
47
+ agentRenameVerdict,
47
48
  checkEscalation,
49
+ DEFAULT_AGENT_RENAME_DEPS,
48
50
  planHostRuntime,
49
51
  totalConfiguredWorkers,
50
52
  writeHostRuntime,
51
53
  STAGED_SERVICE_NAME,
52
54
  SYSTEMD_UNIT_DIR,
53
55
  RECOVER_SCRIPT_INSTALL_PATH,
56
+ type AgentRenameDeps,
54
57
  type EscalationDeps,
55
58
  type ServiceRuntime,
56
59
  } from "./setup-host.ts";
@@ -108,6 +111,12 @@ export interface InstallDeps {
108
111
  * attributed before the session host restarts under them. */
109
112
  drainStore?: Pick<Store, "liveRuns" | "updateRun">;
110
113
  };
114
+ /**
115
+ * The live herdr CLI seam for the post-install agent rename (#541).
116
+ * Injectable so tests pin the verdict without touching the host's herdr; the
117
+ * default is the same `herdr --session` CLI recover.sh and the tick use.
118
+ */
119
+ agentRename?: AgentRenameDeps;
111
120
  }
112
121
 
113
122
  /**
@@ -185,6 +194,7 @@ export async function runHostInstall(
185
194
  // No pane-shell key is planned (unusable login shell, unparseable config):
186
195
  // the operator hears why before the consent prompt, not after an install
187
196
  // that quietly had nothing to say about the shell.
197
+ if (plan.herdrConfigWarning !== undefined) ui.notify(plan.herdrConfigWarning, "warning");
188
198
  if (plan.herdrConfigProblem !== undefined) ui.notify(plan.herdrConfigProblem, "warning");
189
199
  // A host-global install (no --project): the per-project tail was
190
200
  // deliberately not written, and the operator must be told which files and
@@ -299,6 +309,34 @@ export async function runHostInstall(
299
309
  }
300
310
  if (fence !== undefined && !fence.initialPaused) drainDeps.setPaused(false, fence.scope.pauseKey);
301
311
  ui.notify(`Installed and started ${STAGED_SERVICE_NAME}.`, "info");
312
+ // #541: the tick plan may have restamped agentName away from the shared
313
+ // default — the exact moment the live pane is stuck under the old name and
314
+ // every tick declines with the rename hint. Perform the rename now that the
315
+ // herdr session has (re)started, safely: exactly one pane carrying the old
316
+ // identity is renamed; zero, several, or an unreadable agent list prints the
317
+ // exact command and says why it was not run. Never guesses a pane.
318
+ if (plan.agentRename !== undefined) {
319
+ const renameDeps = deps.agentRename ?? DEFAULT_AGENT_RENAME_DEPS;
320
+ const verdict = agentRenameVerdict(
321
+ renameDeps.list(plan.agentRename.session),
322
+ plan.agentRename.from,
323
+ plan.agentRename.to,
324
+ plan.agentRename.session,
325
+ );
326
+ if (verdict.kind === "rename") {
327
+ const ran = renameDeps.rename(plan.agentRename.session, verdict.pane, plan.agentRename.to);
328
+ if (ran.ok) {
329
+ ui.notify(`Renamed the herdr agent in pane ${verdict.pane} to ${plan.agentRename.to}.`, "info");
330
+ } else {
331
+ ui.notify(
332
+ `The herdr agent rename did not go through (${ran.problem ?? "unknown"}): ${verdict.command} — the pane still carries the old name.`,
333
+ "warning",
334
+ );
335
+ }
336
+ } else {
337
+ ui.notify(`${verdict.reason}; run it by hand: ${verdict.command}`, "warning");
338
+ }
339
+ }
302
340
  return { kind: "installed", wrote };
303
341
  }
304
342
 
@@ -321,7 +359,7 @@ function hostInstallDrainDeps(ui: WizardUi, deps: InstallDeps): DrainDeps {
321
359
  : { running: true, project: daemon.project, generation: `${daemon.pid}@${daemon.startedAt}` };
322
360
  },
323
361
  pauseState: (project) => pauseInstance(project),
324
- setPaused: (v, project) => setPaused(v, { source: "setup host", reason: "setup host, draining" }, project),
362
+ setPaused: (v, project) => setPaused(v, { source: pauseSourceToken("setup host"), reason: "setup host, draining" }, project),
325
363
  sleep: Bun.sleep,
326
364
  log: (message) => ui.notify(message, "info"),
327
365
  };