opencode-zellij 0.0.1 → 0.0.3

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/dist/index.mjs CHANGED
@@ -1,10 +1,131 @@
1
+ import process from "node:process";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import { homedir, tmpdir } from "node:os";
5
+ import path, { join } from "node:path";
6
+ import { parseJSON, parseJSONC } from "confbox";
7
+ import { z } from "zod";
1
8
  import { randomUUID } from "node:crypto";
2
- import { setTimeout } from "node:timers/promises";
9
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
3
10
  import { tool } from "@opencode-ai/plugin";
4
- import { execFile, spawn } from "node:child_process";
5
- import process from "node:process";
11
+ import { execFile, spawn, spawnSync } from "node:child_process";
6
12
  import { promisify } from "node:util";
13
+ import { fileURLToPath } from "node:url";
7
14
  import { Buffer } from "node:buffer";
15
+ //#region src/config.ts
16
+ const sudoPaneSchema = z.enum([
17
+ "allow",
18
+ "deny",
19
+ "hide"
20
+ ]);
21
+ const configFilenames = ["opencode-zellij.config.jsonc", "opencode-zellij.config.json"];
22
+ const tabTitleLayerSchema = z.object({
23
+ enabled: z.boolean().optional().describe("Enable dynamic Zellij tab title updates."),
24
+ emojiIdle: z.string().optional().describe("Prefix used when OpenCode is idle."),
25
+ emojiRunning: z.string().optional().describe("Prefix used while OpenCode is running work."),
26
+ emojiNeedsInput: z.string().optional().describe("Prefix used when OpenCode is waiting for human input."),
27
+ emojiBranch: z.string().optional().describe("Prefix used before the current git branch name."),
28
+ debounceMs: z.number().finite().min(0).optional().describe("Debounce time for tab title updates in milliseconds.")
29
+ }).strict();
30
+ const ptyLayerSchema = z.object({
31
+ enabled: z.boolean().optional().describe("Enable Zellij-backed PTY tools."),
32
+ sudoPane: sudoPaneSchema.optional().describe("Controls whether the sudo pane tool is available, denied, or hidden.")
33
+ }).strict();
34
+ const sidecarConfigSchema = z.object({
35
+ $schema: z.string().optional().describe("JSON Schema URI for editor completion."),
36
+ tabTitle: tabTitleLayerSchema.optional(),
37
+ pty: ptyLayerSchema.optional()
38
+ }).strict();
39
+ const defaultConfig = {
40
+ tabTitle: {
41
+ enabled: true,
42
+ emojiIdle: "🟢",
43
+ emojiRunning: "⚡",
44
+ emojiNeedsInput: "💬",
45
+ emojiBranch: "🌱",
46
+ debounceMs: 300
47
+ },
48
+ pty: {
49
+ enabled: true,
50
+ sudoPane: "allow"
51
+ }
52
+ };
53
+ function validConfigLayer(value) {
54
+ const result = sidecarConfigSchema.safeParse(value);
55
+ if (!result.success) return void 0;
56
+ return {
57
+ tabTitle: result.data.tabTitle,
58
+ pty: result.data.pty
59
+ };
60
+ }
61
+ function mergeConfig(user, project) {
62
+ return {
63
+ tabTitle: {
64
+ enabled: project?.tabTitle?.enabled ?? user?.tabTitle?.enabled ?? defaultConfig.tabTitle.enabled,
65
+ emojiIdle: project?.tabTitle?.emojiIdle ?? user?.tabTitle?.emojiIdle ?? defaultConfig.tabTitle.emojiIdle,
66
+ emojiRunning: project?.tabTitle?.emojiRunning ?? user?.tabTitle?.emojiRunning ?? defaultConfig.tabTitle.emojiRunning,
67
+ emojiNeedsInput: project?.tabTitle?.emojiNeedsInput ?? user?.tabTitle?.emojiNeedsInput ?? defaultConfig.tabTitle.emojiNeedsInput,
68
+ emojiBranch: project?.tabTitle?.emojiBranch ?? user?.tabTitle?.emojiBranch ?? defaultConfig.tabTitle.emojiBranch,
69
+ debounceMs: project?.tabTitle?.debounceMs ?? user?.tabTitle?.debounceMs ?? defaultConfig.tabTitle.debounceMs
70
+ },
71
+ pty: {
72
+ enabled: project?.pty?.enabled ?? user?.pty?.enabled ?? defaultConfig.pty.enabled,
73
+ sudoPane: project?.pty?.sudoPane ?? user?.pty?.sudoPane ?? defaultConfig.pty.sudoPane
74
+ }
75
+ };
76
+ }
77
+ async function loadConfigLayer(directory, warnings) {
78
+ const configFile = detectConfigFile(directory);
79
+ if (!configFile) return {};
80
+ try {
81
+ const text = await readFile(configFile, "utf8");
82
+ const layer = validConfigLayer(configFile.endsWith(".jsonc") ? parseJSONC(text) : parseJSON(text));
83
+ if (!layer) {
84
+ warnings.push(`Ignoring invalid config shape in ${configFile}.`);
85
+ return { source: configFile };
86
+ }
87
+ return {
88
+ layer,
89
+ source: configFile
90
+ };
91
+ } catch (cause) {
92
+ warnings.push(`Ignoring unreadable or invalid config file ${configFile}: ${cause instanceof Error ? cause.message : String(cause)}`);
93
+ return {};
94
+ }
95
+ }
96
+ function detectConfigFile(directory) {
97
+ return configFilenames.map((filename) => join(directory, filename)).find((path) => existsSync(path));
98
+ }
99
+ function userConfigDir() {
100
+ return process.env.XDG_CONFIG_HOME ? join(process.env.XDG_CONFIG_HOME, "opencode") : join(homedir(), ".config", "opencode");
101
+ }
102
+ function projectConfigDirs(input) {
103
+ const dirs = [];
104
+ if (input.worktree) dirs.push(join(input.worktree, ".opencode"));
105
+ if (input.directory && input.directory !== input.worktree) dirs.push(join(input.directory, ".opencode"));
106
+ return dirs;
107
+ }
108
+ async function loadConfig(input) {
109
+ const warnings = [];
110
+ const sources = {};
111
+ const userResult = await loadConfigLayer(userConfigDir(), warnings);
112
+ const userLayer = userResult.layer;
113
+ if (userResult.source && userLayer) sources.user = userResult.source;
114
+ let projectLayer;
115
+ for (const projectDir of projectConfigDirs(input)) {
116
+ const projectResult = await loadConfigLayer(projectDir, warnings);
117
+ if (!projectResult.source) continue;
118
+ projectLayer = projectResult.layer;
119
+ if (projectLayer) sources.project = projectResult.source;
120
+ break;
121
+ }
122
+ return {
123
+ config: mergeConfig(userLayer, projectLayer),
124
+ sources,
125
+ warnings
126
+ };
127
+ }
128
+ //#endregion
8
129
  //#region src/utils/shell-args.ts
9
130
  const directCommandExitWrapper = "token=\"$1\"; shift; set +e; \"$@\"; code=$?; printf \"\\n[zellij-pty:%s] exit-code=%s\\n\" \"$token\" \"$code\"; exit \"$code\"";
10
131
  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\"";
@@ -63,6 +184,9 @@ function wildcardMatches(pattern, commandLine) {
63
184
  return new RegExp(`^${pattern.split("*").map(escapeRegex).join(".*")}$`).test(commandLine);
64
185
  }
65
186
  function configurePolicy(config) {
187
+ configuredDenyCommands = [];
188
+ configuredAllowCommands = [];
189
+ allowSudoPane = true;
66
190
  if (!config || typeof config !== "object") return;
67
191
  const object = config;
68
192
  if (isStringArray(object.denyCommands)) configuredDenyCommands = object.denyCommands;
@@ -74,8 +198,8 @@ function assertCommandAllowed(input) {
74
198
  for (const pattern of denyPatterns) if (pattern.test(commandLine)) throw new Error(`Command denied by zellij-pty policy: ${commandLine}`);
75
199
  for (const pattern of configuredDenyCommands) if (wildcardMatches(pattern, commandLine)) throw new Error(`Command denied by zellij-pty configured deny rule: ${commandLine}`);
76
200
  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.");
201
+ if (!input.humanInputOnly && sudoPattern.test(commandLine)) throw new Error("sudo commands must use zellij_pty_request_sudo so credentials stay human-input-only and never pass through agent tool input.");
202
+ if (input.humanInputOnly && !allowSudoPane) throw new Error("sudo pane is disabled by zellij-pty policy.");
79
203
  }
80
204
  //#endregion
81
205
  //#region src/utils/ids.ts
@@ -159,7 +283,7 @@ var SessionManager = class {
159
283
  const sessionManager = new SessionManager();
160
284
  //#endregion
161
285
  //#region src/zellij/cli.ts
162
- const execFileAsync = promisify(execFile);
286
+ const execFileAsync$1 = promisify(execFile);
163
287
  function zellijCommandArgs(actionArgs) {
164
288
  const sessionName = process.env.ZELLIJ_SESSION_NAME?.trim();
165
289
  if (sessionName) return [
@@ -185,6 +309,13 @@ function buildNewPaneActionArgs(options) {
185
309
  args.push("--", ...buildCommandArgv(options, { exitCodeToken: options.exitCodeToken }));
186
310
  return args;
187
311
  }
312
+ function buildRenameTabActionArgs(title) {
313
+ return [
314
+ "action",
315
+ "rename-tab",
316
+ title
317
+ ];
318
+ }
188
319
  function ensureZellijTarget() {
189
320
  if (process.env.ZELLIJ || process.env.ZELLIJ_SESSION_NAME) return;
190
321
  throw new Error("Zellij context not found. Run OpenCode inside Zellij or set ZELLIJ_SESSION_NAME to an existing session.");
@@ -192,7 +323,7 @@ function ensureZellijTarget() {
192
323
  async function runZellij(actionArgs, options = {}) {
193
324
  ensureZellijTarget();
194
325
  try {
195
- const result = await execFileAsync("zellij", zellijCommandArgs(actionArgs), {
326
+ const result = await execFileAsync$1("zellij", zellijCommandArgs(actionArgs), {
196
327
  encoding: "utf8",
197
328
  timeout: options.timeoutMs ?? 1e4,
198
329
  maxBuffer: 20 * 1024 * 1024
@@ -230,6 +361,14 @@ var ZellijCli = class {
230
361
  async closePane(paneId) {
231
362
  await runZellij(zellijActionArgs("close-pane", ["--pane-id", paneId]));
232
363
  }
364
+ closePaneSync(paneId) {
365
+ ensureZellijTarget();
366
+ spawnSync("zellij", zellijCommandArgs(zellijActionArgs("close-pane", ["--pane-id", paneId])), {
367
+ encoding: "utf8",
368
+ stdio: "ignore",
369
+ timeout: 2e3
370
+ });
371
+ }
233
372
  async focusPane(paneId) {
234
373
  await runZellij(zellijActionArgs("focus-pane-id", [paneId]));
235
374
  }
@@ -240,9 +379,149 @@ var ZellijCli = class {
240
379
  "--full"
241
380
  ]), { timeoutMs: 1e4 })).stdout;
242
381
  }
382
+ async renameTab(title) {
383
+ await runZellij(buildRenameTabActionArgs(title));
384
+ }
243
385
  };
244
386
  const zellijCli = new ZellijCli();
245
387
  //#endregion
388
+ //#region src/zellij/pane-watchdog.ts
389
+ const instanceId = randomUUID();
390
+ let watchdogStarted = false;
391
+ function registryDirectory() {
392
+ const base = process.env.XDG_RUNTIME_DIR || tmpdir();
393
+ return path.join(base, `opencode-zellij-${process.getuid?.() ?? "user"}`);
394
+ }
395
+ function watchdogRegistryPath() {
396
+ return path.join(registryDirectory(), `panes-${process.pid}-${instanceId}.json`);
397
+ }
398
+ function parseLinuxProcessStartTime(stat) {
399
+ return stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/)[19] ?? null;
400
+ }
401
+ function linuxProcessStartTime(pid) {
402
+ try {
403
+ return parseLinuxProcessStartTime(readFileSync(`/proc/${pid}/stat`, "utf8"));
404
+ } catch {
405
+ return null;
406
+ }
407
+ }
408
+ function emptyRegistry() {
409
+ return {
410
+ version: 1,
411
+ instanceId,
412
+ ownerPid: process.pid,
413
+ ownerStartTime: linuxProcessStartTime(process.pid),
414
+ zellijSessionName: process.env.ZELLIJ_SESSION_NAME?.trim() || null,
415
+ panes: []
416
+ };
417
+ }
418
+ function readRegistry() {
419
+ const file = watchdogRegistryPath();
420
+ if (!existsSync(file)) return emptyRegistry();
421
+ try {
422
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
423
+ if (parsed.version !== 1 || parsed.instanceId !== instanceId || parsed.ownerPid !== process.pid || !Array.isArray(parsed.panes)) return emptyRegistry();
424
+ return parsed;
425
+ } catch {
426
+ return emptyRegistry();
427
+ }
428
+ }
429
+ function writeRegistry(registry) {
430
+ mkdirSync(registryDirectory(), {
431
+ recursive: true,
432
+ mode: 448
433
+ });
434
+ const file = watchdogRegistryPath();
435
+ const tempFile = `${file}.tmp-${process.pid}`;
436
+ writeFileSync(tempFile, JSON.stringify(registry, null, 2), { mode: 384 });
437
+ renameSync(tempFile, file);
438
+ }
439
+ function ensureWatchdog() {
440
+ if (watchdogStarted) return;
441
+ watchdogStarted = true;
442
+ spawn("node", [watchdogRunnerPath(), watchdogRegistryPath()], {
443
+ detached: true,
444
+ stdio: "ignore",
445
+ env: process.env
446
+ }).unref();
447
+ }
448
+ function watchdogRunnerPath() {
449
+ return fileURLToPath(new URL("./pane-watchdog-runner.mjs", import.meta.url));
450
+ }
451
+ function cleanupStaleWatchdogRegistries() {
452
+ const directory = registryDirectory();
453
+ if (!existsSync(directory)) return;
454
+ for (const fileName of readdirSync(directory)) {
455
+ if (!fileName.startsWith("panes-") || !fileName.endsWith(".json")) continue;
456
+ const file = path.join(directory, fileName);
457
+ try {
458
+ const registry = JSON.parse(readFileSync(file, "utf8"));
459
+ if (registry.version !== 1 || ownerStillMatches(registry)) continue;
460
+ closeRegistryPanes(registry);
461
+ rmSync(file, { force: true });
462
+ } catch {
463
+ rmSync(file, { force: true });
464
+ }
465
+ }
466
+ }
467
+ function ownerStillMatches(registry) {
468
+ try {
469
+ process.kill(registry.ownerPid, 0);
470
+ } catch {
471
+ return false;
472
+ }
473
+ return !registry.ownerStartTime || linuxProcessStartTime(registry.ownerPid) === registry.ownerStartTime;
474
+ }
475
+ function closeRegistryPanes(registry) {
476
+ for (const pane of registry.panes) {
477
+ const args = [];
478
+ if (registry.zellijSessionName) args.push("--session", registry.zellijSessionName);
479
+ args.push("action", "close-pane", "--pane-id", pane.paneId);
480
+ spawn("zellij", args, {
481
+ detached: true,
482
+ stdio: "ignore",
483
+ env: process.env
484
+ }).unref();
485
+ }
486
+ }
487
+ function upsertWatchdogPane(registry, session) {
488
+ return {
489
+ ...registry,
490
+ panes: [...registry.panes.filter((pane) => pane.sessionId !== session.id && pane.paneId !== session.paneId), {
491
+ sessionId: session.id,
492
+ paneId: session.paneId,
493
+ title: session.title,
494
+ openCodeSessionId: session.openCodeSessionId,
495
+ createdAt: session.createdAt
496
+ }]
497
+ };
498
+ }
499
+ function removeWatchdogPane(registry, sessionId) {
500
+ return {
501
+ ...registry,
502
+ panes: registry.panes.filter((pane) => pane.sessionId !== sessionId)
503
+ };
504
+ }
505
+ function registerPaneForWatchdog(session) {
506
+ writeRegistry(upsertWatchdogPane(readRegistry(), session));
507
+ ensureWatchdog();
508
+ }
509
+ function unregisterPaneFromWatchdog(sessionId) {
510
+ const registry = readRegistry();
511
+ const updated = removeWatchdogPane(registry, sessionId);
512
+ if (updated.panes.length === registry.panes.length) return;
513
+ if (updated.panes.length === 0) {
514
+ removeWatchdogRegistry();
515
+ return;
516
+ }
517
+ writeRegistry(updated);
518
+ }
519
+ function removeWatchdogRegistry() {
520
+ try {
521
+ rmSync(watchdogRegistryPath(), { force: true });
522
+ } catch {}
523
+ }
524
+ //#endregion
246
525
  //#region src/pty/ring-buffer.ts
247
526
  const ansiPattern$1 = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[a-z]`, "gi");
248
527
  function normalizeLines(input) {
@@ -501,6 +780,7 @@ var SubscriberManager = class {
501
780
  state.buffer.append(`[zellij-pty] Pane ${session.paneId} closed at ${(/* @__PURE__ */ new Date()).toISOString()}`);
502
781
  this.sessions.updateLineCount(sessionId, state.buffer.lineCount);
503
782
  this.sessions.updateStatus(sessionId, session.status === "killed" ? "killed" : "exited");
783
+ unregisterPaneFromWatchdog(sessionId);
504
784
  this.stop(sessionId);
505
785
  return;
506
786
  }
@@ -629,7 +909,7 @@ const zellijPtyKillTool = tool({
629
909
  const output = subscriberManager.has(session.id) ? readOutputSnapshot(session.id) : void 0;
630
910
  try {
631
911
  await zellijCli.sendCtrlC(session.paneId);
632
- await setTimeout(500);
912
+ await setTimeout$1(500);
633
913
  } catch (error) {
634
914
  warnings.push(`Ctrl-C failed or pane was already gone: ${error instanceof Error ? error.message : String(error)}`);
635
915
  }
@@ -649,6 +929,7 @@ const zellijPtyKillTool = tool({
649
929
  }
650
930
  subscriberManager.stop(session.id);
651
931
  subscriberManager.forget(session.id);
932
+ unregisterPaneFromWatchdog(session.id);
652
933
  sessionManager.remove(session.id);
653
934
  return jsonResponse({
654
935
  killed: true,
@@ -784,7 +1065,7 @@ const requestSudoTool = tool({
784
1065
  humanInputOnly: true
785
1066
  });
786
1067
  const command = buildReviewScript(args.summary, args.scripts);
787
- const title = createOpenCodePaneTitle("request_sudo");
1068
+ const title = createOpenCodePaneTitle("zellij_pty_request_sudo");
788
1069
  const paneId = await zellijCli.newPane({
789
1070
  command: "bash",
790
1071
  args: ["-lc", command],
@@ -804,13 +1085,14 @@ const requestSudoTool = tool({
804
1085
  openCodeSessionId: context.sessionID,
805
1086
  paneId,
806
1087
  title,
807
- command: "request_sudo",
1088
+ command: "zellij_pty_request_sudo",
808
1089
  args: [],
809
1090
  cwd,
810
1091
  allowAgentInput: false,
811
1092
  humanInputOnly: true,
812
1093
  exitCodeToken
813
1094
  });
1095
+ registerPaneForWatchdog(session);
814
1096
  await subscriberManager.start(session);
815
1097
  return jsonResponse({
816
1098
  session: publicSession(session),
@@ -833,7 +1115,7 @@ async function runProbe(probe, outputReader) {
833
1115
  };
834
1116
  if (effectiveProbe.type === "sleep") {
835
1117
  const seconds = effectiveProbe.seconds ?? defaultSleepSeconds;
836
- await setTimeout(seconds * 1e3);
1118
+ await setTimeout$1(seconds * 1e3);
837
1119
  return result(effectiveProbe.type, true, `Slept for ${seconds}s.`, startedAt);
838
1120
  }
839
1121
  if (effectiveProbe.type === "output") {
@@ -841,7 +1123,7 @@ async function runProbe(probe, outputReader) {
841
1123
  const deadline = Date.now() + timeoutSeconds * 1e3;
842
1124
  while (Date.now() <= deadline) {
843
1125
  if (outputReader(effectiveProbe.grep, effectiveProbe.ignoreCase)) return result(effectiveProbe.type, true, `Observed output matching /${effectiveProbe.grep}/.`, startedAt);
844
- await setTimeout(pollIntervalMs);
1126
+ await setTimeout$1(pollIntervalMs);
845
1127
  }
846
1128
  return result(effectiveProbe.type, false, `Timed out after ${timeoutSeconds}s waiting for output matching /${effectiveProbe.grep}/.`, startedAt);
847
1129
  }
@@ -861,7 +1143,7 @@ async function runProbe(probe, outputReader) {
861
1143
  } catch (error) {
862
1144
  lastError = error instanceof Error ? error.message : String(error);
863
1145
  }
864
- await setTimeout(pollIntervalMs);
1146
+ await setTimeout$1(pollIntervalMs);
865
1147
  }
866
1148
  return result(effectiveProbe.type, false, `Timed out after ${timeoutSeconds}s probing ${effectiveProbe.url}: ${lastError}.`, startedAt);
867
1149
  }
@@ -934,6 +1216,7 @@ const zellijPtySpawnTool = tool({
934
1216
  humanInputOnly: false,
935
1217
  exitCodeToken
936
1218
  });
1219
+ registerPaneForWatchdog(session);
937
1220
  await subscriberManager.start(session);
938
1221
  const probe = await runProbe(args.probe, (grep, ignoreCase) => outputMatches(session.id, grep, ignoreCase));
939
1222
  const output = readOutputSnapshot(session.id, { maxLines: args.maxLines });
@@ -982,7 +1265,7 @@ const schema = tool.schema;
982
1265
  const zellijPtyWriteTool = tool({
983
1266
  description: "Write stdin to a Zellij PTY session. Refuses human-input-only sessions.",
984
1267
  args: {
985
- id: schema.string().describe("zellij-pty session id returned by zellij_pty_spawn or request_sudo."),
1268
+ id: schema.string().describe("zellij-pty session id returned by zellij_pty_spawn or zellij_pty_request_sudo."),
986
1269
  data: schema.string().describe("Text to write. Use  to send Ctrl-C."),
987
1270
  maxLines: schema.number().int().positive().max(5e3).optional().describe("Maximum recent output lines to return. Defaults to 200."),
988
1271
  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.")
@@ -1002,12 +1285,12 @@ const zellijPtyWriteTool = tool({
1002
1285
  }
1003
1286
  session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1004
1287
  if (args.interruptAfterSeconds) {
1005
- await setTimeout(args.interruptAfterSeconds * 1e3);
1288
+ await setTimeout$1(args.interruptAfterSeconds * 1e3);
1006
1289
  if (sessionManager.get(session.id).status === "running") {
1007
1290
  await zellijCli.sendCtrlC(session.paneId);
1008
- await setTimeout(500);
1291
+ await setTimeout$1(500);
1009
1292
  }
1010
- } else await setTimeout(1e3);
1293
+ } else await setTimeout$1(1e3);
1011
1294
  return jsonResponse({
1012
1295
  session: publicSession(session),
1013
1296
  output: readOutputSnapshot(session.id, { maxLines: args.maxLines }),
@@ -1017,28 +1300,336 @@ const zellijPtyWriteTool = tool({
1017
1300
  }
1018
1301
  });
1019
1302
  //#endregion
1303
+ //#region src/utils/debug.ts
1304
+ function debug(message, ...details) {
1305
+ if (!process.env.ZELLIJ_PTY_DEBUG) return;
1306
+ console.warn(`[opencode-zellij] ${message}`, ...details);
1307
+ }
1308
+ //#endregion
1309
+ //#region src/zellij/shutdown-cleanup.ts
1310
+ let registered = false;
1311
+ let cleanedUp = false;
1312
+ function cleanupPanesOnShutdown(sessions = sessionManager, subscribers = subscriberManager) {
1313
+ if (cleanedUp) return;
1314
+ cleanedUp = true;
1315
+ for (const session of sessions.list()) {
1316
+ try {
1317
+ zellijCli.closePaneSync(session.paneId);
1318
+ } catch {}
1319
+ subscribers.forget(session.id);
1320
+ try {
1321
+ sessions.remove(session.id);
1322
+ } catch {}
1323
+ }
1324
+ }
1325
+ function registerShutdownCleanup() {
1326
+ if (registered) return;
1327
+ registered = true;
1328
+ process.once("exit", () => cleanupPanesOnShutdown());
1329
+ process.once("SIGINT", () => exitAfterCleanup("SIGINT", 130));
1330
+ process.once("SIGTERM", () => exitAfterCleanup("SIGTERM", 143));
1331
+ process.once("SIGHUP", () => exitAfterCleanup("SIGHUP", 129));
1332
+ }
1333
+ function exitAfterCleanup(signal, code) {
1334
+ cleanupPanesOnShutdown();
1335
+ process.removeAllListeners(signal);
1336
+ process.exit(code);
1337
+ }
1338
+ //#endregion
1339
+ //#region src/zellij/tab-title-events.ts
1340
+ const execFileAsync = promisify(execFile);
1341
+ function isRecord(value) {
1342
+ return typeof value === "object" && value !== null;
1343
+ }
1344
+ function stringProperty(object, key) {
1345
+ const value = object[key];
1346
+ return typeof value === "string" ? value : void 0;
1347
+ }
1348
+ function nestedStringProperty(object, key, nestedKey) {
1349
+ const nested = object[key];
1350
+ if (!isRecord(nested)) return void 0;
1351
+ return stringProperty(nested, nestedKey);
1352
+ }
1353
+ function sessionStatusProperty(object) {
1354
+ const status = object.status;
1355
+ if (!isRecord(status)) return void 0;
1356
+ if (status.type === "idle" || status.type === "busy") return { type: status.type };
1357
+ if (status.type === "retry") return {
1358
+ type: "retry",
1359
+ attempt: typeof status.attempt === "number" ? status.attempt : 0,
1360
+ message: typeof status.message === "string" ? status.message : "",
1361
+ next: typeof status.next === "number" ? status.next : 0
1362
+ };
1363
+ }
1364
+ function inputRequestID(object) {
1365
+ return stringProperty(object, "id") ?? stringProperty(object, "requestID") ?? stringProperty(object, "permissionID");
1366
+ }
1367
+ function deletedSessionID(event) {
1368
+ if (!isRecord(event.properties)) return void 0;
1369
+ return nestedStringProperty(event.properties, "info", "id") ?? stringProperty(event.properties, "sessionID");
1370
+ }
1371
+ async function readGitBranch(worktree) {
1372
+ return (await execFileAsync("git", [
1373
+ "-C",
1374
+ worktree,
1375
+ "branch",
1376
+ "--show-current"
1377
+ ], {
1378
+ encoding: "utf8",
1379
+ timeout: 1e3,
1380
+ maxBuffer: 1024 * 1024
1381
+ })).stdout;
1382
+ }
1383
+ async function getInitialBranch(worktree, readBranch = readGitBranch) {
1384
+ try {
1385
+ return (await readBranch(worktree)).trim() || void 0;
1386
+ } catch {
1387
+ return;
1388
+ }
1389
+ }
1390
+ function shouldReadInitialBranch(zellij) {
1391
+ return Boolean(zellij);
1392
+ }
1393
+ function handleTabTitleEvent(tabTitleManager, event) {
1394
+ if (!isRecord(event.properties)) return;
1395
+ const properties = event.properties;
1396
+ switch (event.type) {
1397
+ case "session.status": {
1398
+ const sessionID = stringProperty(properties, "sessionID");
1399
+ const status = sessionStatusProperty(properties);
1400
+ if (sessionID && status) tabTitleManager.updateSessionStatus(sessionID, status);
1401
+ break;
1402
+ }
1403
+ case "session.idle": {
1404
+ const sessionID = stringProperty(properties, "sessionID");
1405
+ if (sessionID) tabTitleManager.markSessionIdle(sessionID);
1406
+ break;
1407
+ }
1408
+ case "vcs.branch.updated":
1409
+ tabTitleManager.setBranch(stringProperty(properties, "branch"));
1410
+ break;
1411
+ case "question.asked":
1412
+ case "permission.asked":
1413
+ case "permission.updated": {
1414
+ const id = inputRequestID(properties);
1415
+ const sessionID = stringProperty(properties, "sessionID");
1416
+ if (id && sessionID) tabTitleManager.markNeedsInput(id, sessionID);
1417
+ break;
1418
+ }
1419
+ case "question.replied":
1420
+ case "question.rejected":
1421
+ case "permission.replied": {
1422
+ const id = inputRequestID(properties);
1423
+ if (id) tabTitleManager.clearNeedsInput(id);
1424
+ break;
1425
+ }
1426
+ case "session.deleted": {
1427
+ const sessionID = deletedSessionID(event);
1428
+ if (sessionID) tabTitleManager.removeSession(sessionID);
1429
+ break;
1430
+ }
1431
+ }
1432
+ }
1433
+ //#endregion
1434
+ //#region src/zellij/tab-title.ts
1435
+ const defaultTabTitleEmojis = {
1436
+ idle: "🟢",
1437
+ running: "⚡",
1438
+ needsInput: "💬",
1439
+ branch: "🌱"
1440
+ };
1441
+ function formatTabTitle(context) {
1442
+ const branch = context.branchName ? ` ${context.emojis.branch} ${context.branchName}` : "";
1443
+ return `${context.emojis[context.status === "needs-input" ? "needsInput" : context.status]} ${context.projectName}${branch}`;
1444
+ }
1445
+ function sanitizeTitle(title, maxLength = 90) {
1446
+ let cleaned = title.replace(/[\p{Cc}\p{Cf}\p{Co}\p{Cn}]/gu, " ").replace(/\s+/g, " ").trim();
1447
+ const chars = Array.from(cleaned);
1448
+ if (chars.length > maxLength) cleaned = `${chars.slice(0, maxLength - 1).join("")}…`;
1449
+ return cleaned;
1450
+ }
1451
+ var TabTitleManager = class {
1452
+ sessionStatuses = /* @__PURE__ */ new Map();
1453
+ pendingInputs = /* @__PURE__ */ new Map();
1454
+ branchName;
1455
+ desiredTitle;
1456
+ lastSyncedTitle;
1457
+ debounceTimer;
1458
+ syncInFlight = false;
1459
+ debounceMs;
1460
+ projectName;
1461
+ cli;
1462
+ emojis;
1463
+ enabled;
1464
+ constructor(options) {
1465
+ this.projectName = options.projectName;
1466
+ this.branchName = options.branchName?.trim() || void 0;
1467
+ this.cli = options.cli ?? new ZellijCli();
1468
+ this.emojis = {
1469
+ ...defaultTabTitleEmojis,
1470
+ ...options.emojis
1471
+ };
1472
+ this.debounceMs = options.debounceMs ?? 300;
1473
+ this.enabled = Boolean(process.env.ZELLIJ);
1474
+ }
1475
+ setBranch(branch) {
1476
+ const trimmed = branch?.trim() || void 0;
1477
+ if (this.branchName === trimmed) return;
1478
+ this.branchName = trimmed;
1479
+ this.scheduleUpdate();
1480
+ }
1481
+ updateSessionStatus(sessionID, status) {
1482
+ const activity = status.type === "idle" ? "idle" : "running";
1483
+ if (this.sessionStatuses.get(sessionID) === activity) return;
1484
+ this.sessionStatuses.set(sessionID, activity);
1485
+ this.scheduleUpdate();
1486
+ }
1487
+ markSessionIdle(sessionID) {
1488
+ this.updateSessionStatus(sessionID, { type: "idle" });
1489
+ }
1490
+ removeSession(sessionID) {
1491
+ const hadSessionStatus = this.sessionStatuses.delete(sessionID);
1492
+ let hadPendingInput = false;
1493
+ for (const [id, pendingSessionID] of this.pendingInputs) if (pendingSessionID === sessionID) {
1494
+ this.pendingInputs.delete(id);
1495
+ hadPendingInput = true;
1496
+ }
1497
+ if (!hadSessionStatus && !hadPendingInput) return;
1498
+ this.scheduleUpdate();
1499
+ }
1500
+ markNeedsInput(id, sessionID) {
1501
+ if (this.pendingInputs.get(id) === sessionID) return;
1502
+ this.pendingInputs.set(id, sessionID);
1503
+ this.scheduleUpdate();
1504
+ }
1505
+ clearNeedsInput(id) {
1506
+ if (!this.pendingInputs.delete(id)) return;
1507
+ this.scheduleUpdate();
1508
+ }
1509
+ get isBusy() {
1510
+ for (const activity of this.sessionStatuses.values()) if (activity === "running") return true;
1511
+ return false;
1512
+ }
1513
+ get needsInput() {
1514
+ return this.pendingInputs.size > 0;
1515
+ }
1516
+ get status() {
1517
+ if (this.needsInput) return "needs-input";
1518
+ if (this.isBusy) return "running";
1519
+ return "idle";
1520
+ }
1521
+ buildTitle() {
1522
+ return sanitizeTitle(formatTabTitle({
1523
+ projectName: this.projectName,
1524
+ branchName: this.branchName,
1525
+ status: this.status,
1526
+ emojis: this.emojis
1527
+ }));
1528
+ }
1529
+ getCurrentTitle() {
1530
+ return this.buildTitle();
1531
+ }
1532
+ async renderImmediate() {
1533
+ if (!this.enabled) return;
1534
+ this.desiredTitle = this.buildTitle();
1535
+ this.clearDebounceTimer();
1536
+ await this.syncDesiredTitle();
1537
+ }
1538
+ scheduleUpdate() {
1539
+ if (!this.enabled) return;
1540
+ const title = this.buildTitle();
1541
+ if (title === this.desiredTitle && title === this.lastSyncedTitle) return;
1542
+ this.desiredTitle = title;
1543
+ if (this.syncInFlight) return;
1544
+ this.clearDebounceTimer();
1545
+ this.debounceTimer = setTimeout(() => {
1546
+ this.debounceTimer = void 0;
1547
+ this.syncDesiredTitle().catch(() => {});
1548
+ }, this.debounceMs);
1549
+ }
1550
+ async syncDesiredTitle() {
1551
+ if (!this.enabled) return;
1552
+ if (this.syncInFlight) return;
1553
+ this.syncInFlight = true;
1554
+ try {
1555
+ while (this.desiredTitle && this.desiredTitle !== this.lastSyncedTitle) {
1556
+ const title = this.desiredTitle;
1557
+ try {
1558
+ await this.cli.renameTab(title);
1559
+ this.lastSyncedTitle = title;
1560
+ } catch (cause) {
1561
+ debug("Failed to rename Zellij tab.", cause);
1562
+ break;
1563
+ }
1564
+ }
1565
+ } finally {
1566
+ this.syncInFlight = false;
1567
+ }
1568
+ }
1569
+ clearDebounceTimer() {
1570
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
1571
+ this.debounceTimer = void 0;
1572
+ }
1573
+ destroy() {
1574
+ this.clearDebounceTimer();
1575
+ }
1576
+ };
1577
+ //#endregion
1020
1578
  //#region src/plugin.ts
1021
- const ZellijPtyPlugin = async (_input, options) => {
1022
- configurePolicy(options?.zellijPty ?? options);
1579
+ const ptyTools = {
1580
+ zellij_pty_spawn: zellijPtySpawnTool,
1581
+ zellij_pty_list: zellijPtyListTool,
1582
+ zellij_pty_write: zellijPtyWriteTool,
1583
+ zellij_pty_read: zellijPtyReadTool,
1584
+ zellij_pty_kill: zellijPtyKillTool
1585
+ };
1586
+ function getProjectName(path) {
1587
+ return path.split(/[/\\]/).filter(Boolean).pop() || "opencode";
1588
+ }
1589
+ function getWorkspaceRoot(input) {
1590
+ return input.worktree || input.directory || process.cwd();
1591
+ }
1592
+ const ZellijPtyPlugin = async (input) => {
1593
+ const { config, warnings } = await loadConfig(input);
1594
+ for (const warning of warnings) debug(warning);
1595
+ configurePolicy({ allowSudoPane: config.pty.sudoPane === "allow" });
1596
+ cleanupStaleWatchdogRegistries();
1597
+ registerShutdownCleanup();
1598
+ const workspaceRoot = getWorkspaceRoot(input);
1599
+ const projectName = getProjectName(workspaceRoot);
1600
+ const branchName = config.tabTitle.enabled && shouldReadInitialBranch(process.env.ZELLIJ) ? await getInitialBranch(workspaceRoot) : void 0;
1601
+ const tabTitleManager = config.tabTitle.enabled ? new TabTitleManager({
1602
+ projectName,
1603
+ branchName,
1604
+ debounceMs: config.tabTitle.debounceMs,
1605
+ emojis: {
1606
+ idle: config.tabTitle.emojiIdle,
1607
+ running: config.tabTitle.emojiRunning,
1608
+ needsInput: config.tabTitle.emojiNeedsInput,
1609
+ branch: config.tabTitle.emojiBranch
1610
+ }
1611
+ }) : void 0;
1612
+ tabTitleManager?.renderImmediate().catch(() => {});
1023
1613
  return {
1024
1614
  async event(input) {
1025
- if (input.event.type === "session.deleted") {
1026
- const sessions = sessionManager.listByOpenCodeSession(input.event.properties.info.id);
1615
+ const event = input.event;
1616
+ if (tabTitleManager) handleTabTitleEvent(tabTitleManager, event);
1617
+ if (event.type === "session.deleted") {
1618
+ const sessionID = deletedSessionID(event);
1619
+ if (!sessionID) return;
1620
+ const sessions = sessionManager.listByOpenCodeSession(sessionID);
1027
1621
  await Promise.all(sessions.map(async (session) => {
1028
1622
  await subscriberManager.closeSessionPane(session.id);
1029
1623
  subscriberManager.forget(session.id);
1624
+ unregisterPaneFromWatchdog(session.id);
1030
1625
  sessionManager.remove(session.id);
1031
1626
  }));
1032
1627
  }
1033
1628
  },
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
- }
1629
+ tool: config.pty.enabled ? {
1630
+ ...ptyTools,
1631
+ ...config.pty.sudoPane === "hide" ? {} : { zellij_pty_request_sudo: requestSudoTool }
1632
+ } : {}
1042
1633
  };
1043
1634
  };
1044
1635
  //#endregion