agent-relay-orchestrator 0.10.26 → 0.11.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/package.json +1 -1
- package/src/self-supervision.ts +31 -2
- package/src/self-upgrade.ts +20 -9
- package/src/spawn.ts +41 -11
package/package.json
CHANGED
package/src/self-supervision.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
|
|
4
4
|
export interface SelfSupervision {
|
|
5
|
-
supervisor: "process" | "systemd" | "unknown";
|
|
5
|
+
supervisor: "process" | "systemd" | "launchd" | "unknown";
|
|
6
6
|
selfUnit?: string;
|
|
7
7
|
runtimePrefix?: string;
|
|
8
8
|
}
|
|
@@ -11,7 +11,7 @@ let cached: SelfSupervision | undefined;
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Detect how this orchestrator process is supervised so the relay can target a
|
|
14
|
-
* remote self-upgrade at the correct
|
|
14
|
+
* remote self-upgrade at the correct unit/label and install prefix. Result is
|
|
15
15
|
* stable for the process lifetime, so it is computed once and cached.
|
|
16
16
|
*/
|
|
17
17
|
export function detectSelfSupervision(moduleUrl: string = import.meta.url): SelfSupervision {
|
|
@@ -21,6 +21,12 @@ export function detectSelfSupervision(moduleUrl: string = import.meta.url): Self
|
|
|
21
21
|
if (unit) {
|
|
22
22
|
cached.supervisor = "systemd";
|
|
23
23
|
cached.selfUnit = unit;
|
|
24
|
+
} else if (process.platform === "darwin") {
|
|
25
|
+
const label = detectLaunchdLabel();
|
|
26
|
+
if (label) {
|
|
27
|
+
cached.supervisor = "launchd";
|
|
28
|
+
cached.selfUnit = label;
|
|
29
|
+
}
|
|
24
30
|
}
|
|
25
31
|
return cached;
|
|
26
32
|
}
|
|
@@ -64,6 +70,29 @@ function detectSystemdUnit(): string | undefined {
|
|
|
64
70
|
}
|
|
65
71
|
}
|
|
66
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Parse `launchctl list` output to find the label for a given PID. Output format
|
|
75
|
+
* is tab-separated: PID\tStatus\tLabel (PID is "-" when not running).
|
|
76
|
+
*/
|
|
77
|
+
export function parseLaunchdLabelForPid(output: string, pid: number): string | undefined {
|
|
78
|
+
const target = String(pid);
|
|
79
|
+
for (const line of output.split("\n")) {
|
|
80
|
+
const parts = line.split("\t");
|
|
81
|
+
if (parts[0] === target && parts[2]) return parts[2];
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function detectLaunchdLabel(): string | undefined {
|
|
87
|
+
try {
|
|
88
|
+
const result = Bun.spawnSync({ cmd: ["launchctl", "list"], stdout: "pipe", stderr: "ignore" });
|
|
89
|
+
if (result.exitCode !== 0) return undefined;
|
|
90
|
+
return parseLaunchdLabelForPid(new TextDecoder().decode(result.stdout), process.pid);
|
|
91
|
+
} catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
67
96
|
/**
|
|
68
97
|
* The install prefix is the directory above `node_modules` when the orchestrator
|
|
69
98
|
* runs from an installed package (e.g. ~/.agent-relay/runtime). Undefined when
|
package/src/self-upgrade.ts
CHANGED
|
@@ -59,8 +59,8 @@ export function planSelfUpgrade(
|
|
|
59
59
|
}
|
|
60
60
|
const providers = normalizeProviders(params.providers);
|
|
61
61
|
|
|
62
|
-
if (supervision.supervisor !== "systemd" || !supervision.selfUnit) {
|
|
63
|
-
throw new Error("orchestrator is not under systemd
|
|
62
|
+
if ((supervision.supervisor !== "systemd" && supervision.supervisor !== "launchd") || !supervision.selfUnit) {
|
|
63
|
+
throw new Error("orchestrator is not under systemd or launchd; remote self-upgrade requires a managed service");
|
|
64
64
|
}
|
|
65
65
|
const unit = supervision.selfUnit;
|
|
66
66
|
const binary = resolveBinary(supervision.runtimePrefix);
|
|
@@ -74,13 +74,24 @@ export function planSelfUpgrade(
|
|
|
74
74
|
];
|
|
75
75
|
if (supervision.runtimePrefix) installCmd.push("--runtime-prefix", supervision.runtimePrefix);
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
77
|
+
let restartDetached: boolean;
|
|
78
|
+
let restartCmd: string[];
|
|
79
|
+
|
|
80
|
+
if (supervision.supervisor === "launchd") {
|
|
81
|
+
const uid = process.getuid?.() ?? 501;
|
|
82
|
+
// Shell-background the kickstart so it survives our process teardown when
|
|
83
|
+
// launchd kills us. The shell exits immediately; launchd handles the restart.
|
|
84
|
+
restartCmd = ["/bin/sh", "-c", `launchctl kickstart -kp gui/${uid}/${unit} &`];
|
|
85
|
+
restartDetached = true;
|
|
86
|
+
} else {
|
|
87
|
+
// Decouple the restart from this orchestrator's own cgroup: restarting our unit
|
|
88
|
+
// SIGTERMs us, and a child in our cgroup would be killed mid-restart. systemd-run
|
|
89
|
+
// schedules it as an independent transient unit that survives our teardown.
|
|
90
|
+
restartDetached = runner.commandExists("systemd-run");
|
|
91
|
+
restartCmd = restartDetached
|
|
92
|
+
? ["systemd-run", "--user", "--collect", "--description", "agent-relay orchestrator self-upgrade restart", "systemctl", "--user", "restart", unit]
|
|
93
|
+
: ["setsid", "systemctl", "--user", "restart", unit];
|
|
94
|
+
}
|
|
84
95
|
|
|
85
96
|
return { targetVersion, providers, unit, runtimePrefix: supervision.runtimePrefix, binary, installCmd, restartCmd, restartDetached };
|
|
86
97
|
}
|
package/src/spawn.ts
CHANGED
|
@@ -962,20 +962,14 @@ export function captureTerminal(name: string, config: OrchestratorConfig): Termi
|
|
|
962
962
|
}
|
|
963
963
|
|
|
964
964
|
const size = tmuxPaneSize(name, socketName);
|
|
965
|
-
const cursor =
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
stderr: "pipe",
|
|
970
|
-
});
|
|
971
|
-
if (result.exitCode !== 0) {
|
|
972
|
-
const stderr = result.stderr.toString().trim();
|
|
973
|
-
throw new Error(stderr || `tmux capture-pane failed with exit code ${result.exitCode}`);
|
|
974
|
-
}
|
|
965
|
+
const { content, cursor } = captureConsistent(
|
|
966
|
+
() => captureContent(name, socketName),
|
|
967
|
+
() => tmuxCursorPos(name, socketName),
|
|
968
|
+
);
|
|
975
969
|
|
|
976
970
|
return {
|
|
977
971
|
session: name,
|
|
978
|
-
content
|
|
972
|
+
content,
|
|
979
973
|
running: true,
|
|
980
974
|
agentAlive,
|
|
981
975
|
...size,
|
|
@@ -984,6 +978,42 @@ export function captureTerminal(name: string, config: OrchestratorConfig): Termi
|
|
|
984
978
|
};
|
|
985
979
|
}
|
|
986
980
|
|
|
981
|
+
// Capture cursor and content *consistently*. They come from separate tmux invocations,
|
|
982
|
+
// so if the pane scrolls between them (e.g. tool output streaming while the agent
|
|
983
|
+
// "thinks"), cursorY ends up off-by-one against the captured grid — the parked cursor
|
|
984
|
+
// then lands a row off and the TUI's next relative redraw stacks a stale statusline row
|
|
985
|
+
// (bottom-box ghost). Read content, then cursor, then content again; accept only when the
|
|
986
|
+
// two content reads bracket the cursor read unchanged, which proves the cursor reflects
|
|
987
|
+
// that exact grid. Fall through with the latest capture if the pane never holds still.
|
|
988
|
+
export function captureConsistent(
|
|
989
|
+
readContent: () => string,
|
|
990
|
+
readCursor: () => { cursorX?: number; cursorY?: number },
|
|
991
|
+
maxAttempts = 4,
|
|
992
|
+
): { content: string; cursor: { cursorX?: number; cursorY?: number } } {
|
|
993
|
+
let content = readContent();
|
|
994
|
+
let cursor = readCursor();
|
|
995
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
996
|
+
const recheck = readContent();
|
|
997
|
+
if (recheck === content) break;
|
|
998
|
+
content = recheck;
|
|
999
|
+
cursor = readCursor();
|
|
1000
|
+
}
|
|
1001
|
+
return { content, cursor };
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function captureContent(name: string, socketName?: string): string {
|
|
1005
|
+
const result = Bun.spawnSync(tmuxCommand(socketName, "capture-pane", "-p", "-e", "-S", "-1000", "-t", name), {
|
|
1006
|
+
stdin: "ignore",
|
|
1007
|
+
stdout: "pipe",
|
|
1008
|
+
stderr: "pipe",
|
|
1009
|
+
});
|
|
1010
|
+
if (result.exitCode !== 0) {
|
|
1011
|
+
const stderr = result.stderr.toString().trim();
|
|
1012
|
+
throw new Error(stderr || `tmux capture-pane failed with exit code ${result.exitCode}`);
|
|
1013
|
+
}
|
|
1014
|
+
return result.stdout.toString();
|
|
1015
|
+
}
|
|
1016
|
+
|
|
987
1017
|
export function terminalInputTokens(data: string): TerminalInputToken[] {
|
|
988
1018
|
const tokens: TerminalInputToken[] = [];
|
|
989
1019
|
let literal = "";
|