@hheei/pi-subagents 0.1.2
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/LICENSE +21 -0
- package/README.md +161 -0
- package/dist/agent-resolver.d.ts +37 -0
- package/dist/agent-resolver.js +232 -0
- package/dist/child-bridge.d.ts +41 -0
- package/dist/child-bridge.js +214 -0
- package/dist/child-nudge.d.ts +24 -0
- package/dist/child-nudge.js +102 -0
- package/dist/commands.d.ts +6 -0
- package/dist/commands.js +155 -0
- package/dist/connector.d.ts +53 -0
- package/dist/connector.js +283 -0
- package/dist/diagnostics.d.ts +10 -0
- package/dist/diagnostics.js +20 -0
- package/dist/domain.d.ts +192 -0
- package/dist/domain.js +63 -0
- package/dist/extension.d.ts +2 -0
- package/dist/extension.js +145 -0
- package/dist/host-adapter.d.ts +76 -0
- package/dist/host-adapter.js +323 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/json-lines.d.ts +14 -0
- package/dist/json-lines.js +94 -0
- package/dist/launch-spec.d.ts +47 -0
- package/dist/launch-spec.js +149 -0
- package/dist/manager.d.ts +101 -0
- package/dist/manager.js +802 -0
- package/dist/pause-gate.d.ts +14 -0
- package/dist/pause-gate.js +64 -0
- package/dist/protocol.d.ts +141 -0
- package/dist/protocol.js +168 -0
- package/dist/registry.d.ts +41 -0
- package/dist/registry.js +575 -0
- package/dist/rpc-adapter.d.ts +40 -0
- package/dist/rpc-adapter.js +194 -0
- package/dist/rpc-events.d.ts +8 -0
- package/dist/rpc-events.js +29 -0
- package/dist/runner-entry.d.ts +24 -0
- package/dist/runner-entry.js +166 -0
- package/dist/runner.d.ts +52 -0
- package/dist/runner.js +930 -0
- package/dist/runtime.d.ts +22 -0
- package/dist/runtime.js +253 -0
- package/dist/session-bootstrap.d.ts +69 -0
- package/dist/session-bootstrap.js +195 -0
- package/dist/state.d.ts +15 -0
- package/dist/state.js +168 -0
- package/dist/tools.d.ts +9 -0
- package/dist/tools.js +158 -0
- package/dist/widget.d.ts +16 -0
- package/dist/widget.js +158 -0
- package/package.json +53 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { registerExtensionLifecycle } from "@hheei/pi-ext-core";
|
|
2
|
+
import { registerChildBridge } from "./child-bridge.js";
|
|
3
|
+
import { bindParentStatus, registerParentCommands } from "./commands.js";
|
|
4
|
+
import { BRIDGE_ENVIRONMENT_KEYS, isThinkingLevel } from "./domain.js";
|
|
5
|
+
import { createCmuxHostAdapter, createHerdrHostAdapter, selectHostAdapter, } from "./host-adapter.js";
|
|
6
|
+
import { SubagentManager } from "./manager.js";
|
|
7
|
+
import { createSubagentRegistry } from "./registry.js";
|
|
8
|
+
import { createRuntimeTokenStore, launchDetachedRunner, recoverDetachedRunner } from "./runtime.js";
|
|
9
|
+
import { persistSubagentIntent, resolveSubagentLaunch } from "./session-bootstrap.js";
|
|
10
|
+
import { registerParentTools } from "./tools.js";
|
|
11
|
+
import { createSubagentWidget } from "./widget.js";
|
|
12
|
+
const CHILD_ENV_KEYS = Object.values(BRIDGE_ENVIRONMENT_KEYS);
|
|
13
|
+
function requiredBridgeValue(env, key) {
|
|
14
|
+
const value = env[key];
|
|
15
|
+
if (typeof value !== "string" || value === "") {
|
|
16
|
+
throw new Error("Invalid partial pi-subagents child identity environment");
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
function childIdentityFromEnv(env) {
|
|
21
|
+
const present = CHILD_ENV_KEYS.filter((key) => env[key] !== undefined);
|
|
22
|
+
if (present.length === 0)
|
|
23
|
+
return undefined;
|
|
24
|
+
return {
|
|
25
|
+
parentSessionId: requiredBridgeValue(env, BRIDGE_ENVIRONMENT_KEYS.parentSessionId),
|
|
26
|
+
subagentId: requiredBridgeValue(env, BRIDGE_ENVIRONMENT_KEYS.childId),
|
|
27
|
+
runtimeIdentity: requiredBridgeValue(env, BRIDGE_ENVIRONMENT_KEYS.runtimeId),
|
|
28
|
+
endpoint: requiredBridgeValue(env, BRIDGE_ENVIRONMENT_KEYS.endpoint),
|
|
29
|
+
token: requiredBridgeValue(env, BRIDGE_ENVIRONMENT_KEYS.token),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export default function piSubagentsExtension(pi) {
|
|
33
|
+
const child = childIdentityFromEnv(process.env);
|
|
34
|
+
if (child !== undefined) {
|
|
35
|
+
registerChildBridge(pi, child);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
registerExtensionLifecycle(pi, {
|
|
39
|
+
key: "@hheei/pi-subagents",
|
|
40
|
+
async start(runtime) {
|
|
41
|
+
const context = runtime.extension;
|
|
42
|
+
const parentSessionId = context.sessionManager.getSessionId();
|
|
43
|
+
const registry = createSubagentRegistry({ parentSessionId });
|
|
44
|
+
const tokens = createRuntimeTokenStore();
|
|
45
|
+
const channel = {
|
|
46
|
+
async deliver(report) {
|
|
47
|
+
pi.sendMessage({
|
|
48
|
+
customType: "pi-subagent-report",
|
|
49
|
+
content: `[Subagent ${report.agent} ${report.childId}: ${report.reason}]\n${report.message}`,
|
|
50
|
+
display: true,
|
|
51
|
+
details: report,
|
|
52
|
+
}, { triggerTurn: true, deliverAs: "nextTurn" });
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
let manager;
|
|
56
|
+
const ownsAttachment = (identity) => manager.ownsHostAttachment(identity);
|
|
57
|
+
manager = new SubagentManager({
|
|
58
|
+
parentSessionId,
|
|
59
|
+
registry,
|
|
60
|
+
channel,
|
|
61
|
+
tokens,
|
|
62
|
+
async resolve(input) {
|
|
63
|
+
const model = context.model;
|
|
64
|
+
if (model === undefined)
|
|
65
|
+
throw new Error("No parent model is selected");
|
|
66
|
+
const thinking = context.thinkingLevel ?? pi.getThinkingLevel();
|
|
67
|
+
if (!isThinkingLevel(thinking)) {
|
|
68
|
+
throw new Error(`Unsupported parent thinking level: ${thinking}`);
|
|
69
|
+
}
|
|
70
|
+
return resolveSubagentLaunch({
|
|
71
|
+
input,
|
|
72
|
+
cwd: context.cwd,
|
|
73
|
+
modelRegistry: context.modelRegistry,
|
|
74
|
+
parent: {
|
|
75
|
+
model: { provider: model.provider, id: model.id },
|
|
76
|
+
thinking,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
bootstrap(input) {
|
|
81
|
+
return persistSubagentIntent({ registry, ...input });
|
|
82
|
+
},
|
|
83
|
+
launch(record) {
|
|
84
|
+
return launchDetachedRunner({
|
|
85
|
+
registry,
|
|
86
|
+
record,
|
|
87
|
+
tokens,
|
|
88
|
+
signal: runtime.signal,
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
connect(record) {
|
|
92
|
+
return recoverDetachedRunner({
|
|
93
|
+
registry,
|
|
94
|
+
record,
|
|
95
|
+
tokens,
|
|
96
|
+
signal: runtime.signal,
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
attachHost: {
|
|
100
|
+
select(preferredHost) {
|
|
101
|
+
return selectHostAdapter({
|
|
102
|
+
adapters: {
|
|
103
|
+
herdr: createHerdrHostAdapter({
|
|
104
|
+
ownerId: parentSessionId,
|
|
105
|
+
ownsAttachment,
|
|
106
|
+
}),
|
|
107
|
+
cmux: createCmuxHostAdapter({
|
|
108
|
+
ownerId: parentSessionId,
|
|
109
|
+
ownsAttachment,
|
|
110
|
+
}),
|
|
111
|
+
},
|
|
112
|
+
...(preferredHost === undefined ? {} : { preferredHost }),
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
const recovery = await manager.recover();
|
|
118
|
+
if (recovery.failures.length > 0) {
|
|
119
|
+
pi.sendMessage({
|
|
120
|
+
customType: "pi-subagent-recovery-failure",
|
|
121
|
+
content: `Subagent recovery failed:\n${recovery.failures
|
|
122
|
+
.map((failure) => `- ${failure.childId}: ${failure.reason}`)
|
|
123
|
+
.join("\n")}`,
|
|
124
|
+
display: true,
|
|
125
|
+
details: recovery.failures,
|
|
126
|
+
}, { triggerTurn: false });
|
|
127
|
+
}
|
|
128
|
+
registerParentTools(pi, manager);
|
|
129
|
+
registerParentCommands(pi, manager);
|
|
130
|
+
const widget = createSubagentWidget(pi, context, runtime.signal);
|
|
131
|
+
const refreshWidget = () => {
|
|
132
|
+
void manager.list().then((children) => widget?.refresh(children));
|
|
133
|
+
};
|
|
134
|
+
const unsubscribe = manager.onChange(refreshWidget);
|
|
135
|
+
const unbindStatus = bindParentStatus(pi, context, manager);
|
|
136
|
+
refreshWidget();
|
|
137
|
+
runtime.resources.add("subagent-widget", () => {
|
|
138
|
+
unsubscribe();
|
|
139
|
+
unbindStatus();
|
|
140
|
+
widget?.dispose();
|
|
141
|
+
});
|
|
142
|
+
runtime.resources.add("subagent-manager", () => manager.closeLocalConnections());
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { LaunchSpec } from "./launch-spec.js";
|
|
2
|
+
export type HostKind = "herdr" | "cmux";
|
|
3
|
+
/** Where a host command failed: session/env, layout, or the launch command itself. */
|
|
4
|
+
export type HostFailureClass = "environment" | "pane" | "command";
|
|
5
|
+
export interface HostCommandResult {
|
|
6
|
+
readonly stdout: string;
|
|
7
|
+
readonly stderr: string;
|
|
8
|
+
readonly exitCode: number | null;
|
|
9
|
+
readonly timedOut: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface HostCommandRunner {
|
|
12
|
+
run(command: string, args: readonly string[], options?: {
|
|
13
|
+
readonly timeoutMs?: number;
|
|
14
|
+
}): Promise<HostCommandResult>;
|
|
15
|
+
}
|
|
16
|
+
export interface HostCapability {
|
|
17
|
+
readonly host: HostKind;
|
|
18
|
+
readonly available: boolean;
|
|
19
|
+
readonly reason: string;
|
|
20
|
+
}
|
|
21
|
+
export type HostSelection = {
|
|
22
|
+
readonly available: true;
|
|
23
|
+
readonly selectedHost: HostKind;
|
|
24
|
+
readonly adapter: HostAdapter;
|
|
25
|
+
readonly explicit: boolean;
|
|
26
|
+
readonly reason: string;
|
|
27
|
+
readonly attempts: readonly HostCapability[];
|
|
28
|
+
} | {
|
|
29
|
+
readonly available: false;
|
|
30
|
+
readonly selectedHost: null;
|
|
31
|
+
readonly explicit: boolean;
|
|
32
|
+
readonly reason: string;
|
|
33
|
+
readonly attempts: readonly HostCapability[];
|
|
34
|
+
};
|
|
35
|
+
export interface HostAttachmentIdentity {
|
|
36
|
+
readonly host: HostKind;
|
|
37
|
+
readonly attachmentId: string;
|
|
38
|
+
readonly createdBy: string;
|
|
39
|
+
}
|
|
40
|
+
export interface HostObservation {
|
|
41
|
+
readonly identity: HostAttachmentIdentity;
|
|
42
|
+
readonly alive: boolean;
|
|
43
|
+
readonly known: boolean;
|
|
44
|
+
readonly detail: string;
|
|
45
|
+
}
|
|
46
|
+
export interface HostAttachment {
|
|
47
|
+
readonly identity: HostAttachmentIdentity;
|
|
48
|
+
readonly launch: HostCommandResult;
|
|
49
|
+
observe(): Promise<HostObservation>;
|
|
50
|
+
cleanup(): Promise<HostCommandResult>;
|
|
51
|
+
}
|
|
52
|
+
export interface HostAdapter {
|
|
53
|
+
readonly kind: HostKind;
|
|
54
|
+
probe(): Promise<HostCapability>;
|
|
55
|
+
attach(spec: LaunchSpec): Promise<HostAttachment>;
|
|
56
|
+
}
|
|
57
|
+
export interface SelectHostAdapterOptions {
|
|
58
|
+
readonly adapters: Readonly<Record<HostKind, HostAdapter>>;
|
|
59
|
+
readonly preferredHost?: HostKind;
|
|
60
|
+
}
|
|
61
|
+
/** Selects a presentation host visibly; an explicit unavailable host never falls back. */
|
|
62
|
+
export declare function selectHostAdapter(options: SelectHostAdapterOptions): Promise<HostSelection>;
|
|
63
|
+
export declare class HostCommandError extends Error {
|
|
64
|
+
readonly kind: HostFailureClass;
|
|
65
|
+
readonly result: HostCommandResult;
|
|
66
|
+
constructor(kind: HostFailureClass, message: string, result: HostCommandResult);
|
|
67
|
+
}
|
|
68
|
+
export declare const systemHostCommandRunner: HostCommandRunner;
|
|
69
|
+
export interface HostAdapterOptions {
|
|
70
|
+
readonly runner?: HostCommandRunner;
|
|
71
|
+
readonly ownerId: string;
|
|
72
|
+
readonly ownsAttachment: (identity: HostAttachmentIdentity) => boolean;
|
|
73
|
+
readonly timeoutMs?: number;
|
|
74
|
+
}
|
|
75
|
+
export declare function createHerdrHostAdapter(options: HostAdapterOptions): HostAdapter;
|
|
76
|
+
export declare function createCmuxHostAdapter(options: HostAdapterOptions): HostAdapter;
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
const DEFAULT_HOST_ORDER = ["herdr", "cmux"];
|
|
5
|
+
/** Selects a presentation host visibly; an explicit unavailable host never falls back. */
|
|
6
|
+
export async function selectHostAdapter(options) {
|
|
7
|
+
const explicit = options.preferredHost !== undefined;
|
|
8
|
+
const order = explicit ? [options.preferredHost] : DEFAULT_HOST_ORDER;
|
|
9
|
+
const attempts = [];
|
|
10
|
+
for (const host of order) {
|
|
11
|
+
const adapter = options.adapters[host];
|
|
12
|
+
const capability = await adapter.probe();
|
|
13
|
+
attempts.push(capability);
|
|
14
|
+
if (capability.available) {
|
|
15
|
+
const priorFailures = attempts
|
|
16
|
+
.slice(0, -1)
|
|
17
|
+
.map((attempt) => `${attempt.host}: ${attempt.reason}`)
|
|
18
|
+
.join("; ");
|
|
19
|
+
return {
|
|
20
|
+
available: true,
|
|
21
|
+
selectedHost: host,
|
|
22
|
+
adapter,
|
|
23
|
+
explicit,
|
|
24
|
+
reason: priorFailures === ""
|
|
25
|
+
? `${explicit ? "explicit" : "default"} host ${host} is available`
|
|
26
|
+
: `selected ${host} after ${priorFailures}`,
|
|
27
|
+
attempts,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const failure = attempts.map((attempt) => `${attempt.host}: ${attempt.reason}`).join("; ");
|
|
32
|
+
return {
|
|
33
|
+
available: false,
|
|
34
|
+
selectedHost: null,
|
|
35
|
+
explicit,
|
|
36
|
+
reason: explicit
|
|
37
|
+
? `explicit host unavailable: ${failure}`
|
|
38
|
+
: `no presentation host available: ${failure}`,
|
|
39
|
+
attempts,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export class HostCommandError extends Error {
|
|
43
|
+
kind;
|
|
44
|
+
result;
|
|
45
|
+
constructor(kind, message, result) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = "HostCommandError";
|
|
48
|
+
this.kind = kind;
|
|
49
|
+
this.result = result;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export const systemHostCommandRunner = {
|
|
53
|
+
async run(command, args, options = {}) {
|
|
54
|
+
try {
|
|
55
|
+
const result = await execFileAsync(command, [...args], {
|
|
56
|
+
timeout: options.timeoutMs,
|
|
57
|
+
maxBuffer: 1024 * 1024,
|
|
58
|
+
windowsHide: true,
|
|
59
|
+
});
|
|
60
|
+
return { stdout: result.stdout, stderr: result.stderr, exitCode: 0, timedOut: false };
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
const value = error;
|
|
64
|
+
if (value.code === "ENOENT") {
|
|
65
|
+
return {
|
|
66
|
+
stdout: "",
|
|
67
|
+
stderr: `command not found: ${command}`,
|
|
68
|
+
exitCode: 127,
|
|
69
|
+
timedOut: false,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
stdout: value.stdout ?? "",
|
|
74
|
+
stderr: value.stderr ?? value.message ?? String(error),
|
|
75
|
+
exitCode: typeof value.code === "number" ? value.code : null,
|
|
76
|
+
timedOut: value.killed === true,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
export function createHerdrHostAdapter(options) {
|
|
82
|
+
const runner = options.runner ?? systemHostCommandRunner;
|
|
83
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
84
|
+
return {
|
|
85
|
+
kind: "herdr",
|
|
86
|
+
async probe() {
|
|
87
|
+
if (process.env.HERDR_ENV !== "1") {
|
|
88
|
+
return { host: "herdr", available: false, reason: "HERDR_ENV is not 1" };
|
|
89
|
+
}
|
|
90
|
+
const current = await runner.run("herdr", ["pane", "current", "--current"], { timeoutMs });
|
|
91
|
+
if (current.timedOut) {
|
|
92
|
+
return { host: "herdr", available: false, reason: "herdr pane probe timed out" };
|
|
93
|
+
}
|
|
94
|
+
if (current.exitCode === 127) {
|
|
95
|
+
return { host: "herdr", available: false, reason: current.stderr };
|
|
96
|
+
}
|
|
97
|
+
if (current.exitCode !== 0) {
|
|
98
|
+
return {
|
|
99
|
+
host: "herdr",
|
|
100
|
+
available: false,
|
|
101
|
+
reason: `herdr pane probe failed: ${current.stderr}`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const pane = parseHerdrPaneId(current.stdout);
|
|
105
|
+
return pane === undefined
|
|
106
|
+
? { host: "herdr", available: false, reason: "herdr probe returned no current pane id" }
|
|
107
|
+
: { host: "herdr", available: true, reason: `current pane ${pane}` };
|
|
108
|
+
},
|
|
109
|
+
async attach(spec) {
|
|
110
|
+
const current = await runner.run("herdr", ["pane", "current", "--current"], { timeoutMs });
|
|
111
|
+
const parentPane = parseHerdrPaneId(current.stdout);
|
|
112
|
+
if (parentPane === undefined) {
|
|
113
|
+
throw new HostCommandError("pane", "herdr current pane unavailable", current);
|
|
114
|
+
}
|
|
115
|
+
const split = await runner.run("herdr", herdrSplitArgs(parentPane, spec), { timeoutMs });
|
|
116
|
+
const childPane = parseHerdrPaneId(split.stdout);
|
|
117
|
+
if (childPane === undefined) {
|
|
118
|
+
throw new HostCommandError("pane", "herdr split returned no child pane id", split);
|
|
119
|
+
}
|
|
120
|
+
// pane run sends text+Enter to the new shell. Timeout is not rollback: the pane
|
|
121
|
+
// already exists and the process may still start after the CLI deadline.
|
|
122
|
+
const launch = await runner.run("herdr", ["pane", "run", childPane, quotedArgv(spec.command, spec.argv)], { timeoutMs });
|
|
123
|
+
return hostAttachment({
|
|
124
|
+
identity: { host: "herdr", attachmentId: childPane, createdBy: options.ownerId },
|
|
125
|
+
launch,
|
|
126
|
+
noun: "pane",
|
|
127
|
+
observe: () => runner.run("herdr", ["pane", "process-info", "--pane", childPane], { timeoutMs }),
|
|
128
|
+
isAlive: (result) => result.exitCode === 0 && herdrChildProcessIsRunning(result.stdout),
|
|
129
|
+
close: () => runner.run("herdr", ["pane", "close", childPane], { timeoutMs }),
|
|
130
|
+
ownsAttachment: options.ownsAttachment,
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
export function createCmuxHostAdapter(options) {
|
|
136
|
+
const runner = options.runner ?? systemHostCommandRunner;
|
|
137
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
138
|
+
return {
|
|
139
|
+
kind: "cmux",
|
|
140
|
+
async probe() {
|
|
141
|
+
const ping = await runner.run("cmux", ["ping"], { timeoutMs });
|
|
142
|
+
if (ping.timedOut)
|
|
143
|
+
return { host: "cmux", available: false, reason: "cmux ping timed out" };
|
|
144
|
+
if (ping.exitCode !== 0) {
|
|
145
|
+
return { host: "cmux", available: false, reason: `cmux unavailable: ${ping.stderr}` };
|
|
146
|
+
}
|
|
147
|
+
const capabilities = await runner.run("cmux", ["capabilities", "--json"], { timeoutMs });
|
|
148
|
+
if (capabilities.timedOut || capabilities.exitCode !== 0) {
|
|
149
|
+
return { host: "cmux", available: false, reason: "cmux capability query failed" };
|
|
150
|
+
}
|
|
151
|
+
return { host: "cmux", available: true, reason: "cmux responded and exposed capabilities" };
|
|
152
|
+
},
|
|
153
|
+
async attach(spec) {
|
|
154
|
+
const launch = await runner.run("cmux", ["--json", "new-split", "right", "--command", cmuxLaunchCommand(spec)], { timeoutMs });
|
|
155
|
+
const attachmentId = parseCmuxSurfaceId(launch.stdout);
|
|
156
|
+
if (attachmentId === undefined) {
|
|
157
|
+
throw new HostCommandError("pane", "cmux split returned no surface id; process state is unknown", launch);
|
|
158
|
+
}
|
|
159
|
+
return hostAttachment({
|
|
160
|
+
identity: { host: "cmux", attachmentId, createdBy: options.ownerId },
|
|
161
|
+
launch,
|
|
162
|
+
noun: "surface",
|
|
163
|
+
observe: () => runner.run("cmux", ["--json", "list-panels"], { timeoutMs }),
|
|
164
|
+
isAlive: (result) => result.exitCode === 0 && containsString(result.stdout, attachmentId),
|
|
165
|
+
close: () => runner.run("cmux", ["--json", "close-surface", "--surface", attachmentId], {
|
|
166
|
+
timeoutMs,
|
|
167
|
+
}),
|
|
168
|
+
ownsAttachment: options.ownsAttachment,
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function hostAttachment(options) {
|
|
174
|
+
const observe = async () => {
|
|
175
|
+
const result = await options.observe();
|
|
176
|
+
return {
|
|
177
|
+
identity: options.identity,
|
|
178
|
+
alive: !result.timedOut && options.isAlive(result),
|
|
179
|
+
known: !result.timedOut,
|
|
180
|
+
detail: result.stderr || result.stdout,
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
return {
|
|
184
|
+
identity: options.identity,
|
|
185
|
+
launch: options.launch,
|
|
186
|
+
observe,
|
|
187
|
+
async cleanup() {
|
|
188
|
+
if (!options.ownsAttachment(options.identity)) {
|
|
189
|
+
return failedResult(`${options.noun} ownership was revoked`);
|
|
190
|
+
}
|
|
191
|
+
const observed = await observe();
|
|
192
|
+
if (!options.ownsAttachment(options.identity)) {
|
|
193
|
+
return failedResult(`${options.noun} ownership was revoked during observation`);
|
|
194
|
+
}
|
|
195
|
+
if (!observed.known) {
|
|
196
|
+
return failedResult(`${options.noun} observation timed out; not closing`);
|
|
197
|
+
}
|
|
198
|
+
return options.close();
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function herdrSplitArgs(parentPane, spec) {
|
|
203
|
+
const args = [
|
|
204
|
+
"pane",
|
|
205
|
+
"split",
|
|
206
|
+
"--pane",
|
|
207
|
+
parentPane,
|
|
208
|
+
"--direction",
|
|
209
|
+
"right",
|
|
210
|
+
"--cwd",
|
|
211
|
+
spec.cwd,
|
|
212
|
+
"--no-focus",
|
|
213
|
+
];
|
|
214
|
+
for (const [key, value] of Object.entries(spec.env)) {
|
|
215
|
+
args.push("--env", `${key}=${value}`);
|
|
216
|
+
}
|
|
217
|
+
return args;
|
|
218
|
+
}
|
|
219
|
+
/** Herdr `pane run` concatenates extra argv with spaces, so quoting happens here once. */
|
|
220
|
+
function quotedArgv(command, argv) {
|
|
221
|
+
return [command, ...argv].map(shellQuote).join(" ");
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* cmux `new-split --command` is shell text. Encode cwd + env + argv without inventing Pi flags.
|
|
225
|
+
*/
|
|
226
|
+
function cmuxLaunchCommand(spec) {
|
|
227
|
+
const directory = `cd ${shellQuote(spec.cwd)}`;
|
|
228
|
+
const environment = Object.entries(spec.env)
|
|
229
|
+
.map(([key, value]) => `${key}=${shellQuote(value)}`)
|
|
230
|
+
.join(" ");
|
|
231
|
+
return [directory, "&&", environment, quotedArgv(spec.command, spec.argv)]
|
|
232
|
+
.filter((part) => part.length > 0)
|
|
233
|
+
.join(" ");
|
|
234
|
+
}
|
|
235
|
+
function shellQuote(value) {
|
|
236
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
237
|
+
}
|
|
238
|
+
function containsString(output, target) {
|
|
239
|
+
try {
|
|
240
|
+
return valueContainsString(JSON.parse(output), target);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function valueContainsString(value, target) {
|
|
247
|
+
if (value === target)
|
|
248
|
+
return true;
|
|
249
|
+
if (typeof value !== "object" || value === null)
|
|
250
|
+
return false;
|
|
251
|
+
return Object.values(value).some((child) => valueContainsString(child, target));
|
|
252
|
+
}
|
|
253
|
+
function parseHerdrPaneId(output) {
|
|
254
|
+
return parseJsonId(output, ["pane_id"]);
|
|
255
|
+
}
|
|
256
|
+
function parseCmuxSurfaceId(output) {
|
|
257
|
+
const named = parseJsonId(output, ["surface_id", "surfaceId"]);
|
|
258
|
+
if (named !== undefined)
|
|
259
|
+
return named;
|
|
260
|
+
const generic = parseJsonId(output, ["id"], (value) => !value.startsWith("cli:"));
|
|
261
|
+
return generic;
|
|
262
|
+
}
|
|
263
|
+
function parseJsonId(output, keys, accept = () => true) {
|
|
264
|
+
try {
|
|
265
|
+
return findString(JSON.parse(output), keys, accept);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
function findString(value, keys, accept) {
|
|
272
|
+
if (typeof value !== "object" || value === null)
|
|
273
|
+
return undefined;
|
|
274
|
+
for (const key of keys) {
|
|
275
|
+
const candidate = value[key];
|
|
276
|
+
if (typeof candidate === "string" && candidate.length > 0 && accept(candidate)) {
|
|
277
|
+
return candidate;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
for (const child of Object.values(value)) {
|
|
281
|
+
const found = findString(child, keys, accept);
|
|
282
|
+
if (found !== undefined)
|
|
283
|
+
return found;
|
|
284
|
+
}
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
function herdrChildProcessIsRunning(output) {
|
|
288
|
+
try {
|
|
289
|
+
const info = findNamedObject(JSON.parse(output), "process_info");
|
|
290
|
+
if (info === undefined)
|
|
291
|
+
return false;
|
|
292
|
+
const shellPid = typeof info.shell_pid === "number" ? info.shell_pid : undefined;
|
|
293
|
+
const foreground = info.foreground_processes;
|
|
294
|
+
if (!Array.isArray(foreground))
|
|
295
|
+
return false;
|
|
296
|
+
return foreground.some((proc) => {
|
|
297
|
+
if (typeof proc !== "object" || proc === null)
|
|
298
|
+
return false;
|
|
299
|
+
const pid = proc.pid;
|
|
300
|
+
return typeof pid === "number" && pid !== shellPid;
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function findNamedObject(value, name) {
|
|
308
|
+
if (typeof value !== "object" || value === null)
|
|
309
|
+
return undefined;
|
|
310
|
+
const record = value;
|
|
311
|
+
const direct = record[name];
|
|
312
|
+
if (typeof direct === "object" && direct !== null)
|
|
313
|
+
return direct;
|
|
314
|
+
for (const child of Object.values(record)) {
|
|
315
|
+
const found = findNamedObject(child, name);
|
|
316
|
+
if (found !== undefined)
|
|
317
|
+
return found;
|
|
318
|
+
}
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
function failedResult(message) {
|
|
322
|
+
return { stdout: "", stderr: message, exitCode: null, timedOut: false };
|
|
323
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./extension.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./extension.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Readable, Writable } from "node:stream";
|
|
2
|
+
export type JsonLineErrorCode = "frame_too_large" | "malformed_json" | "stream_error";
|
|
3
|
+
export declare class JsonLineError extends Error {
|
|
4
|
+
readonly code: JsonLineErrorCode;
|
|
5
|
+
constructor(code: JsonLineErrorCode, message: string);
|
|
6
|
+
}
|
|
7
|
+
export interface JsonLineReaderOptions {
|
|
8
|
+
readonly maxFrameBytes: number;
|
|
9
|
+
readonly onValue: (value: unknown) => void;
|
|
10
|
+
readonly onError: (error: JsonLineError) => void;
|
|
11
|
+
}
|
|
12
|
+
export declare function attachJsonLineReader(stream: Readable, options: JsonLineReaderOptions): () => void;
|
|
13
|
+
export declare function serializeJsonLine(value: unknown, maxFrameBytes: number): Buffer;
|
|
14
|
+
export declare function writeJsonLine(stream: Writable, value: unknown, maxFrameBytes: number): Promise<void>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export class JsonLineError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
constructor(code, message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "JsonLineError";
|
|
6
|
+
this.code = code;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function attachJsonLineReader(stream, options) {
|
|
10
|
+
if (!Number.isSafeInteger(options.maxFrameBytes) || options.maxFrameBytes < 1) {
|
|
11
|
+
throw new Error("maxFrameBytes must be a positive safe integer");
|
|
12
|
+
}
|
|
13
|
+
let buffer = Buffer.alloc(0);
|
|
14
|
+
let stopped = false;
|
|
15
|
+
const fail = (error) => {
|
|
16
|
+
if (stopped)
|
|
17
|
+
return;
|
|
18
|
+
stopped = true;
|
|
19
|
+
cleanup();
|
|
20
|
+
options.onError(error);
|
|
21
|
+
};
|
|
22
|
+
const onData = (chunk) => {
|
|
23
|
+
if (stopped)
|
|
24
|
+
return;
|
|
25
|
+
const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
26
|
+
buffer = buffer.length === 0 ? incoming : Buffer.concat([buffer, incoming]);
|
|
27
|
+
while (!stopped) {
|
|
28
|
+
const newline = buffer.indexOf(0x0a);
|
|
29
|
+
if (newline < 0) {
|
|
30
|
+
if (buffer.length > options.maxFrameBytes) {
|
|
31
|
+
fail(new JsonLineError("frame_too_large", "JSON line exceeds frame limit"));
|
|
32
|
+
}
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (newline > options.maxFrameBytes) {
|
|
36
|
+
fail(new JsonLineError("frame_too_large", "JSON line exceeds frame limit"));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
let frame = buffer.subarray(0, newline);
|
|
40
|
+
buffer = buffer.subarray(newline + 1);
|
|
41
|
+
if (frame.length > 0 && frame[frame.length - 1] === 0x0d) {
|
|
42
|
+
frame = frame.subarray(0, frame.length - 1);
|
|
43
|
+
}
|
|
44
|
+
if (frame.length === 0)
|
|
45
|
+
continue;
|
|
46
|
+
try {
|
|
47
|
+
options.onValue(JSON.parse(frame.toString("utf8")));
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
fail(new JsonLineError("malformed_json", `Malformed JSON line: ${error instanceof Error ? error.message : String(error)}`));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const onStreamError = (error) => {
|
|
55
|
+
fail(new JsonLineError("stream_error", error.message));
|
|
56
|
+
};
|
|
57
|
+
const cleanup = () => {
|
|
58
|
+
stream.off("data", onData);
|
|
59
|
+
stream.off("error", onStreamError);
|
|
60
|
+
buffer = Buffer.alloc(0);
|
|
61
|
+
};
|
|
62
|
+
stream.on("data", onData);
|
|
63
|
+
stream.on("error", onStreamError);
|
|
64
|
+
return () => {
|
|
65
|
+
if (stopped)
|
|
66
|
+
return;
|
|
67
|
+
stopped = true;
|
|
68
|
+
cleanup();
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export function serializeJsonLine(value, maxFrameBytes) {
|
|
72
|
+
const encoded = Buffer.from(`${JSON.stringify(value)}\n`, "utf8");
|
|
73
|
+
if (encoded.length - 1 > maxFrameBytes) {
|
|
74
|
+
throw new JsonLineError("frame_too_large", "Serialized JSON line exceeds frame limit");
|
|
75
|
+
}
|
|
76
|
+
return encoded;
|
|
77
|
+
}
|
|
78
|
+
export function writeJsonLine(stream, value, maxFrameBytes) {
|
|
79
|
+
let encoded;
|
|
80
|
+
try {
|
|
81
|
+
encoded = serializeJsonLine(value, maxFrameBytes);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
return Promise.reject(error);
|
|
85
|
+
}
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
stream.write(encoded, (error) => {
|
|
88
|
+
if (error)
|
|
89
|
+
reject(error);
|
|
90
|
+
else
|
|
91
|
+
resolve();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|