@f5-sales-demo/xcsh 20.17.0 → 20.18.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "20.17.0",
4
+ "version": "20.18.1",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -61,13 +61,13 @@
61
61
  },
62
62
  "dependencies": {
63
63
  "@agentclientprotocol/sdk": "1.3.0",
64
- "@f5-sales-demo/pi-agent-core": "20.17.0",
65
- "@f5-sales-demo/pi-ai": "20.17.0",
66
- "@f5-sales-demo/pi-natives": "20.17.0",
67
- "@f5-sales-demo/pi-resource-management": "20.17.0",
68
- "@f5-sales-demo/pi-tui": "20.17.0",
69
- "@f5-sales-demo/pi-utils": "20.17.0",
70
- "@f5-sales-demo/xcsh-stats": "20.17.0",
64
+ "@f5-sales-demo/pi-agent-core": "20.18.1",
65
+ "@f5-sales-demo/pi-ai": "20.18.1",
66
+ "@f5-sales-demo/pi-natives": "20.18.1",
67
+ "@f5-sales-demo/pi-resource-management": "20.18.1",
68
+ "@f5-sales-demo/pi-tui": "20.18.1",
69
+ "@f5-sales-demo/pi-utils": "20.18.1",
70
+ "@f5-sales-demo/xcsh-stats": "20.18.1",
71
71
  "@mozilla/readability": "^0.6",
72
72
  "@sinclair/typebox": "^0.34",
73
73
  "@xterm/headless": "^6.0",
@@ -334,7 +334,7 @@ export class ChatHandler {
334
334
  return;
335
335
  }
336
336
  if (event.type === "tool_execution_end" && "toolName" in event) {
337
- if (event.toolName === "display_media" || event.toolName === "display_image") {
337
+ if (event.toolName === "display_media") {
338
338
  const descriptor = extractMediaDescriptorFromToolResult(event.result);
339
339
  if (descriptor) {
340
340
  this.#server.send({
package/src/cli.ts CHANGED
@@ -63,6 +63,7 @@ const commands: CommandEntry[] = [
63
63
  { name: "chrome", load: () => import("./commands/chrome").then(m => m.default) },
64
64
  { name: "chrome-host", load: () => import("./commands/native-host").then(m => m.default) },
65
65
  { name: "grep", load: () => import("./commands/grep").then(m => m.default) },
66
+ { name: "herdr", load: () => import("./commands/herdr").then(m => m.default) },
66
67
  { name: "grievances", load: () => import("./commands/grievances").then(m => m.default) },
67
68
  { name: "read", load: () => import("./commands/read").then(m => m.default) },
68
69
  {
@@ -0,0 +1,122 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { Args, Command, Flags } from "@f5-sales-demo/pi-utils/cli";
6
+ import type { HerdrBindingV1 } from "../herdr/controller";
7
+
8
+ interface CommandResult {
9
+ stdout: string;
10
+ stderr: string;
11
+ exitCode: number;
12
+ }
13
+
14
+ async function execute(command: string[], inherit = false): Promise<CommandResult> {
15
+ const child = Bun.spawn({
16
+ cmd: command,
17
+ stdin: inherit ? "inherit" : "ignore",
18
+ stdout: inherit ? "inherit" : "pipe",
19
+ stderr: inherit ? "inherit" : "pipe",
20
+ });
21
+ const [stdout, stderr, exitCode] = await Promise.all([
22
+ inherit ? Promise.resolve("") : new Response(child.stdout).text(),
23
+ inherit ? Promise.resolve("") : new Response(child.stderr).text(),
24
+ child.exited,
25
+ ]);
26
+ if (exitCode !== 0) throw new Error(stderr.trim() || `${command[0]} exited with ${exitCode}`);
27
+ return { stdout, stderr, exitCode };
28
+ }
29
+
30
+ function shellQuote(value: string): string {
31
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
32
+ }
33
+
34
+ function buildXcshCommand(executable: string, args: string[]): string {
35
+ return [executable, ...args].map(shellQuote).join(" ");
36
+ }
37
+
38
+ export default class Herdr extends Command {
39
+ static description = "Launch xcsh in a conversation-owned Herdr workspace";
40
+ static strict = false;
41
+ static flags = {
42
+ session: Flags.string({ required: true, description: "Herdr named session" }),
43
+ label: Flags.string({ description: "Workspace label" }),
44
+ };
45
+ static args = {
46
+ xcshArgs: Args.string({ required: false, multiple: true, description: "Arguments passed to xcsh after --" }),
47
+ };
48
+
49
+ async run(): Promise<void> {
50
+ if (process.env.HERDR_ENV === "1") throw new Error("xcsh herdr must be launched outside an existing Herdr pane");
51
+ const { flags, args } = await this.parse(Herdr);
52
+ const session = flags.session;
53
+ if (!session) throw new Error("--session is required");
54
+ const ownerToken = randomUUID();
55
+ const stateRoot = process.env.XDG_STATE_HOME ?? path.join(os.homedir(), ".local", "state");
56
+ const bindingDir = path.join(stateRoot, "xcsh", "herdr");
57
+ await fs.mkdir(bindingDir, { recursive: true, mode: 0o700 });
58
+ const bindingPath = path.join(bindingDir, `${session}-${ownerToken}.json`);
59
+ const label = flags.label ?? `xcsh-${new Date().toISOString().replace(/[-:.]/g, "").slice(0, 15).toLowerCase()}`;
60
+ const created = await execute([
61
+ "herdr",
62
+ "--session",
63
+ session,
64
+ "workspace",
65
+ "create",
66
+ "--cwd",
67
+ process.cwd(),
68
+ "--label",
69
+ label,
70
+ "--env",
71
+ `XCSH_HERDR_OWNER=${ownerToken}`,
72
+ "--env",
73
+ `XCSH_HERDR_BINDING_PATH=${bindingPath}`,
74
+ "--focus",
75
+ ]);
76
+ const response = JSON.parse(created.stdout) as {
77
+ result?: { workspace?: { workspace_id?: string }; root_pane?: { pane_id?: string } };
78
+ };
79
+ const workspaceId = response.result?.workspace?.workspace_id;
80
+ const rootPaneId = response.result?.root_pane?.pane_id;
81
+ if (!workspaceId || !rootPaneId) throw new Error("Herdr returned an invalid workspace creation response");
82
+ const binding: HerdrBindingV1 = {
83
+ version: 1,
84
+ sessionName: session,
85
+ workspaceId,
86
+ rootPaneId,
87
+ ownerToken,
88
+ terminals: [],
89
+ };
90
+ await fs.writeFile(bindingPath, `${JSON.stringify(binding, null, 2)}\n`, { mode: 0o600 });
91
+ await execute([
92
+ "herdr",
93
+ "--session",
94
+ session,
95
+ "workspace",
96
+ "report-metadata",
97
+ workspaceId,
98
+ "--source",
99
+ "xcsh:terminal",
100
+ "--token",
101
+ `xcsh_owner=${ownerToken}`,
102
+ ]);
103
+ await execute([
104
+ "herdr",
105
+ "--session",
106
+ session,
107
+ "pane",
108
+ "report-metadata",
109
+ rootPaneId,
110
+ "--source",
111
+ "xcsh:terminal",
112
+ "--token",
113
+ `xcsh_owner=${ownerToken}`,
114
+ ]);
115
+ const forwarded = Array.isArray(args.xcshArgs) ? args.xcshArgs : [];
116
+ const xcshCommand = buildXcshCommand(process.env.XCSH_BIN ?? "xcsh", forwarded);
117
+ await execute(["herdr", "--session", session, "pane", "run", rootPaneId, xcshCommand]);
118
+ await execute(["herdr", "session", "attach", session], true);
119
+ }
120
+ }
121
+
122
+ export { buildXcshCommand, shellQuote };
@@ -0,0 +1,135 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@f5-sales-demo/xcsh";
2
+ import { HerdrController } from "../../../herdr/controller";
3
+
4
+ type Action = "list" | "create" | "run" | "send" | "read" | "wait" | "status" | "focus" | "close";
5
+ interface TerminalParams {
6
+ action: Action;
7
+ target?: string;
8
+ name?: string;
9
+ cwd?: string;
10
+ command?: string;
11
+ text?: string;
12
+ match?: string;
13
+ lines?: number;
14
+ timeoutMs?: number;
15
+ enter?: boolean;
16
+ force?: boolean;
17
+ }
18
+
19
+ export function createRetryingLazy<T>(factory: () => Promise<T>): () => Promise<T> {
20
+ let pending: Promise<T> | undefined;
21
+ return () => {
22
+ pending ??= factory().catch(error => {
23
+ pending = undefined;
24
+ throw error;
25
+ });
26
+ return pending;
27
+ };
28
+ }
29
+
30
+ function sessionRef(ctx: ExtensionContext): string | undefined {
31
+ return ctx.sessionManager.getSessionFile?.() ?? ctx.sessionManager.getSessionId?.();
32
+ }
33
+
34
+ async function dispatch(controller: HerdrController, params: TerminalParams): Promise<unknown> {
35
+ switch (params.action) {
36
+ case "list":
37
+ return controller.list();
38
+ case "create":
39
+ return controller.create(params.name ?? "", params.cwd);
40
+ case "run":
41
+ await controller.run(params.target ?? "", params.command ?? "");
42
+ return { ok: true };
43
+ case "send":
44
+ await controller.send(params.target ?? "", params.text ?? "", params.enter);
45
+ return { ok: true };
46
+ case "read":
47
+ return controller.read(params.target ?? "", params.lines);
48
+ case "wait":
49
+ return controller.wait(params.target ?? "", params.match ?? "", params.timeoutMs);
50
+ case "status":
51
+ return controller.status(params.target ?? "");
52
+ case "focus":
53
+ await controller.focus(params.target ?? "");
54
+ return { ok: true };
55
+ case "close":
56
+ await controller.close(params.target ?? "", params.force);
57
+ return { ok: true };
58
+ }
59
+ }
60
+
61
+ export default function herdrTerminal(pi: ExtensionAPI): void {
62
+ const { Type } = pi.typebox;
63
+ const controller = createRetryingLazy(() => HerdrController.connect());
64
+
65
+ pi.registerTool({
66
+ name: "herdr_terminal",
67
+ label: "Herdr Terminal",
68
+ description:
69
+ "Manage support terminals owned by the current xcsh conversation. Actions: list, create, run, send, read, wait, status, focus, close. Focus and forced close must be explicit.",
70
+ parameters: Type.Object({
71
+ action: Type.Union(
72
+ ["list", "create", "run", "send", "read", "wait", "status", "focus", "close"].map(value =>
73
+ Type.Literal(value),
74
+ ),
75
+ ),
76
+ target: Type.Optional(Type.String({ description: "Terminal name or owned pane ID" })),
77
+ name: Type.Optional(Type.String({ description: "Name for a newly created terminal" })),
78
+ cwd: Type.Optional(Type.String({ description: "Working directory for a newly created terminal" })),
79
+ command: Type.Optional(Type.String({ description: "Command for run" })),
80
+ text: Type.Optional(Type.String({ description: "Text for send" })),
81
+ match: Type.Optional(Type.String({ description: "Literal output awaited by wait" })),
82
+ lines: Type.Optional(Type.Integer({ minimum: 1, maximum: 1000 })),
83
+ timeoutMs: Type.Optional(Type.Integer({ minimum: 0, maximum: 300000 })),
84
+ enter: Type.Optional(Type.Boolean()),
85
+ force: Type.Optional(Type.Boolean({ description: "Required on the second close call for a busy pane" })),
86
+ }),
87
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
88
+ try {
89
+ const active = await controller();
90
+ await active.updateSessionRef(sessionRef(ctx));
91
+ const result = await dispatch(active, params as TerminalParams);
92
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: result };
93
+ } catch (error) {
94
+ const message = error instanceof Error ? error.message : String(error);
95
+ return { content: [{ type: "text", text: message }], details: { error: message }, isError: true };
96
+ }
97
+ },
98
+ });
99
+
100
+ pi.registerCommand("terminal", {
101
+ description: "Manage Herdr terminals: /terminal <list|create|run|send|read|wait|status|focus|close>",
102
+ handler: async (args, ctx) => {
103
+ const [action = "list", target, ...rest] = args.trim().split(/\s+/).filter(Boolean);
104
+ const params: TerminalParams = { action: action as Action, target };
105
+ if (action === "create") params.name = target;
106
+ if (action === "run") params.command = rest.join(" ");
107
+ if (action === "send") params.text = rest.join(" ");
108
+ if (action === "wait") params.match = rest.join(" ");
109
+ if (action === "read" && rest[0]) params.lines = Number(rest[0]);
110
+ if (action === "close") params.force = rest.includes("--force");
111
+ try {
112
+ const active = await controller();
113
+ await active.updateSessionRef(sessionRef(ctx));
114
+ ctx.ui.notify(JSON.stringify(await dispatch(active, params), null, 2), "info");
115
+ } catch (error) {
116
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
117
+ }
118
+ },
119
+ });
120
+
121
+ const update = async (_event: unknown, ctx: ExtensionContext): Promise<void> => {
122
+ if (!process.env.HERDR_SOCKET_PATH) return;
123
+ try {
124
+ await (await controller()).updateSessionRef(sessionRef(ctx));
125
+ } catch (error) {
126
+ pi.logger.debug("Herdr terminal binding update failed", {
127
+ error: error instanceof Error ? error.message : String(error),
128
+ });
129
+ }
130
+ };
131
+ pi.on("session_start", update);
132
+ pi.on("session_switch", update);
133
+ pi.on("session_branch", update);
134
+ pi.on("session_tree", update);
135
+ }
@@ -20,6 +20,7 @@ import { EventBus } from "../../utils/event-bus";
20
20
  import { getAllPluginExtensionPaths } from "../plugins/loader";
21
21
  import { resolvePath } from "../utils";
22
22
  import herdrReporter from "./bundled/herdr-reporter";
23
+ import herdrTerminal from "./bundled/herdr-terminal";
23
24
  import sandboxGuard from "./bundled/sandbox-guard";
24
25
  import type {
25
26
  Extension,
@@ -501,6 +502,7 @@ async function discoverExtensionsInDir(dir: string): Promise<string[]> {
501
502
  /** Extensions bundled with xcsh and loaded by default (before user extensions). */
502
503
  const BUNDLED_EXTENSIONS: ReadonlyArray<{ name: string; factory: ExtensionFactory }> = [
503
504
  { name: "herdr-reporter", factory: herdrReporter },
505
+ { name: "herdr-terminal", factory: herdrTerminal },
504
506
  { name: "sandbox-guard", factory: sandboxGuard },
505
507
  ];
506
508
 
@@ -0,0 +1,127 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { connect } from "node:net";
3
+
4
+ export const HERDR_PROTOCOL_VERSION = 18;
5
+ const DEFAULT_TIMEOUT_MS = 5_000;
6
+ const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
7
+
8
+ export class HerdrProtocolError extends Error {
9
+ constructor(
10
+ message: string,
11
+ readonly code = "protocol_error",
12
+ ) {
13
+ super(message);
14
+ this.name = "HerdrProtocolError";
15
+ }
16
+ }
17
+
18
+ function isRecord(value: unknown): value is Record<string, unknown> {
19
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20
+ }
21
+
22
+ /** Typed, one-request-per-connection client for Herdr's protocol-18 JSONL socket. */
23
+ export class HerdrClient {
24
+ private protocolChecked = false;
25
+
26
+ constructor(
27
+ readonly socketPath: string,
28
+ readonly timeoutMs = DEFAULT_TIMEOUT_MS,
29
+ ) {
30
+ if (!socketPath) throw new HerdrProtocolError("HERDR_SOCKET_PATH is unavailable", "unavailable");
31
+ }
32
+
33
+ async ensureProtocol(): Promise<void> {
34
+ if (this.protocolChecked) return;
35
+ const pong = await this.requestRaw<{ type: string; protocol: number; version: string }>("ping", {});
36
+ if (pong.type !== "pong" || pong.protocol !== HERDR_PROTOCOL_VERSION) {
37
+ throw new HerdrProtocolError(
38
+ `Herdr protocol mismatch: expected ${HERDR_PROTOCOL_VERSION}, received ${String(pong.protocol)}`,
39
+ "protocol_mismatch",
40
+ );
41
+ }
42
+ this.protocolChecked = true;
43
+ }
44
+
45
+ async request<T extends Record<string, unknown>>(method: string, params: Record<string, unknown>): Promise<T> {
46
+ await this.ensureProtocol();
47
+ return this.requestRaw<T>(method, params);
48
+ }
49
+
50
+ private requestRaw<T>(method: string, params: Record<string, unknown>): Promise<T> {
51
+ const id = `xcsh:${randomUUID()}`;
52
+ return new Promise<T>((resolve, reject) => {
53
+ let buffer = "";
54
+ let settled = false;
55
+ const socket = connect({ path: this.socketPath });
56
+ const finish = (error?: unknown, value?: T): void => {
57
+ if (settled) return;
58
+ settled = true;
59
+ socket.destroy();
60
+ if (error !== undefined) reject(error);
61
+ else resolve(value as T);
62
+ };
63
+ socket.setEncoding("utf8");
64
+ socket.setTimeout(this.timeoutMs, () => finish(new HerdrProtocolError("Herdr request timed out", "timeout")));
65
+ socket.once("error", error => finish(new HerdrProtocolError(error.message, "transport_error")));
66
+ socket.once("connect", () => socket.write(`${JSON.stringify({ id, method, params })}\n`));
67
+ socket.on("data", chunk => {
68
+ buffer += chunk;
69
+ if (Buffer.byteLength(buffer) > MAX_RESPONSE_BYTES) {
70
+ finish(new HerdrProtocolError("Herdr response exceeded 4 MiB", "response_too_large"));
71
+ return;
72
+ }
73
+ for (;;) {
74
+ const newline = buffer.indexOf("\n");
75
+ if (newline < 0) break;
76
+ const line = buffer.slice(0, newline);
77
+ buffer = buffer.slice(newline + 1);
78
+ if (!line.trim()) continue;
79
+ let decoded: unknown;
80
+ try {
81
+ decoded = JSON.parse(line);
82
+ } catch {
83
+ finish(new HerdrProtocolError("Herdr returned invalid JSON", "invalid_json"));
84
+ return;
85
+ }
86
+ if (!isRecord(decoded) || decoded.id !== id) continue;
87
+ if (isRecord(decoded.error)) {
88
+ finish(
89
+ new HerdrProtocolError(
90
+ typeof decoded.error.message === "string" ? decoded.error.message : "Herdr request failed",
91
+ typeof decoded.error.code === "string" ? decoded.error.code : "remote_error",
92
+ ),
93
+ );
94
+ return;
95
+ }
96
+ if (!("result" in decoded) || !isRecord(decoded.result)) {
97
+ finish(new HerdrProtocolError("Herdr response is missing a typed result"));
98
+ return;
99
+ }
100
+ finish(undefined, decoded.result as T);
101
+ return;
102
+ }
103
+ });
104
+ socket.once("end", () => {
105
+ if (settled) return;
106
+ // Protocol 18 permits the final response to be terminated by EOF. The
107
+ // server normally emits JSONL, but older/current transports may omit the
108
+ // trailing newline when they close a one-shot connection.
109
+ try {
110
+ const decoded = JSON.parse(buffer) as Record<string, unknown>;
111
+ if (decoded.id !== id) throw new Error("response id mismatch");
112
+ if (isRecord(decoded.error)) {
113
+ finish(
114
+ new HerdrProtocolError(
115
+ typeof decoded.error.message === "string" ? decoded.error.message : "Herdr request failed",
116
+ typeof decoded.error.code === "string" ? decoded.error.code : "remote_error",
117
+ ),
118
+ );
119
+ } else if (isRecord(decoded.result)) finish(undefined, decoded.result as T);
120
+ else finish(new HerdrProtocolError("Herdr response is missing a typed result"));
121
+ } catch {
122
+ finish(new HerdrProtocolError("Herdr closed the socket before responding", "eof"));
123
+ }
124
+ });
125
+ });
126
+ }
127
+ }