opencode-zellij 0.0.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/README.md +59 -0
- package/README.zh.md +59 -0
- package/dist/index.d.mts +7 -0
- package/dist/index.mjs +1047 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +38 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1047 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { setTimeout } from "node:timers/promises";
|
|
3
|
+
import { tool } from "@opencode-ai/plugin";
|
|
4
|
+
import { execFile, spawn } from "node:child_process";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { Buffer } from "node:buffer";
|
|
8
|
+
//#region src/utils/shell-args.ts
|
|
9
|
+
const directCommandExitWrapper = "token=\"$1\"; shift; set +e; \"$@\"; code=$?; printf \"\\n[zellij-pty:%s] exit-code=%s\\n\" \"$token\" \"$code\"; exit \"$code\"";
|
|
10
|
+
const shellCommandExitWrapper = "token=\"$1\"; command=\"$2\"; set +e; bash -lc \"$command\"; code=$?; printf \"\\n[zellij-pty:%s] exit-code=%s\\n\" \"$token\" \"$code\"; exit \"$code\"";
|
|
11
|
+
function buildCommandArgv(input, options = {}) {
|
|
12
|
+
const command = input.command.trim();
|
|
13
|
+
if (!command) throw new Error("command is required");
|
|
14
|
+
if (options.exitCodeToken) {
|
|
15
|
+
if (input.args && input.args.length > 0) return [
|
|
16
|
+
"bash",
|
|
17
|
+
"-lc",
|
|
18
|
+
directCommandExitWrapper,
|
|
19
|
+
"zellij-pty",
|
|
20
|
+
options.exitCodeToken,
|
|
21
|
+
command,
|
|
22
|
+
...input.args
|
|
23
|
+
];
|
|
24
|
+
return [
|
|
25
|
+
"bash",
|
|
26
|
+
"-lc",
|
|
27
|
+
shellCommandExitWrapper,
|
|
28
|
+
"zellij-pty",
|
|
29
|
+
options.exitCodeToken,
|
|
30
|
+
command
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
if (input.args && input.args.length > 0) return [command, ...input.args];
|
|
34
|
+
return [
|
|
35
|
+
"bash",
|
|
36
|
+
"-lc",
|
|
37
|
+
command
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
function commandLineForPolicy(input) {
|
|
41
|
+
if (!input.args || input.args.length === 0) return input.command.trim();
|
|
42
|
+
return [input.command, ...input.args].join(" ").trim();
|
|
43
|
+
}
|
|
44
|
+
//#endregion
|
|
45
|
+
//#region src/permissions/policy.ts
|
|
46
|
+
const denyPatterns = [
|
|
47
|
+
/(^|\s)rm\s+-[^\n&;r|]*r[^\n&;|]*f\s+\//,
|
|
48
|
+
/(^|\s)mkfs(?:\s|$)/,
|
|
49
|
+
/(^|\s)dd\s+(?:[^\s&;|][^\n;|&]*)?\bof=\/dev\//,
|
|
50
|
+
/:\(\)\s*\{\s*:\|:\s*&\s*\}\s*;/
|
|
51
|
+
];
|
|
52
|
+
const sudoPattern = /(?:^|[\s;&|])sudo(?:[\s;&|]|$)/;
|
|
53
|
+
let configuredDenyCommands = [];
|
|
54
|
+
let configuredAllowCommands = [];
|
|
55
|
+
let allowSudoPane = true;
|
|
56
|
+
function isStringArray(value) {
|
|
57
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
58
|
+
}
|
|
59
|
+
function escapeRegex(value) {
|
|
60
|
+
return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
61
|
+
}
|
|
62
|
+
function wildcardMatches(pattern, commandLine) {
|
|
63
|
+
return new RegExp(`^${pattern.split("*").map(escapeRegex).join(".*")}$`).test(commandLine);
|
|
64
|
+
}
|
|
65
|
+
function configurePolicy(config) {
|
|
66
|
+
if (!config || typeof config !== "object") return;
|
|
67
|
+
const object = config;
|
|
68
|
+
if (isStringArray(object.denyCommands)) configuredDenyCommands = object.denyCommands;
|
|
69
|
+
if (isStringArray(object.allowCommands)) configuredAllowCommands = object.allowCommands;
|
|
70
|
+
if (typeof object.allowSudoPane === "boolean") allowSudoPane = object.allowSudoPane;
|
|
71
|
+
}
|
|
72
|
+
function assertCommandAllowed(input) {
|
|
73
|
+
const commandLine = commandLineForPolicy(input);
|
|
74
|
+
for (const pattern of denyPatterns) if (pattern.test(commandLine)) throw new Error(`Command denied by zellij-pty policy: ${commandLine}`);
|
|
75
|
+
for (const pattern of configuredDenyCommands) if (wildcardMatches(pattern, commandLine)) throw new Error(`Command denied by zellij-pty configured deny rule: ${commandLine}`);
|
|
76
|
+
if (configuredAllowCommands.length > 0 && !configuredAllowCommands.some((pattern) => wildcardMatches(pattern, commandLine))) throw new Error(`Command denied by zellij-pty allow list: ${commandLine}`);
|
|
77
|
+
if (!input.humanInputOnly && sudoPattern.test(commandLine)) throw new Error("sudo commands must use request_sudo so credentials stay human-input-only and never pass through agent tool input.");
|
|
78
|
+
if (input.humanInputOnly && sudoPattern.test(commandLine) && !allowSudoPane) throw new Error("sudo pane is disabled by zellij-pty policy.");
|
|
79
|
+
}
|
|
80
|
+
//#endregion
|
|
81
|
+
//#region src/utils/ids.ts
|
|
82
|
+
const paneIdPattern = /\b(?:terminal_)?(\d+)\b/;
|
|
83
|
+
function createSessionId() {
|
|
84
|
+
return `zpty_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
85
|
+
}
|
|
86
|
+
function normalizePaneId(rawPaneId) {
|
|
87
|
+
const trimmed = rawPaneId.trim();
|
|
88
|
+
if (/^terminal_\d+$/.test(trimmed)) return trimmed;
|
|
89
|
+
if (/^\d+$/.test(trimmed)) return `terminal_${trimmed}`;
|
|
90
|
+
throw new Error(`Invalid Zellij terminal pane id: ${rawPaneId}`);
|
|
91
|
+
}
|
|
92
|
+
function parsePaneId(output) {
|
|
93
|
+
const match = output.match(paneIdPattern);
|
|
94
|
+
if (!match?.[1]) throw new Error(`Unable to parse Zellij pane id from output: ${output.trim() || "<empty>"}`);
|
|
95
|
+
return normalizePaneId(match[1]);
|
|
96
|
+
}
|
|
97
|
+
//#endregion
|
|
98
|
+
//#region src/pty/manager.ts
|
|
99
|
+
var SessionManager = class {
|
|
100
|
+
sessions = /* @__PURE__ */ new Map();
|
|
101
|
+
create(input) {
|
|
102
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
103
|
+
const session = {
|
|
104
|
+
id: createSessionId(),
|
|
105
|
+
openCodeSessionId: input.openCodeSessionId ?? null,
|
|
106
|
+
paneId: input.paneId,
|
|
107
|
+
title: input.title,
|
|
108
|
+
command: input.command,
|
|
109
|
+
args: input.args ?? [],
|
|
110
|
+
cwd: input.cwd,
|
|
111
|
+
status: "running",
|
|
112
|
+
lineCount: 0,
|
|
113
|
+
createdAt: now,
|
|
114
|
+
updatedAt: now,
|
|
115
|
+
allowAgentInput: input.allowAgentInput,
|
|
116
|
+
humanInputOnly: input.humanInputOnly,
|
|
117
|
+
exitCode: null,
|
|
118
|
+
exitedAt: null,
|
|
119
|
+
exitCodeToken: input.exitCodeToken ?? null
|
|
120
|
+
};
|
|
121
|
+
this.sessions.set(session.id, session);
|
|
122
|
+
return session;
|
|
123
|
+
}
|
|
124
|
+
get(id) {
|
|
125
|
+
const session = this.sessions.get(id);
|
|
126
|
+
if (!session) throw new Error(`Unknown zellij PTY session: ${id}`);
|
|
127
|
+
return session;
|
|
128
|
+
}
|
|
129
|
+
list() {
|
|
130
|
+
return Array.from(this.sessions.values()).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
131
|
+
}
|
|
132
|
+
updateLineCount(id, lineCount) {
|
|
133
|
+
const session = this.get(id);
|
|
134
|
+
session.lineCount = lineCount;
|
|
135
|
+
session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
136
|
+
return session;
|
|
137
|
+
}
|
|
138
|
+
updateStatus(id, status) {
|
|
139
|
+
const session = this.get(id);
|
|
140
|
+
session.status = status;
|
|
141
|
+
session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
142
|
+
return session;
|
|
143
|
+
}
|
|
144
|
+
markExited(id, exitCode) {
|
|
145
|
+
const session = this.get(id);
|
|
146
|
+
session.status = "exited";
|
|
147
|
+
session.exitCode = exitCode;
|
|
148
|
+
session.exitedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
149
|
+
session.updatedAt = session.exitedAt;
|
|
150
|
+
return session;
|
|
151
|
+
}
|
|
152
|
+
listByOpenCodeSession(openCodeSessionId) {
|
|
153
|
+
return this.list().filter((session) => session.openCodeSessionId === openCodeSessionId);
|
|
154
|
+
}
|
|
155
|
+
remove(id) {
|
|
156
|
+
if (!this.sessions.delete(id)) throw new Error(`Unknown zellij PTY session: ${id}`);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
const sessionManager = new SessionManager();
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/zellij/cli.ts
|
|
162
|
+
const execFileAsync = promisify(execFile);
|
|
163
|
+
function zellijCommandArgs(actionArgs) {
|
|
164
|
+
const sessionName = process.env.ZELLIJ_SESSION_NAME?.trim();
|
|
165
|
+
if (sessionName) return [
|
|
166
|
+
"--session",
|
|
167
|
+
sessionName,
|
|
168
|
+
...actionArgs
|
|
169
|
+
];
|
|
170
|
+
return actionArgs;
|
|
171
|
+
}
|
|
172
|
+
function zellijActionArgs(action, args = []) {
|
|
173
|
+
return [
|
|
174
|
+
"action",
|
|
175
|
+
action,
|
|
176
|
+
...args
|
|
177
|
+
];
|
|
178
|
+
}
|
|
179
|
+
function buildNewPaneActionArgs(options) {
|
|
180
|
+
const args = ["action", "new-pane"];
|
|
181
|
+
if (process.env.ZELLIJ) args.push("--near-current-pane");
|
|
182
|
+
if (options.title) args.push("--name", options.title);
|
|
183
|
+
if (options.cwd) args.push("--cwd", options.cwd);
|
|
184
|
+
if (options.floating) args.push("--floating");
|
|
185
|
+
args.push("--", ...buildCommandArgv(options, { exitCodeToken: options.exitCodeToken }));
|
|
186
|
+
return args;
|
|
187
|
+
}
|
|
188
|
+
function ensureZellijTarget() {
|
|
189
|
+
if (process.env.ZELLIJ || process.env.ZELLIJ_SESSION_NAME) return;
|
|
190
|
+
throw new Error("Zellij context not found. Run OpenCode inside Zellij or set ZELLIJ_SESSION_NAME to an existing session.");
|
|
191
|
+
}
|
|
192
|
+
async function runZellij(actionArgs, options = {}) {
|
|
193
|
+
ensureZellijTarget();
|
|
194
|
+
try {
|
|
195
|
+
const result = await execFileAsync("zellij", zellijCommandArgs(actionArgs), {
|
|
196
|
+
encoding: "utf8",
|
|
197
|
+
timeout: options.timeoutMs ?? 1e4,
|
|
198
|
+
maxBuffer: 20 * 1024 * 1024
|
|
199
|
+
});
|
|
200
|
+
return {
|
|
201
|
+
stdout: result.stdout ?? "",
|
|
202
|
+
stderr: result.stderr ?? ""
|
|
203
|
+
};
|
|
204
|
+
} catch (cause) {
|
|
205
|
+
const error = cause;
|
|
206
|
+
const stderr = error.stderr?.trim();
|
|
207
|
+
const stdout = error.stdout?.trim();
|
|
208
|
+
const detail = stderr || stdout || error.message || "unknown error";
|
|
209
|
+
throw new Error(`zellij ${actionArgs.join(" ")} failed: ${detail}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
var ZellijCli = class {
|
|
213
|
+
async newPane(options) {
|
|
214
|
+
return parsePaneId((await runZellij(buildNewPaneActionArgs(options))).stdout);
|
|
215
|
+
}
|
|
216
|
+
async writeChars(paneId, data) {
|
|
217
|
+
await runZellij(zellijActionArgs("write-chars", [
|
|
218
|
+
"--pane-id",
|
|
219
|
+
paneId,
|
|
220
|
+
data
|
|
221
|
+
]));
|
|
222
|
+
}
|
|
223
|
+
async sendCtrlC(paneId) {
|
|
224
|
+
await runZellij(zellijActionArgs("send-keys", [
|
|
225
|
+
"--pane-id",
|
|
226
|
+
paneId,
|
|
227
|
+
"Ctrl c"
|
|
228
|
+
]));
|
|
229
|
+
}
|
|
230
|
+
async closePane(paneId) {
|
|
231
|
+
await runZellij(zellijActionArgs("close-pane", ["--pane-id", paneId]));
|
|
232
|
+
}
|
|
233
|
+
async focusPane(paneId) {
|
|
234
|
+
await runZellij(zellijActionArgs("focus-pane-id", [paneId]));
|
|
235
|
+
}
|
|
236
|
+
async dumpScreen(paneId) {
|
|
237
|
+
return (await runZellij(zellijActionArgs("dump-screen", [
|
|
238
|
+
"--pane-id",
|
|
239
|
+
paneId,
|
|
240
|
+
"--full"
|
|
241
|
+
]), { timeoutMs: 1e4 })).stdout;
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
const zellijCli = new ZellijCli();
|
|
245
|
+
//#endregion
|
|
246
|
+
//#region src/pty/ring-buffer.ts
|
|
247
|
+
const ansiPattern$1 = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[a-z]`, "gi");
|
|
248
|
+
function normalizeLines(input) {
|
|
249
|
+
const lines = Array.isArray(input) ? input : input.replace(/\r\n/g, "\n").split("\n");
|
|
250
|
+
if (lines.at(-1) === "") return lines.slice(0, -1);
|
|
251
|
+
return lines;
|
|
252
|
+
}
|
|
253
|
+
function stripAnsi(line) {
|
|
254
|
+
return line.replace(ansiPattern$1, "");
|
|
255
|
+
}
|
|
256
|
+
function overlapSize(existing, incoming) {
|
|
257
|
+
const max = Math.min(existing.length, incoming.length);
|
|
258
|
+
for (let size = max; size > 0; size -= 1) {
|
|
259
|
+
const existingStart = existing.length - size;
|
|
260
|
+
let matches = true;
|
|
261
|
+
for (let index = 0; index < size; index += 1) if (existing[existingStart + index] !== incoming[index]) {
|
|
262
|
+
matches = false;
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
if (matches) return size;
|
|
266
|
+
}
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
269
|
+
var RingBuffer = class {
|
|
270
|
+
maxLines;
|
|
271
|
+
lines = [];
|
|
272
|
+
totalAppended = 0;
|
|
273
|
+
constructor(maxLines = 5e4) {
|
|
274
|
+
this.maxLines = Math.max(1, maxLines);
|
|
275
|
+
}
|
|
276
|
+
get lineCount() {
|
|
277
|
+
return this.totalAppended;
|
|
278
|
+
}
|
|
279
|
+
get startOffset() {
|
|
280
|
+
return Math.max(0, this.totalAppended - this.lines.length);
|
|
281
|
+
}
|
|
282
|
+
append(input) {
|
|
283
|
+
const incoming = normalizeLines(input);
|
|
284
|
+
if (incoming.length === 0) return 0;
|
|
285
|
+
this.lines.push(...incoming);
|
|
286
|
+
this.totalAppended += incoming.length;
|
|
287
|
+
this.trim();
|
|
288
|
+
return incoming.length;
|
|
289
|
+
}
|
|
290
|
+
appendSnapshot(input) {
|
|
291
|
+
const incoming = normalizeLines(input);
|
|
292
|
+
if (incoming.length === 0) return 0;
|
|
293
|
+
const overlap = overlapSize(this.lines, incoming);
|
|
294
|
+
return this.append(incoming.slice(overlap));
|
|
295
|
+
}
|
|
296
|
+
read(input = {}) {
|
|
297
|
+
const limit = Math.max(1, Math.min(input.limit ?? 200, 5e3));
|
|
298
|
+
const firstReadableOffset = this.startOffset;
|
|
299
|
+
const defaultOffset = Math.max(firstReadableOffset, this.lineCount - limit);
|
|
300
|
+
const requestedOffset = input.offset ?? defaultOffset;
|
|
301
|
+
const offset = Math.max(firstReadableOffset, Math.min(requestedOffset, this.lineCount));
|
|
302
|
+
const relativeOffset = offset - firstReadableOffset;
|
|
303
|
+
const unfiltered = this.lines.slice(relativeOffset, relativeOffset + limit);
|
|
304
|
+
const pattern = input.grep ? new RegExp(input.grep, input.ignoreCase ? "i" : "") : void 0;
|
|
305
|
+
const lines = unfiltered.map(stripAnsi).filter((line) => pattern ? pattern.test(line) : true);
|
|
306
|
+
return {
|
|
307
|
+
offset,
|
|
308
|
+
returned: lines.length,
|
|
309
|
+
lineCount: this.lineCount,
|
|
310
|
+
lines
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
clear() {
|
|
314
|
+
this.lines = [];
|
|
315
|
+
this.totalAppended = 0;
|
|
316
|
+
}
|
|
317
|
+
trim() {
|
|
318
|
+
if (this.lines.length <= this.maxLines) return;
|
|
319
|
+
this.lines = this.lines.slice(this.lines.length - this.maxLines);
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
//#endregion
|
|
323
|
+
//#region src/utils/exit-code.ts
|
|
324
|
+
const markerPattern = /^\[zellij-pty:([a-f0-9]+)\] exit-code=(\d+)$/;
|
|
325
|
+
const ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[a-z]`, "gi");
|
|
326
|
+
function createExitCodeToken() {
|
|
327
|
+
return randomUUID().replaceAll("-", "");
|
|
328
|
+
}
|
|
329
|
+
function parseExitCodeMarker(line) {
|
|
330
|
+
const match = line.replace(ansiPattern, "").trim().match(markerPattern);
|
|
331
|
+
if (!match?.[1] || !match[2]) return null;
|
|
332
|
+
return {
|
|
333
|
+
token: match[1],
|
|
334
|
+
exitCode: Number(match[2])
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
//#endregion
|
|
338
|
+
//#region src/zellij/subscribe.ts
|
|
339
|
+
const maxStderrLines = 200;
|
|
340
|
+
function splitLines(input) {
|
|
341
|
+
const lines = input.replace(/\r\n/g, "\n").split("\n");
|
|
342
|
+
if (lines.at(-1) === "") return lines.slice(0, -1);
|
|
343
|
+
return lines;
|
|
344
|
+
}
|
|
345
|
+
function textFromCell(cell) {
|
|
346
|
+
if (typeof cell === "string") return cell;
|
|
347
|
+
if (!cell || typeof cell !== "object") return "";
|
|
348
|
+
const object = cell;
|
|
349
|
+
const value = object.text ?? object.character ?? object.ch ?? object.content;
|
|
350
|
+
return typeof value === "string" ? value : "";
|
|
351
|
+
}
|
|
352
|
+
function linesFromRows(rows) {
|
|
353
|
+
return rows.map((row) => {
|
|
354
|
+
if (typeof row === "string") return row;
|
|
355
|
+
if (Array.isArray(row)) return row.map(textFromCell).join("");
|
|
356
|
+
return textFromCell(row);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
function eventPaneId(event) {
|
|
360
|
+
const paneId = event.pane_id ?? event.paneId;
|
|
361
|
+
return typeof paneId === "string" ? paneId : void 0;
|
|
362
|
+
}
|
|
363
|
+
function eventType(event) {
|
|
364
|
+
const type = event.event ?? event.type;
|
|
365
|
+
return typeof type === "string" ? type : void 0;
|
|
366
|
+
}
|
|
367
|
+
function extractRenderedLines(event) {
|
|
368
|
+
for (const key of [
|
|
369
|
+
"viewport",
|
|
370
|
+
"scrollback",
|
|
371
|
+
"lines"
|
|
372
|
+
]) {
|
|
373
|
+
const value = event[key];
|
|
374
|
+
if (Array.isArray(value)) return linesFromRows(value);
|
|
375
|
+
}
|
|
376
|
+
for (const key of [
|
|
377
|
+
"text",
|
|
378
|
+
"output",
|
|
379
|
+
"content"
|
|
380
|
+
]) {
|
|
381
|
+
const value = event[key];
|
|
382
|
+
if (typeof value === "string") return splitLines(value);
|
|
383
|
+
}
|
|
384
|
+
return [];
|
|
385
|
+
}
|
|
386
|
+
var SubscriberManager = class {
|
|
387
|
+
subscribers = /* @__PURE__ */ new Map();
|
|
388
|
+
constructor(sessions, maxBufferLines = Number(process.env.PTY_MAX_BUFFER_LINES ?? 5e4)) {
|
|
389
|
+
this.sessions = sessions;
|
|
390
|
+
this.maxBufferLines = maxBufferLines;
|
|
391
|
+
}
|
|
392
|
+
async start(session) {
|
|
393
|
+
const existing = this.subscribers.get(session.id);
|
|
394
|
+
if (existing?.child) return;
|
|
395
|
+
ensureZellijTarget();
|
|
396
|
+
const state = existing ?? {
|
|
397
|
+
child: null,
|
|
398
|
+
buffer: new RingBuffer(this.maxBufferLines),
|
|
399
|
+
stderr: [],
|
|
400
|
+
stdoutRemainder: "",
|
|
401
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
402
|
+
lastExitedAt: null
|
|
403
|
+
};
|
|
404
|
+
if (!existing) {
|
|
405
|
+
try {
|
|
406
|
+
state.buffer.appendSnapshot(await zellijCli.dumpScreen(session.paneId));
|
|
407
|
+
this.sessions.updateLineCount(session.id, state.buffer.lineCount);
|
|
408
|
+
} catch {}
|
|
409
|
+
this.subscribers.set(session.id, state);
|
|
410
|
+
}
|
|
411
|
+
const child = spawn("zellij", zellijCommandArgs([
|
|
412
|
+
"subscribe",
|
|
413
|
+
"--pane-id",
|
|
414
|
+
session.paneId,
|
|
415
|
+
"--scrollback",
|
|
416
|
+
"--format",
|
|
417
|
+
"json",
|
|
418
|
+
"--ansi"
|
|
419
|
+
]), { stdio: [
|
|
420
|
+
"pipe",
|
|
421
|
+
"pipe",
|
|
422
|
+
"pipe"
|
|
423
|
+
] });
|
|
424
|
+
child.stdin.end();
|
|
425
|
+
state.child = child;
|
|
426
|
+
state.lastExitedAt = null;
|
|
427
|
+
child.stdout.setEncoding("utf8");
|
|
428
|
+
child.stdout.on("data", (chunk) => this.handleStdout(session.id, chunk));
|
|
429
|
+
child.stderr.setEncoding("utf8");
|
|
430
|
+
child.stderr.on("data", (chunk) => this.handleStderr(session.id, chunk));
|
|
431
|
+
child.on("exit", () => this.handleSubscriberExit(session.id));
|
|
432
|
+
child.on("error", (error) => this.handleSubscriberError(session.id, error));
|
|
433
|
+
}
|
|
434
|
+
read(sessionId, input) {
|
|
435
|
+
const state = this.subscribers.get(sessionId);
|
|
436
|
+
if (!state) throw new Error(`No subscriber buffer exists for session: ${sessionId}`);
|
|
437
|
+
return state.buffer.read(input);
|
|
438
|
+
}
|
|
439
|
+
has(sessionId) {
|
|
440
|
+
return this.subscribers.has(sessionId);
|
|
441
|
+
}
|
|
442
|
+
status(sessionId) {
|
|
443
|
+
const state = this.subscribers.get(sessionId);
|
|
444
|
+
return {
|
|
445
|
+
hasBuffer: Boolean(state),
|
|
446
|
+
active: Boolean(state?.child),
|
|
447
|
+
lastExitedAt: state?.lastExitedAt ?? null
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
stderr(sessionId) {
|
|
451
|
+
return this.subscribers.get(sessionId)?.stderr ?? [];
|
|
452
|
+
}
|
|
453
|
+
stop(sessionId) {
|
|
454
|
+
const state = this.subscribers.get(sessionId);
|
|
455
|
+
if (!state) return;
|
|
456
|
+
state.child?.kill("SIGTERM");
|
|
457
|
+
state.child = null;
|
|
458
|
+
state.lastExitedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
459
|
+
}
|
|
460
|
+
forget(sessionId) {
|
|
461
|
+
this.stop(sessionId);
|
|
462
|
+
this.subscribers.delete(sessionId);
|
|
463
|
+
}
|
|
464
|
+
stopAll() {
|
|
465
|
+
for (const sessionId of this.subscribers.keys()) this.forget(sessionId);
|
|
466
|
+
}
|
|
467
|
+
async closeSessionPane(sessionId) {
|
|
468
|
+
const session = this.sessions.get(sessionId);
|
|
469
|
+
this.stop(sessionId);
|
|
470
|
+
try {
|
|
471
|
+
await zellijCli.closePane(session.paneId);
|
|
472
|
+
} catch {}
|
|
473
|
+
}
|
|
474
|
+
handleStdout(sessionId, chunk) {
|
|
475
|
+
const state = this.subscribers.get(sessionId);
|
|
476
|
+
if (!state) return;
|
|
477
|
+
const parts = `${state.stdoutRemainder}${chunk}`.split("\n");
|
|
478
|
+
state.stdoutRemainder = parts.pop() ?? "";
|
|
479
|
+
for (const part of parts) this.handleJsonLine(sessionId, part);
|
|
480
|
+
}
|
|
481
|
+
handleJsonLine(sessionId, line) {
|
|
482
|
+
const state = this.subscribers.get(sessionId);
|
|
483
|
+
if (!state) return;
|
|
484
|
+
const trimmed = line.trim();
|
|
485
|
+
if (!trimmed) return;
|
|
486
|
+
let event;
|
|
487
|
+
try {
|
|
488
|
+
const parsed = JSON.parse(trimmed);
|
|
489
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
490
|
+
event = parsed;
|
|
491
|
+
} catch {
|
|
492
|
+
state.buffer.append(trimmed);
|
|
493
|
+
this.sessions.updateLineCount(sessionId, state.buffer.lineCount);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const session = this.sessions.get(sessionId);
|
|
497
|
+
const paneId = eventPaneId(event);
|
|
498
|
+
if (paneId && paneId !== session.paneId) return;
|
|
499
|
+
const type = eventType(event);
|
|
500
|
+
if (type === "pane_closed" || type === "PaneClosed") {
|
|
501
|
+
state.buffer.append(`[zellij-pty] Pane ${session.paneId} closed at ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
502
|
+
this.sessions.updateLineCount(sessionId, state.buffer.lineCount);
|
|
503
|
+
this.sessions.updateStatus(sessionId, session.status === "killed" ? "killed" : "exited");
|
|
504
|
+
this.stop(sessionId);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const lines = extractRenderedLines(event);
|
|
508
|
+
if (lines.length === 0) return;
|
|
509
|
+
state.buffer.appendSnapshot(lines);
|
|
510
|
+
this.captureExitCode(sessionId, lines);
|
|
511
|
+
this.sessions.updateLineCount(sessionId, state.buffer.lineCount);
|
|
512
|
+
}
|
|
513
|
+
captureExitCode(sessionId, lines) {
|
|
514
|
+
const session = this.sessions.get(sessionId);
|
|
515
|
+
if (!session.exitCodeToken) return;
|
|
516
|
+
for (const line of lines) {
|
|
517
|
+
const marker = parseExitCodeMarker(line);
|
|
518
|
+
if (!marker || marker.token !== session.exitCodeToken) continue;
|
|
519
|
+
this.sessions.markExited(sessionId, marker.exitCode);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
handleStderr(sessionId, chunk) {
|
|
524
|
+
const state = this.subscribers.get(sessionId);
|
|
525
|
+
if (!state) return;
|
|
526
|
+
state.stderr.push(...splitLines(chunk));
|
|
527
|
+
if (state.stderr.length > maxStderrLines) state.stderr = state.stderr.slice(state.stderr.length - maxStderrLines);
|
|
528
|
+
}
|
|
529
|
+
handleSubscriberExit(sessionId) {
|
|
530
|
+
const state = this.subscribers.get(sessionId);
|
|
531
|
+
if (!state) return;
|
|
532
|
+
state.child = null;
|
|
533
|
+
state.lastExitedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
534
|
+
state.stderr.push(`[zellij-pty] subscriber exited at ${state.lastExitedAt}; last buffered output is retained.`);
|
|
535
|
+
if (state.stderr.length > maxStderrLines) state.stderr = state.stderr.slice(state.stderr.length - maxStderrLines);
|
|
536
|
+
}
|
|
537
|
+
handleSubscriberError(sessionId, error) {
|
|
538
|
+
const state = this.subscribers.get(sessionId);
|
|
539
|
+
if (state) state.stderr.push(error.message);
|
|
540
|
+
this.sessions.updateStatus(sessionId, "unknown");
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
const subscriberManager = new SubscriberManager(sessionManager);
|
|
544
|
+
//#endregion
|
|
545
|
+
//#region src/tools/format.ts
|
|
546
|
+
function publicSession(session) {
|
|
547
|
+
return {
|
|
548
|
+
id: session.id,
|
|
549
|
+
paneId: session.paneId,
|
|
550
|
+
title: session.title,
|
|
551
|
+
command: session.command,
|
|
552
|
+
args: session.args,
|
|
553
|
+
cwd: session.cwd,
|
|
554
|
+
status: session.status,
|
|
555
|
+
lineCount: session.lineCount,
|
|
556
|
+
createdAt: session.createdAt,
|
|
557
|
+
updatedAt: session.updatedAt,
|
|
558
|
+
agentWritable: session.allowAgentInput,
|
|
559
|
+
humanInputOnly: session.humanInputOnly,
|
|
560
|
+
exitCode: session.exitCode,
|
|
561
|
+
exitedAt: session.exitedAt
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
function nextAdvice(retryable, reason) {
|
|
565
|
+
return {
|
|
566
|
+
retryable,
|
|
567
|
+
reason
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
function jsonResponse(value) {
|
|
571
|
+
return JSON.stringify(value, null, 2);
|
|
572
|
+
}
|
|
573
|
+
//#endregion
|
|
574
|
+
//#region src/tools/output.ts
|
|
575
|
+
function emptyOutputSnapshot(lineCount = 0) {
|
|
576
|
+
return {
|
|
577
|
+
text: "",
|
|
578
|
+
lines: [],
|
|
579
|
+
lineCount,
|
|
580
|
+
returned: 0,
|
|
581
|
+
truncated: false
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
function validateGrep(grep) {
|
|
585
|
+
if (!grep) return null;
|
|
586
|
+
try {
|
|
587
|
+
new RegExp(grep).test("");
|
|
588
|
+
return null;
|
|
589
|
+
} catch (error) {
|
|
590
|
+
return error instanceof Error ? error.message : String(error);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
function readOutputSnapshot(sessionId, options = {}) {
|
|
594
|
+
const grepError = validateGrep(options.grep);
|
|
595
|
+
if (grepError) throw new Error(`Invalid grep regex: ${grepError}`);
|
|
596
|
+
const buffered = subscriberManager.read(sessionId, {
|
|
597
|
+
limit: options.maxLines,
|
|
598
|
+
grep: options.grep,
|
|
599
|
+
ignoreCase: options.ignoreCase
|
|
600
|
+
});
|
|
601
|
+
sessionManager.updateLineCount(sessionId, buffered.lineCount);
|
|
602
|
+
return {
|
|
603
|
+
text: buffered.lines.join("\n"),
|
|
604
|
+
lines: buffered.lines,
|
|
605
|
+
lineCount: buffered.lineCount,
|
|
606
|
+
returned: buffered.returned,
|
|
607
|
+
truncated: buffered.offset > 0
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
function outputMatches(sessionId, grep, ignoreCase) {
|
|
611
|
+
return readOutputSnapshot(sessionId, {
|
|
612
|
+
maxLines: 5e3,
|
|
613
|
+
grep,
|
|
614
|
+
ignoreCase
|
|
615
|
+
}).returned > 0;
|
|
616
|
+
}
|
|
617
|
+
//#endregion
|
|
618
|
+
//#region src/tools/kill.ts
|
|
619
|
+
const schema$4 = tool.schema;
|
|
620
|
+
function closeFailureMeansGone(message) {
|
|
621
|
+
return /not found|no such|does not exist|already closed|already gone|unknown pane/i.test(message);
|
|
622
|
+
}
|
|
623
|
+
const zellijPtyKillTool = tool({
|
|
624
|
+
description: "Terminate a known Zellij PTY session by sending Ctrl-C, then closing its pane.",
|
|
625
|
+
args: { id: schema$4.string().describe("zellij-pty session id.") },
|
|
626
|
+
async execute(args) {
|
|
627
|
+
const session = sessionManager.get(args.id);
|
|
628
|
+
const warnings = [];
|
|
629
|
+
const output = subscriberManager.has(session.id) ? readOutputSnapshot(session.id) : void 0;
|
|
630
|
+
try {
|
|
631
|
+
await zellijCli.sendCtrlC(session.paneId);
|
|
632
|
+
await setTimeout(500);
|
|
633
|
+
} catch (error) {
|
|
634
|
+
warnings.push(`Ctrl-C failed or pane was already gone: ${error instanceof Error ? error.message : String(error)}`);
|
|
635
|
+
}
|
|
636
|
+
try {
|
|
637
|
+
await zellijCli.closePane(session.paneId);
|
|
638
|
+
} catch (error) {
|
|
639
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
640
|
+
warnings.push(`close-pane failed: ${message}`);
|
|
641
|
+
if (!closeFailureMeansGone(message)) return jsonResponse({
|
|
642
|
+
killed: false,
|
|
643
|
+
cleanedUp: false,
|
|
644
|
+
session: publicSession(sessionManager.updateStatus(session.id, "unknown")),
|
|
645
|
+
output,
|
|
646
|
+
next: nextAdvice(true, "close-pane failed and the pane may still be running; the session was kept so kill can be retried."),
|
|
647
|
+
warnings
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
subscriberManager.stop(session.id);
|
|
651
|
+
subscriberManager.forget(session.id);
|
|
652
|
+
sessionManager.remove(session.id);
|
|
653
|
+
return jsonResponse({
|
|
654
|
+
killed: true,
|
|
655
|
+
cleanedUp: true,
|
|
656
|
+
id: session.id,
|
|
657
|
+
paneId: session.paneId,
|
|
658
|
+
output,
|
|
659
|
+
next: nextAdvice(false, "Session was closed and removed from the in-memory registry."),
|
|
660
|
+
warnings
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
//#endregion
|
|
665
|
+
//#region src/tools/list.ts
|
|
666
|
+
const zellijPtyListTool = tool({
|
|
667
|
+
description: "List known Zellij pane-backed PTY sessions created by this plugin process for the current OpenCode session.",
|
|
668
|
+
args: {},
|
|
669
|
+
async execute(_args, context) {
|
|
670
|
+
return jsonResponse({ sessions: sessionManager.listByOpenCodeSession(context.sessionID).map((session) => ({
|
|
671
|
+
...publicSession(session),
|
|
672
|
+
subscriber: subscriberManager.status(session.id)
|
|
673
|
+
})) });
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
//#endregion
|
|
677
|
+
//#region src/tools/read.ts
|
|
678
|
+
const schema$3 = tool.schema;
|
|
679
|
+
const zellijPtyReadTool = tool({
|
|
680
|
+
description: "Read recent rendered output from a Zellij PTY session. Supports regex grep filtering.",
|
|
681
|
+
args: {
|
|
682
|
+
id: schema$3.string().describe("zellij-pty session id."),
|
|
683
|
+
maxLines: schema$3.number().int().positive().max(5e3).optional().describe("Maximum recent output lines to return. Defaults to 200."),
|
|
684
|
+
grep: schema$3.string().optional().describe("Regex used to filter returned lines."),
|
|
685
|
+
ignoreCase: schema$3.boolean().optional().describe("Use case-insensitive regex matching.")
|
|
686
|
+
},
|
|
687
|
+
async execute(args) {
|
|
688
|
+
const session = sessionManager.get(args.id);
|
|
689
|
+
const grepError = validateGrep(args.grep);
|
|
690
|
+
if (grepError) return jsonResponse({
|
|
691
|
+
session: publicSession(session),
|
|
692
|
+
output: {
|
|
693
|
+
text: "",
|
|
694
|
+
lines: [],
|
|
695
|
+
lineCount: session.lineCount,
|
|
696
|
+
returned: 0,
|
|
697
|
+
truncated: false
|
|
698
|
+
},
|
|
699
|
+
next: nextAdvice(false, `Invalid grep regex: ${grepError}`),
|
|
700
|
+
warnings: []
|
|
701
|
+
});
|
|
702
|
+
if (!subscriberManager.has(session.id)) await subscriberManager.start(session);
|
|
703
|
+
const subscriberStatus = subscriberManager.status(session.id);
|
|
704
|
+
const warnings = [];
|
|
705
|
+
if (session.humanInputOnly) warnings.push("This pane is human-input-only: agent writes are forbidden, but rendered output is visible to the agent.");
|
|
706
|
+
if (!subscriberStatus.active) {
|
|
707
|
+
warnings.push("Subscriber is inactive; returned output may be stale.");
|
|
708
|
+
if (session.status === "running") sessionManager.updateStatus(session.id, "unknown");
|
|
709
|
+
}
|
|
710
|
+
const output = readOutputSnapshot(session.id, {
|
|
711
|
+
maxLines: args.maxLines,
|
|
712
|
+
grep: args.grep,
|
|
713
|
+
ignoreCase: args.ignoreCase
|
|
714
|
+
});
|
|
715
|
+
return jsonResponse({
|
|
716
|
+
session: publicSession(session),
|
|
717
|
+
output,
|
|
718
|
+
next: nextAdvice(session.status !== "exited" && session.status !== "killed", nextReadReason(session.status)),
|
|
719
|
+
subscriberActive: subscriberStatus.active,
|
|
720
|
+
subscriberLastExitedAt: subscriberStatus.lastExitedAt,
|
|
721
|
+
subscriberErrors: subscriberManager.stderr(session.id),
|
|
722
|
+
warnings
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
function nextReadReason(status) {
|
|
727
|
+
if (status === "running") return "Session is still running; read again later if more output is expected.";
|
|
728
|
+
if (status === "unknown") return "Session state is unknown because the subscriber is inactive; output may be stale, but retrying read may restart observation.";
|
|
729
|
+
return "Session is no longer running.";
|
|
730
|
+
}
|
|
731
|
+
//#endregion
|
|
732
|
+
//#region src/utils/pane-title.ts
|
|
733
|
+
const generatedInstanceId = randomUUID().replaceAll("-", "").slice(0, 8);
|
|
734
|
+
const existingOpenCodePrefixPattern = /^oc:[a-z0-9]{4,16}:/i;
|
|
735
|
+
function createOpenCodePaneTitle(title, instanceId = generatedInstanceId) {
|
|
736
|
+
const trimmedTitle = title.trim() || "opencode";
|
|
737
|
+
if (existingOpenCodePrefixPattern.test(trimmedTitle)) return trimmedTitle;
|
|
738
|
+
return `oc:${instanceId.replace(/[^a-z0-9]/gi, "").slice(0, 8) || generatedInstanceId}:${trimmedTitle}`;
|
|
739
|
+
}
|
|
740
|
+
//#endregion
|
|
741
|
+
//#region src/tools/request-sudo.ts
|
|
742
|
+
const schema$2 = tool.schema;
|
|
743
|
+
function shellQuote(value) {
|
|
744
|
+
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
745
|
+
}
|
|
746
|
+
function buildReviewScript(summary, scripts) {
|
|
747
|
+
const lines = [
|
|
748
|
+
"set +e",
|
|
749
|
+
"printf '%s\\n' '=== OpenCode sudo request ==='",
|
|
750
|
+
`printf '%s\\n' ${shellQuote(summary)}`,
|
|
751
|
+
"printf '\\n%s\\n' 'Commands to review:'"
|
|
752
|
+
];
|
|
753
|
+
scripts.forEach((script, index) => {
|
|
754
|
+
const number = index + 1;
|
|
755
|
+
lines.push(`printf '\\n[%s/%s] %s\\n' ${shellQuote(String(number))} ${shellQuote(String(scripts.length))} ${shellQuote(script.description)}`);
|
|
756
|
+
lines.push(`printf ' $ %s\\n' ${shellQuote(script.command)}`);
|
|
757
|
+
});
|
|
758
|
+
lines.push("printf '\\n%s\\n' 'This pane is human-input-only. The agent cannot type here.'", "read -r -p 'Type YES to run these commands, anything else to cancel: ' answer", "if [ \"$answer\" != YES ]; then printf '%s\\n' 'Cancelled by user.'; exit 130; fi", "status=0");
|
|
759
|
+
scripts.forEach((script, index) => {
|
|
760
|
+
const number = index + 1;
|
|
761
|
+
lines.push(`printf '\\n[%s/%s] %s\\n' ${shellQuote(String(number))} ${shellQuote(String(scripts.length))} ${shellQuote(script.description)}`);
|
|
762
|
+
lines.push(`printf '$ %s\\n' ${shellQuote(script.command)}`);
|
|
763
|
+
lines.push(`bash -lc ${shellQuote(script.command)}`);
|
|
764
|
+
lines.push("code=$?");
|
|
765
|
+
lines.push("if [ $code -ne 0 ]; then status=$code; printf 'Command failed with exit code %s\\n' \"$code\"; break; fi");
|
|
766
|
+
});
|
|
767
|
+
lines.push("exit $status");
|
|
768
|
+
return lines.join("\n");
|
|
769
|
+
}
|
|
770
|
+
const requestSudoTool = tool({
|
|
771
|
+
description: "Open a human-reviewed, human-input-only Zellij pane for sudo or other privileged commands.",
|
|
772
|
+
args: {
|
|
773
|
+
summary: schema$2.string().min(1).describe("TL;DR of why privileged or human-reviewed execution is needed."),
|
|
774
|
+
scripts: schema$2.array(schema$2.object({
|
|
775
|
+
command: schema$2.string().min(1).describe("Command or script to run after the user explicitly approves in the pane."),
|
|
776
|
+
description: schema$2.string().min(1).describe("Why this command is needed and what it is expected to change.")
|
|
777
|
+
})).min(1).describe("Commands shown to the user for review before execution.")
|
|
778
|
+
},
|
|
779
|
+
async execute(args, context) {
|
|
780
|
+
const cwd = context.directory;
|
|
781
|
+
const exitCodeToken = createExitCodeToken();
|
|
782
|
+
for (const script of args.scripts) assertCommandAllowed({
|
|
783
|
+
command: script.command,
|
|
784
|
+
humanInputOnly: true
|
|
785
|
+
});
|
|
786
|
+
const command = buildReviewScript(args.summary, args.scripts);
|
|
787
|
+
const title = createOpenCodePaneTitle("request_sudo");
|
|
788
|
+
const paneId = await zellijCli.newPane({
|
|
789
|
+
command: "bash",
|
|
790
|
+
args: ["-lc", command],
|
|
791
|
+
cwd,
|
|
792
|
+
title,
|
|
793
|
+
floating: true,
|
|
794
|
+
exitCodeToken
|
|
795
|
+
});
|
|
796
|
+
const warnings = [];
|
|
797
|
+
try {
|
|
798
|
+
await zellijCli.focusPane(paneId);
|
|
799
|
+
} catch (error) {
|
|
800
|
+
if (!(error instanceof Error ? error.message : String(error)).includes("already focused")) throw error;
|
|
801
|
+
warnings.push("Pane was already focused after creation.");
|
|
802
|
+
}
|
|
803
|
+
const session = sessionManager.create({
|
|
804
|
+
openCodeSessionId: context.sessionID,
|
|
805
|
+
paneId,
|
|
806
|
+
title,
|
|
807
|
+
command: "request_sudo",
|
|
808
|
+
args: [],
|
|
809
|
+
cwd,
|
|
810
|
+
allowAgentInput: false,
|
|
811
|
+
humanInputOnly: true,
|
|
812
|
+
exitCodeToken
|
|
813
|
+
});
|
|
814
|
+
await subscriberManager.start(session);
|
|
815
|
+
return jsonResponse({
|
|
816
|
+
session: publicSession(session),
|
|
817
|
+
output: readOutputSnapshot(session.id),
|
|
818
|
+
next: nextAdvice(false, "The user must review the summary and commands in Zellij, then type YES and any required credentials directly in the pane."),
|
|
819
|
+
warnings
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
//#endregion
|
|
824
|
+
//#region src/pty/probe.ts
|
|
825
|
+
const defaultSleepSeconds = 1;
|
|
826
|
+
const defaultProbeTimeoutSeconds = 20;
|
|
827
|
+
const pollIntervalMs = 250;
|
|
828
|
+
async function runProbe(probe, outputReader) {
|
|
829
|
+
const startedAt = Date.now();
|
|
830
|
+
const effectiveProbe = probe ?? {
|
|
831
|
+
type: "sleep",
|
|
832
|
+
seconds: defaultSleepSeconds
|
|
833
|
+
};
|
|
834
|
+
if (effectiveProbe.type === "sleep") {
|
|
835
|
+
const seconds = effectiveProbe.seconds ?? defaultSleepSeconds;
|
|
836
|
+
await setTimeout(seconds * 1e3);
|
|
837
|
+
return result(effectiveProbe.type, true, `Slept for ${seconds}s.`, startedAt);
|
|
838
|
+
}
|
|
839
|
+
if (effectiveProbe.type === "output") {
|
|
840
|
+
const timeoutSeconds = effectiveProbe.timeoutSeconds ?? defaultProbeTimeoutSeconds;
|
|
841
|
+
const deadline = Date.now() + timeoutSeconds * 1e3;
|
|
842
|
+
while (Date.now() <= deadline) {
|
|
843
|
+
if (outputReader(effectiveProbe.grep, effectiveProbe.ignoreCase)) return result(effectiveProbe.type, true, `Observed output matching /${effectiveProbe.grep}/.`, startedAt);
|
|
844
|
+
await setTimeout(pollIntervalMs);
|
|
845
|
+
}
|
|
846
|
+
return result(effectiveProbe.type, false, `Timed out after ${timeoutSeconds}s waiting for output matching /${effectiveProbe.grep}/.`, startedAt);
|
|
847
|
+
}
|
|
848
|
+
const timeoutSeconds = effectiveProbe.timeoutSeconds ?? defaultProbeTimeoutSeconds;
|
|
849
|
+
const deadline = Date.now() + timeoutSeconds * 1e3;
|
|
850
|
+
const expectStatus = effectiveProbe.expectStatus;
|
|
851
|
+
let lastError = "no response";
|
|
852
|
+
while (Date.now() <= deadline) {
|
|
853
|
+
try {
|
|
854
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
855
|
+
const response = await fetch(effectiveProbe.url, { signal: AbortSignal.timeout(Math.min(remainingMs, 3e3)) });
|
|
856
|
+
if (expectStatus === void 0 ? response.status >= 200 && response.status < 400 : response.status === expectStatus) {
|
|
857
|
+
const expected = expectStatus === void 0 ? "2xx/3xx" : String(expectStatus);
|
|
858
|
+
return result(effectiveProbe.type, true, `HTTP probe ${effectiveProbe.url} returned expected status ${expected}.`, startedAt);
|
|
859
|
+
}
|
|
860
|
+
lastError = `HTTP ${response.status}`;
|
|
861
|
+
} catch (error) {
|
|
862
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
863
|
+
}
|
|
864
|
+
await setTimeout(pollIntervalMs);
|
|
865
|
+
}
|
|
866
|
+
return result(effectiveProbe.type, false, `Timed out after ${timeoutSeconds}s probing ${effectiveProbe.url}: ${lastError}.`, startedAt);
|
|
867
|
+
}
|
|
868
|
+
function result(type, ok, message, startedAt) {
|
|
869
|
+
return {
|
|
870
|
+
type,
|
|
871
|
+
ok,
|
|
872
|
+
message,
|
|
873
|
+
elapsedMs: Date.now() - startedAt
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
//#endregion
|
|
877
|
+
//#region src/tools/spawn.ts
|
|
878
|
+
const schema$1 = tool.schema;
|
|
879
|
+
const probeSchema = schema$1.discriminatedUnion("type", [
|
|
880
|
+
schema$1.object({
|
|
881
|
+
type: schema$1.literal("sleep"),
|
|
882
|
+
seconds: schema$1.number().positive().max(300).optional().describe("Seconds to wait before returning initial output. Defaults to 1.")
|
|
883
|
+
}),
|
|
884
|
+
schema$1.object({
|
|
885
|
+
type: schema$1.literal("http"),
|
|
886
|
+
url: schema$1.string().url().describe("HTTP URL to poll until it returns the expected status."),
|
|
887
|
+
expectStatus: schema$1.number().int().min(100).max(599).optional().describe("Expected HTTP status. Defaults to any 2xx/3xx response."),
|
|
888
|
+
timeoutSeconds: schema$1.number().positive().max(300).optional().describe("How long to poll before returning a failed probe result. Defaults to 20.")
|
|
889
|
+
}),
|
|
890
|
+
schema$1.object({
|
|
891
|
+
type: schema$1.literal("output"),
|
|
892
|
+
grep: schema$1.string().describe("Regex to search for in observed pane output."),
|
|
893
|
+
ignoreCase: schema$1.boolean().optional().describe("Use case-insensitive regex matching."),
|
|
894
|
+
timeoutSeconds: schema$1.number().positive().max(300).optional().describe("How long to wait for matching output. Defaults to 20.")
|
|
895
|
+
})
|
|
896
|
+
]);
|
|
897
|
+
const zellijPtySpawnTool = tool({
|
|
898
|
+
description: "Create a visible Zellij pane and run a command in it.",
|
|
899
|
+
args: {
|
|
900
|
+
command: schema$1.string().describe("Command to run. Without args, it is executed through bash -lc."),
|
|
901
|
+
args: schema$1.array(schema$1.string()).optional().describe("Optional argv. When provided, command is executed directly without shell parsing."),
|
|
902
|
+
cwd: schema$1.string().optional().describe("Working directory for the new pane."),
|
|
903
|
+
title: schema$1.string().optional().describe("Pane title/name."),
|
|
904
|
+
probe: probeSchema.optional().describe("Optional readiness probe. Defaults to a short sleep before returning output."),
|
|
905
|
+
maxLines: schema$1.number().int().positive().max(5e3).optional().describe("Maximum recent output lines to return. Defaults to 200.")
|
|
906
|
+
},
|
|
907
|
+
async execute(args, context) {
|
|
908
|
+
const cwd = args.cwd ?? context.directory;
|
|
909
|
+
const exitCodeToken = createExitCodeToken();
|
|
910
|
+
assertCommandAllowed({
|
|
911
|
+
command: args.command,
|
|
912
|
+
args: args.args,
|
|
913
|
+
humanInputOnly: false
|
|
914
|
+
});
|
|
915
|
+
const grepError = args.probe?.type === "output" ? validateGrep(args.probe.grep) : null;
|
|
916
|
+
if (grepError) throw new Error(`Invalid probe.grep regex: ${grepError}`);
|
|
917
|
+
const title = createOpenCodePaneTitle(args.title ?? args.command);
|
|
918
|
+
const paneId = await zellijCli.newPane({
|
|
919
|
+
command: args.command,
|
|
920
|
+
args: args.args,
|
|
921
|
+
cwd,
|
|
922
|
+
title,
|
|
923
|
+
floating: false,
|
|
924
|
+
exitCodeToken
|
|
925
|
+
});
|
|
926
|
+
const session = sessionManager.create({
|
|
927
|
+
openCodeSessionId: context.sessionID,
|
|
928
|
+
paneId,
|
|
929
|
+
title,
|
|
930
|
+
command: args.command,
|
|
931
|
+
args: args.args,
|
|
932
|
+
cwd,
|
|
933
|
+
allowAgentInput: true,
|
|
934
|
+
humanInputOnly: false,
|
|
935
|
+
exitCodeToken
|
|
936
|
+
});
|
|
937
|
+
await subscriberManager.start(session);
|
|
938
|
+
const probe = await runProbe(args.probe, (grep, ignoreCase) => outputMatches(session.id, grep, ignoreCase));
|
|
939
|
+
const output = readOutputSnapshot(session.id, { maxLines: args.maxLines });
|
|
940
|
+
return jsonResponse({
|
|
941
|
+
session: publicSession(session),
|
|
942
|
+
output,
|
|
943
|
+
probe,
|
|
944
|
+
next: nextAdvice(probe.ok, probe.ok ? "Probe completed; continue with this session or read later for long-running output." : probe.message),
|
|
945
|
+
warnings: ["Registry remains in-memory; restarting OpenCode loses plugin session records."]
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
//#endregion
|
|
950
|
+
//#region src/pty/write-data.ts
|
|
951
|
+
const defaultMaxWriteBytes = 64 * 1024;
|
|
952
|
+
const defaultChunkBytes = 8 * 1024;
|
|
953
|
+
function maxWriteBytes() {
|
|
954
|
+
const configured = Number(process.env.ZELLIJ_PTY_MAX_WRITE_BYTES ?? defaultMaxWriteBytes);
|
|
955
|
+
return Number.isFinite(configured) && configured > 0 ? configured : defaultMaxWriteBytes;
|
|
956
|
+
}
|
|
957
|
+
function assertWriteSizeAllowed(data) {
|
|
958
|
+
const bytes = Buffer.byteLength(data, "utf8");
|
|
959
|
+
const maxBytes = maxWriteBytes();
|
|
960
|
+
if (bytes > maxBytes) throw new Error(`Write payload is too large: ${bytes} bytes exceeds ${maxBytes} bytes. Split the input into smaller writes.`);
|
|
961
|
+
}
|
|
962
|
+
function chunkWriteData(data, maxChunkBytes = defaultChunkBytes) {
|
|
963
|
+
const chunks = [];
|
|
964
|
+
let current = "";
|
|
965
|
+
let currentBytes = 0;
|
|
966
|
+
for (const character of data) {
|
|
967
|
+
const characterBytes = Buffer.byteLength(character, "utf8");
|
|
968
|
+
if (current && currentBytes + characterBytes > maxChunkBytes) {
|
|
969
|
+
chunks.push(current);
|
|
970
|
+
current = "";
|
|
971
|
+
currentBytes = 0;
|
|
972
|
+
}
|
|
973
|
+
current += character;
|
|
974
|
+
currentBytes += characterBytes;
|
|
975
|
+
}
|
|
976
|
+
if (current) chunks.push(current);
|
|
977
|
+
return chunks;
|
|
978
|
+
}
|
|
979
|
+
//#endregion
|
|
980
|
+
//#region src/tools/write.ts
|
|
981
|
+
const schema = tool.schema;
|
|
982
|
+
const zellijPtyWriteTool = tool({
|
|
983
|
+
description: "Write stdin to a Zellij PTY session. Refuses human-input-only sessions.",
|
|
984
|
+
args: {
|
|
985
|
+
id: schema.string().describe("zellij-pty session id returned by zellij_pty_spawn or request_sudo."),
|
|
986
|
+
data: schema.string().describe("Text to write. Use to send Ctrl-C."),
|
|
987
|
+
maxLines: schema.number().int().positive().max(5e3).optional().describe("Maximum recent output lines to return. Defaults to 200."),
|
|
988
|
+
interruptAfterSeconds: schema.number().positive().max(300).optional().describe("Blindly send Ctrl-C after this many seconds if the pane is still running; keeps the pane alive.")
|
|
989
|
+
},
|
|
990
|
+
async execute(args) {
|
|
991
|
+
const session = sessionManager.get(args.id);
|
|
992
|
+
if (session.humanInputOnly || !session.allowAgentInput) return jsonResponse({
|
|
993
|
+
session: publicSession(session),
|
|
994
|
+
output: subscriberManager.has(session.id) ? readOutputSnapshot(session.id, { maxLines: args.maxLines }) : emptyOutputSnapshot(session.lineCount),
|
|
995
|
+
next: nextAdvice(false, "This session is human-input-only; the user must type directly in the Zellij pane."),
|
|
996
|
+
warnings: ["Agent writes to human-input-only sessions are forbidden."]
|
|
997
|
+
});
|
|
998
|
+
if (args.data === "" || args.data === "") await zellijCli.sendCtrlC(session.paneId);
|
|
999
|
+
else {
|
|
1000
|
+
assertWriteSizeAllowed(args.data);
|
|
1001
|
+
for (const chunk of chunkWriteData(args.data)) await zellijCli.writeChars(session.paneId, chunk);
|
|
1002
|
+
}
|
|
1003
|
+
session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1004
|
+
if (args.interruptAfterSeconds) {
|
|
1005
|
+
await setTimeout(args.interruptAfterSeconds * 1e3);
|
|
1006
|
+
if (sessionManager.get(session.id).status === "running") {
|
|
1007
|
+
await zellijCli.sendCtrlC(session.paneId);
|
|
1008
|
+
await setTimeout(500);
|
|
1009
|
+
}
|
|
1010
|
+
} else await setTimeout(1e3);
|
|
1011
|
+
return jsonResponse({
|
|
1012
|
+
session: publicSession(session),
|
|
1013
|
+
output: readOutputSnapshot(session.id, { maxLines: args.maxLines }),
|
|
1014
|
+
next: nextAdvice(true, args.interruptAfterSeconds ? "Input was sent; Ctrl-C was sent after the requested interrupt timeout if the session was still running." : "Input was sent and recent output was observed."),
|
|
1015
|
+
warnings: []
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
//#endregion
|
|
1020
|
+
//#region src/plugin.ts
|
|
1021
|
+
const ZellijPtyPlugin = async (_input, options) => {
|
|
1022
|
+
configurePolicy(options?.zellijPty ?? options);
|
|
1023
|
+
return {
|
|
1024
|
+
async event(input) {
|
|
1025
|
+
if (input.event.type === "session.deleted") {
|
|
1026
|
+
const sessions = sessionManager.listByOpenCodeSession(input.event.properties.info.id);
|
|
1027
|
+
await Promise.all(sessions.map(async (session) => {
|
|
1028
|
+
await subscriberManager.closeSessionPane(session.id);
|
|
1029
|
+
subscriberManager.forget(session.id);
|
|
1030
|
+
sessionManager.remove(session.id);
|
|
1031
|
+
}));
|
|
1032
|
+
}
|
|
1033
|
+
},
|
|
1034
|
+
tool: {
|
|
1035
|
+
zellij_pty_spawn: zellijPtySpawnTool,
|
|
1036
|
+
zellij_pty_list: zellijPtyListTool,
|
|
1037
|
+
zellij_pty_write: zellijPtyWriteTool,
|
|
1038
|
+
zellij_pty_read: zellijPtyReadTool,
|
|
1039
|
+
zellij_pty_kill: zellijPtyKillTool,
|
|
1040
|
+
request_sudo: requestSudoTool
|
|
1041
|
+
}
|
|
1042
|
+
};
|
|
1043
|
+
};
|
|
1044
|
+
//#endregion
|
|
1045
|
+
export { ZellijPtyPlugin, ZellijPtyPlugin as default };
|
|
1046
|
+
|
|
1047
|
+
//# sourceMappingURL=index.mjs.map
|