opencode-zellij 0.0.9 → 0.0.11

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
@@ -250,6 +250,13 @@ const sudoPaneSchema = z.enum([
250
250
  "deny",
251
251
  "hide"
252
252
  ]);
253
+ const completionNotificationModeSchema = z.enum([
254
+ "off",
255
+ "queue",
256
+ "toast",
257
+ "queue+toast",
258
+ "prompt"
259
+ ]);
253
260
  const configFilenames = ["opencode-zellij.config.jsonc", "opencode-zellij.config.json"];
254
261
  const tabTitleLayerSchema = z.object({
255
262
  enabled: z.boolean().optional().describe("Enable dynamic Zellij tab title updates."),
@@ -261,7 +268,16 @@ const tabTitleLayerSchema = z.object({
261
268
  }).strict();
262
269
  const ptyLayerSchema = z.object({
263
270
  enabled: z.boolean().optional().describe("Enable Zellij-backed PTY tools."),
264
- sudoPane: sudoPaneSchema.optional().describe("Controls whether the sudo pane tool is available, denied, or hidden.")
271
+ cleanupExitedPaneOnRead: z.boolean().optional().describe("Remove exited PTY panes after they are read."),
272
+ sudoPane: sudoPaneSchema.optional().describe("Controls whether the sudo pane tool is available, denied, or hidden."),
273
+ completionNotification: z.object({
274
+ mode: completionNotificationModeSchema.optional().describe("Controls how completion notifications are delivered."),
275
+ prompt: z.object({
276
+ requireIdle: z.boolean().optional().describe("Require the plugin to be idle before prompting."),
277
+ cooldownMs: z.number().finite().min(0).optional().describe("Cooldown time before prompting again in milliseconds."),
278
+ maxAttempts: z.number().finite().int().min(0).optional().describe("Maximum prompt attempts before backing off.")
279
+ }).strict().optional().describe("Prompt-specific completion notification settings.")
280
+ }).strict().optional().describe("Completion notification delivery settings.")
265
281
  }).strict();
266
282
  const autoUpdateLayerSchema = z.boolean().optional().describe("Enable automatic update checks for the opencode-zellij plugin.");
267
283
  const sidecarConfigSchema = z.object({
@@ -281,7 +297,16 @@ const defaultConfig = {
281
297
  },
282
298
  pty: {
283
299
  enabled: true,
284
- sudoPane: "allow"
300
+ cleanupExitedPaneOnRead: true,
301
+ sudoPane: "allow",
302
+ completionNotification: {
303
+ mode: "queue+toast",
304
+ prompt: {
305
+ requireIdle: true,
306
+ cooldownMs: 3e4,
307
+ maxAttempts: 1
308
+ }
309
+ }
285
310
  },
286
311
  autoUpdate: true
287
312
  };
@@ -306,7 +331,16 @@ function mergeConfig(user, project) {
306
331
  },
307
332
  pty: {
308
333
  enabled: project?.pty?.enabled ?? user?.pty?.enabled ?? defaultConfig.pty.enabled,
309
- sudoPane: project?.pty?.sudoPane ?? user?.pty?.sudoPane ?? defaultConfig.pty.sudoPane
334
+ cleanupExitedPaneOnRead: project?.pty?.cleanupExitedPaneOnRead ?? user?.pty?.cleanupExitedPaneOnRead ?? defaultConfig.pty.cleanupExitedPaneOnRead,
335
+ sudoPane: project?.pty?.sudoPane ?? user?.pty?.sudoPane ?? defaultConfig.pty.sudoPane,
336
+ completionNotification: {
337
+ mode: project?.pty?.completionNotification?.mode ?? user?.pty?.completionNotification?.mode ?? defaultConfig.pty.completionNotification.mode,
338
+ prompt: {
339
+ requireIdle: project?.pty?.completionNotification?.prompt?.requireIdle ?? user?.pty?.completionNotification?.prompt?.requireIdle ?? defaultConfig.pty.completionNotification.prompt.requireIdle,
340
+ cooldownMs: project?.pty?.completionNotification?.prompt?.cooldownMs ?? user?.pty?.completionNotification?.prompt?.cooldownMs ?? defaultConfig.pty.completionNotification.prompt.cooldownMs,
341
+ maxAttempts: project?.pty?.completionNotification?.prompt?.maxAttempts ?? user?.pty?.completionNotification?.prompt?.maxAttempts ?? defaultConfig.pty.completionNotification.prompt.maxAttempts
342
+ }
343
+ }
310
344
  },
311
345
  autoUpdate: project?.autoUpdate ?? user?.autoUpdate ?? defaultConfig.autoUpdate
312
346
  };
@@ -390,6 +424,7 @@ function parsePaneId(output) {
390
424
  }
391
425
  //#endregion
392
426
  //#region src/pty/manager.ts
427
+ const tombstoneTailLimit = 200;
393
428
  var SessionManager = class {
394
429
  sessions = /* @__PURE__ */ new Map();
395
430
  create(input) {
@@ -410,7 +445,8 @@ var SessionManager = class {
410
445
  humanInputOnly: input.humanInputOnly,
411
446
  exitCode: null,
412
447
  exitedAt: null,
413
- exitCodeToken: input.exitCodeToken ?? null
448
+ exitCodeToken: input.exitCodeToken ?? null,
449
+ tombstone: null
414
450
  };
415
451
  this.sessions.set(session.id, session);
416
452
  return session;
@@ -434,16 +470,65 @@ var SessionManager = class {
434
470
  }
435
471
  updateStatus(id, status) {
436
472
  const session = this.get(id);
473
+ if (session.status === "terminal" && status !== "terminal") return session;
437
474
  session.status = status;
438
475
  session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
439
476
  return session;
440
477
  }
441
478
  markExited(id, exitCode) {
479
+ return this.markTerminal(id, {
480
+ reason: "exit_marker",
481
+ exitCode
482
+ }).session;
483
+ }
484
+ markTerminal(id, input) {
485
+ const session = this.get(id);
486
+ const now = (/* @__PURE__ */ new Date()).toISOString();
487
+ const created = session.status !== "terminal" || !session.tombstone;
488
+ if (created) {
489
+ const tombstone = {
490
+ reason: input.reason,
491
+ terminalAt: now,
492
+ tail: (input.tail ?? []).slice(-tombstoneTailLimit),
493
+ paneClosedAt: null,
494
+ notificationSentAt: null
495
+ };
496
+ session.status = "terminal";
497
+ session.tombstone = tombstone;
498
+ session.updatedAt = now;
499
+ }
500
+ if (input.exitCode !== void 0 && session.exitCode === null) {
501
+ session.exitCode = input.exitCode;
502
+ session.exitedAt = now;
503
+ session.updatedAt = now;
504
+ }
505
+ if (session.tombstone) {
506
+ if (input.tail?.length && session.tombstone.tail.length === 0) session.tombstone.tail = input.tail.slice(-tombstoneTailLimit);
507
+ if (!session.tombstone.reason) session.tombstone.reason = input.reason;
508
+ }
509
+ return {
510
+ session,
511
+ created
512
+ };
513
+ }
514
+ markTerminalPaneClosed(id) {
442
515
  const session = this.get(id);
443
- session.status = "exited";
444
- session.exitCode = exitCode;
445
- session.exitedAt = (/* @__PURE__ */ new Date()).toISOString();
446
- session.updatedAt = session.exitedAt;
516
+ const now = (/* @__PURE__ */ new Date()).toISOString();
517
+ if (!session.tombstone) return session;
518
+ if (!session.tombstone.paneClosedAt) {
519
+ session.tombstone.paneClosedAt = now;
520
+ session.updatedAt = now;
521
+ }
522
+ return session;
523
+ }
524
+ markTerminalNotificationSent(id) {
525
+ const session = this.get(id);
526
+ const now = (/* @__PURE__ */ new Date()).toISOString();
527
+ if (!session.tombstone) return session;
528
+ if (!session.tombstone.notificationSentAt) {
529
+ session.tombstone.notificationSentAt = now;
530
+ session.updatedAt = now;
531
+ }
447
532
  return session;
448
533
  }
449
534
  listByOpenCodeSession(openCodeSessionId) {
@@ -488,6 +573,105 @@ function buildCommandArgv(input, options = {}) {
488
573
  ];
489
574
  }
490
575
  //#endregion
576
+ //#region src/zellij/parse.ts
577
+ function numericProperty(object, keys) {
578
+ for (const key of keys) {
579
+ const value = object[key];
580
+ if (typeof value === "number" && Number.isFinite(value)) return value;
581
+ if (typeof value === "string") {
582
+ const parsed = Number(value);
583
+ if (Number.isInteger(parsed)) return parsed;
584
+ }
585
+ }
586
+ }
587
+ function stringProperty$1(object, keys) {
588
+ for (const key of keys) {
589
+ const value = object[key];
590
+ if (typeof value === "string") return value;
591
+ }
592
+ }
593
+ function paneMatches(object, paneId) {
594
+ return numericProperty(object, [
595
+ "id",
596
+ "pane_id",
597
+ "paneId"
598
+ ]) === paneId && object.is_plugin !== true;
599
+ }
600
+ function findPaneRecord(value, paneId) {
601
+ if (Array.isArray(value)) {
602
+ for (const item of value) {
603
+ const found = findPaneRecord(item, paneId);
604
+ if (found !== void 0) return found;
605
+ }
606
+ return;
607
+ }
608
+ if (typeof value !== "object" || value === null) return void 0;
609
+ const object = value;
610
+ if (paneMatches(object, paneId)) return object;
611
+ for (const nested of Object.values(object)) {
612
+ const found = findPaneRecord(nested, paneId);
613
+ if (found !== void 0) return found;
614
+ }
615
+ }
616
+ function parseCurrentPaneTabId(listPanesJson, paneId) {
617
+ if (!paneId) return void 0;
618
+ const parsedPaneId = Number(paneId);
619
+ if (!Number.isInteger(parsedPaneId)) return void 0;
620
+ try {
621
+ const pane = findPaneRecord(JSON.parse(listPanesJson), parsedPaneId);
622
+ return pane ? numericProperty(pane, ["tab_id", "tabId"]) : void 0;
623
+ } catch (error) {
624
+ debug("parseCurrentPaneTabId failed", errorMessage(error));
625
+ return;
626
+ }
627
+ }
628
+ function parsePaneExists(listPanesJson, paneId) {
629
+ if (!paneId) return void 0;
630
+ let parsedPaneId;
631
+ try {
632
+ parsedPaneId = Number(normalizePaneId(paneId).slice(9));
633
+ } catch {
634
+ return;
635
+ }
636
+ try {
637
+ return findPaneRecord(JSON.parse(listPanesJson), parsedPaneId) !== void 0;
638
+ } catch (error) {
639
+ debug("parsePaneExists failed", errorMessage(error));
640
+ return;
641
+ }
642
+ }
643
+ function tabNameProperty(object, tabId) {
644
+ if (tabId === void 0) return void 0;
645
+ if (numericProperty(object, ["tab_id", "tabId"]) !== tabId) return void 0;
646
+ const name = stringProperty$1(object, ["name", "title"]);
647
+ return typeof name === "string" ? name : void 0;
648
+ }
649
+ function findTabName(value, tabId) {
650
+ if (Array.isArray(value)) {
651
+ for (const item of value) {
652
+ const found = findTabName(item, tabId);
653
+ if (found !== void 0) return found;
654
+ }
655
+ return;
656
+ }
657
+ if (typeof value !== "object" || value === null) return void 0;
658
+ const object = value;
659
+ const name = tabNameProperty(object, tabId);
660
+ if (name !== void 0) return name;
661
+ for (const nested of Object.values(object)) {
662
+ const found = findTabName(nested, tabId);
663
+ if (found !== void 0) return found;
664
+ }
665
+ }
666
+ function parseTabName(listTabsJson, tabId) {
667
+ try {
668
+ return findTabName(JSON.parse(listTabsJson), tabId);
669
+ } catch (error) {
670
+ debug("parseTabName failed", errorMessage(error));
671
+ return;
672
+ }
673
+ }
674
+ //#endregion
491
675
  //#region src/zellij/cli.ts
492
676
  const execFileAsync$1 = promisify(execFile);
493
677
  function zellijCommandArgs(actionArgs) {
@@ -529,50 +713,6 @@ function buildRenameTabActionArgs(title, options = {}) {
529
713
  title
530
714
  ];
531
715
  }
532
- function numericProperty(object, keys) {
533
- for (const key of keys) {
534
- const value = object[key];
535
- if (typeof value === "number" && Number.isFinite(value)) return value;
536
- if (typeof value === "string") {
537
- const parsed = Number(value);
538
- if (Number.isInteger(parsed)) return parsed;
539
- }
540
- }
541
- }
542
- function paneMatches(object, paneId) {
543
- return numericProperty(object, [
544
- "id",
545
- "pane_id",
546
- "paneId"
547
- ]) === paneId && object.is_plugin !== true;
548
- }
549
- function findPaneTabId(value, paneId) {
550
- if (Array.isArray(value)) {
551
- for (const item of value) {
552
- const found = findPaneTabId(item, paneId);
553
- if (found !== void 0) return found;
554
- }
555
- return;
556
- }
557
- if (typeof value !== "object" || value === null) return void 0;
558
- const object = value;
559
- if (paneMatches(object, paneId)) return numericProperty(object, ["tab_id", "tabId"]);
560
- for (const nested of Object.values(object)) {
561
- const found = findPaneTabId(nested, paneId);
562
- if (found !== void 0) return found;
563
- }
564
- }
565
- function parseCurrentPaneTabId(listPanesJson, paneId) {
566
- if (!paneId) return void 0;
567
- const parsedPaneId = Number(paneId);
568
- if (!Number.isInteger(parsedPaneId)) return void 0;
569
- try {
570
- return findPaneTabId(JSON.parse(listPanesJson), parsedPaneId);
571
- } catch (error) {
572
- debug("parseCurrentPaneTabId failed", errorMessage(error));
573
- return;
574
- }
575
- }
576
716
  function ensureZellijTarget() {
577
717
  if (process.env.ZELLIJ || process.env.ZELLIJ_SESSION_NAME) return;
578
718
  throw new Error("Zellij context not found. Run OpenCode inside Zellij or set ZELLIJ_SESSION_NAME to an existing session.");
@@ -641,11 +781,20 @@ var ZellijCli = class {
641
781
  if (!paneId) return void 0;
642
782
  return parseCurrentPaneTabId((await runZellij(zellijActionArgs("list-panes", ["--json"]), { timeoutMs: 5e3 })).stdout, paneId);
643
783
  }
784
+ async paneExists(paneId) {
785
+ return parsePaneExists((await runZellij(zellijActionArgs("list-panes", ["--json"]), { timeoutMs: 5e3 })).stdout, paneId);
786
+ }
644
787
  async renameTab(title) {
645
788
  const tabId = await this.currentPaneTabId();
646
789
  if (tabId === void 0 && process.env.ZELLIJ) throw new Error(`Could not resolve Zellij tab id for pane ${process.env.ZELLIJ_PANE_ID ?? "<missing>"}`);
647
790
  await runZellij(tabId === void 0 ? buildRenameTabActionArgs(title) : buildRenameTabActionArgs(title, { tabId }));
648
791
  }
792
+ async currentTabTitle() {
793
+ if (!process.env.ZELLIJ_PANE_ID) return void 0;
794
+ const tabId = await this.currentPaneTabId();
795
+ if (tabId === void 0) return void 0;
796
+ return parseTabName((await runZellij(zellijActionArgs("list-tabs", ["--json"]), { timeoutMs: 5e3 })).stdout, tabId);
797
+ }
649
798
  };
650
799
  const zellijCli = new ZellijCli();
651
800
  //#endregion
@@ -948,11 +1097,25 @@ function extractRenderedLines(event) {
948
1097
  var SubscriberManager = class {
949
1098
  subscribers = /* @__PURE__ */ new Map();
950
1099
  startingSessions = /* @__PURE__ */ new Map();
951
- constructor(sessions, maxBufferLines = Number(process.env.PTY_MAX_BUFFER_LINES ?? 5e4)) {
1100
+ spawnProcess;
1101
+ dumpScreen;
1102
+ closePane;
1103
+ lifecycleHooks;
1104
+ terminalTailLines;
1105
+ constructor(sessions, maxBufferLines = Number(process.env.PTY_MAX_BUFFER_LINES ?? 5e4), dependencies = {}) {
952
1106
  this.sessions = sessions;
953
1107
  this.maxBufferLines = maxBufferLines;
1108
+ this.spawnProcess = dependencies.spawn ?? spawn;
1109
+ this.dumpScreen = dependencies.dumpScreen ?? zellijCli.dumpScreen;
1110
+ this.closePane = dependencies.closePane ?? zellijCli.closePane;
1111
+ this.lifecycleHooks = dependencies.lifecycleHooks;
1112
+ this.terminalTailLines = dependencies.terminalTailLines ?? 200;
1113
+ }
1114
+ setLifecycleHooks(hooks) {
1115
+ this.lifecycleHooks = hooks;
954
1116
  }
955
1117
  async start(session) {
1118
+ if ((this.sessions.find(session.id) ?? session).status === "terminal") return;
956
1119
  if (this.subscribers.get(session.id)?.child) return;
957
1120
  const inProgress = this.startingSessions.get(session.id);
958
1121
  if (inProgress) return inProgress;
@@ -976,7 +1139,7 @@ var SubscriberManager = class {
976
1139
  lastExitedAt: null
977
1140
  };
978
1141
  if (!existing) this.subscribers.set(session.id, state);
979
- const child = spawn("zellij", zellijCommandArgs([
1142
+ const child = this.spawnProcess("zellij", zellijCommandArgs([
980
1143
  "subscribe",
981
1144
  "--pane-id",
982
1145
  session.paneId,
@@ -1003,7 +1166,7 @@ var SubscriberManager = class {
1003
1166
  child.on("exit", () => this.handleSubscriberExit(session.id, child));
1004
1167
  child.on("error", (error) => this.handleSubscriberError(session.id, child, error));
1005
1168
  if (!existing) try {
1006
- const snapshot = await zellijCli.dumpScreen(session.paneId);
1169
+ const snapshot = await this.dumpScreen(session.paneId);
1007
1170
  if (this.subscribers.get(session.id) !== state || state.child !== child) return;
1008
1171
  state.buffer.appendSnapshot(snapshot);
1009
1172
  this.sessions.updateLineCount(session.id, state.buffer.lineCount);
@@ -1024,7 +1187,8 @@ var SubscriberManager = class {
1024
1187
  return {
1025
1188
  hasBuffer: Boolean(state),
1026
1189
  active: Boolean(state?.child),
1027
- lastExitedAt: state?.lastExitedAt ?? null
1190
+ lastExitedAt: state?.lastExitedAt ?? null,
1191
+ terminal: this.sessions.find(sessionId)?.status === "terminal"
1028
1192
  };
1029
1193
  }
1030
1194
  stderr(sessionId) {
@@ -1044,13 +1208,14 @@ var SubscriberManager = class {
1044
1208
  stopAll() {
1045
1209
  for (const sessionId of this.subscribers.keys()) this.forget(sessionId);
1046
1210
  }
1047
- async closeSessionPane(sessionId) {
1211
+ async closeSessionPane(sessionId, options = {}) {
1048
1212
  const session = this.sessions.get(sessionId);
1049
1213
  this.stop(sessionId);
1050
1214
  try {
1051
- await zellijCli.closePane(session.paneId);
1215
+ await this.closePane(session.paneId);
1052
1216
  } catch (error) {
1053
1217
  debug("closePane failed", errorMessage(error));
1218
+ if (options.throwOnFailure) throw error;
1054
1219
  }
1055
1220
  }
1056
1221
  handleStdout(sessionId, child, chunk) {
@@ -1088,11 +1253,8 @@ var SubscriberManager = class {
1088
1253
  if (paneId && paneId !== session.paneId) return;
1089
1254
  const type = eventType(event);
1090
1255
  if (type === "pane_closed" || type === "PaneClosed") {
1091
- state.buffer.append(`[zellij-pty] Pane ${session.paneId} closed at ${(/* @__PURE__ */ new Date()).toISOString()}`);
1092
- this.sessions.updateLineCount(sessionId, state.buffer.lineCount);
1093
- this.sessions.updateStatus(sessionId, session.status === "killed" ? "killed" : "exited");
1256
+ this.markSessionTerminal(sessionId, "pane_closed");
1094
1257
  unregisterPaneFromWatchdog(sessionId);
1095
- this.stop(sessionId);
1096
1258
  return;
1097
1259
  }
1098
1260
  const lines = extractRenderedLines(event);
@@ -1107,7 +1269,7 @@ var SubscriberManager = class {
1107
1269
  for (const line of lines) {
1108
1270
  const marker = parseExitCodeMarker(line);
1109
1271
  if (!marker || marker.token !== session.exitCodeToken) continue;
1110
- this.sessions.markExited(sessionId, marker.exitCode);
1272
+ this.markSessionTerminal(sessionId, "exit_marker", { exitCode: marker.exitCode });
1111
1273
  return;
1112
1274
  }
1113
1275
  }
@@ -1123,7 +1285,7 @@ var SubscriberManager = class {
1123
1285
  if (state.child !== child) return;
1124
1286
  state.child = null;
1125
1287
  state.lastExitedAt = (/* @__PURE__ */ new Date()).toISOString();
1126
- state.stderr.push(`[zellij-pty] subscriber exited at ${state.lastExitedAt}; last buffered output is retained.`);
1288
+ if (this.sessions.find(sessionId)?.status !== "terminal") state.stderr.push(`[zellij-pty] subscriber exited at ${state.lastExitedAt}; last buffered output is retained.`);
1127
1289
  if (state.stderr.length > maxStderrLines) state.stderr = state.stderr.slice(state.stderr.length - maxStderrLines);
1128
1290
  }
1129
1291
  handleSubscriberError(sessionId, child, error) {
@@ -1135,6 +1297,29 @@ var SubscriberManager = class {
1135
1297
  this.sessions.updateStatus(sessionId, "unknown");
1136
1298
  }
1137
1299
  }
1300
+ markSessionTerminal(sessionId, reason, input = {}) {
1301
+ const state = this.subscribers.get(sessionId);
1302
+ if (!state) return;
1303
+ const tail = state.buffer.read({ limit: this.terminalTailLines }).lines;
1304
+ const result = this.sessions.markTerminal(sessionId, {
1305
+ reason,
1306
+ tail,
1307
+ exitCode: input.exitCode
1308
+ });
1309
+ if (result.created) try {
1310
+ const maybePromise = this.lifecycleHooks?.onSessionTerminal?.({
1311
+ sessionId,
1312
+ reason,
1313
+ session: result.session
1314
+ });
1315
+ if (maybePromise && typeof maybePromise.then === "function") maybePromise.catch((error) => {
1316
+ debug("onSessionTerminal hook failed", errorMessage(error));
1317
+ });
1318
+ } catch (error) {
1319
+ debug("onSessionTerminal hook failed", errorMessage(error));
1320
+ }
1321
+ this.stop(sessionId);
1322
+ }
1138
1323
  };
1139
1324
  const subscriberManager = new SubscriberManager(sessionManager);
1140
1325
  //#endregion
@@ -1147,14 +1332,21 @@ function publicSession(session) {
1147
1332
  command: session.command,
1148
1333
  args: session.args,
1149
1334
  cwd: session.cwd,
1150
- status: session.status,
1335
+ status: session.status === "terminal" ? "exited" : session.status,
1151
1336
  lineCount: session.lineCount,
1152
1337
  createdAt: session.createdAt,
1153
1338
  updatedAt: session.updatedAt,
1154
1339
  agentWritable: session.allowAgentInput,
1155
1340
  humanInputOnly: session.humanInputOnly,
1156
1341
  exitCode: session.exitCode,
1157
- exitedAt: session.exitedAt
1342
+ exitedAt: session.exitedAt,
1343
+ tombstone: session.tombstone ? {
1344
+ reason: session.tombstone.reason,
1345
+ terminalAt: session.tombstone.terminalAt,
1346
+ tailLines: session.tombstone.tail.length,
1347
+ paneClosedAt: session.tombstone.paneClosedAt,
1348
+ notificationSentAt: session.tombstone.notificationSentAt
1349
+ } : null
1158
1350
  };
1159
1351
  }
1160
1352
  function nextAdvice(retryable, reason) {
@@ -1211,53 +1403,90 @@ function outputMatches(sessionId, grep, ignoreCase) {
1211
1403
  }).returned > 0;
1212
1404
  }
1213
1405
  //#endregion
1406
+ //#region src/tools/pane-cleanup.ts
1407
+ async function closePaneOrVerifyGone(input) {
1408
+ try {
1409
+ await input.closePane();
1410
+ return {
1411
+ cleanupReady: true,
1412
+ alreadyGone: false
1413
+ };
1414
+ } catch (error) {
1415
+ const closeErrorMessage = error instanceof Error ? error.message : String(error);
1416
+ if (await isPaneGone(input.paneId, input.paneExists)) return {
1417
+ cleanupReady: true,
1418
+ alreadyGone: true,
1419
+ closeErrorMessage
1420
+ };
1421
+ return {
1422
+ cleanupReady: false,
1423
+ alreadyGone: false,
1424
+ closeErrorMessage
1425
+ };
1426
+ }
1427
+ }
1428
+ async function isPaneGone(paneId, paneExists) {
1429
+ if (!paneExists) return false;
1430
+ try {
1431
+ return await paneExists(paneId) === false;
1432
+ } catch {
1433
+ return false;
1434
+ }
1435
+ }
1436
+ //#endregion
1214
1437
  //#region src/tools/kill.ts
1215
1438
  const schema$4 = tool.schema;
1216
- function closeFailureMeansGone(message) {
1217
- return /not found|no such|does not exist|already closed|already gone|unknown pane/i.test(message);
1439
+ async function executeZellijPtyKill(args, dependencies = {}) {
1440
+ const zellijCliApi = dependencies.zellijCli ?? zellijCli;
1441
+ const session = sessionManager.get(args.id);
1442
+ const warnings = [];
1443
+ const output = subscriberManager.has(session.id) ? readOutputSnapshot(session.id) : void 0;
1444
+ try {
1445
+ await zellijCliApi.sendCtrlC(session.paneId);
1446
+ await setTimeout$1(500);
1447
+ } catch (error) {
1448
+ warnings.push(`Ctrl-C failed or pane was already gone: ${error instanceof Error ? error.message : String(error)}`);
1449
+ }
1450
+ const closeResult = await closePaneOrVerifyGone({
1451
+ paneId: session.paneId,
1452
+ closePane: () => zellijCliApi.closePane(session.paneId),
1453
+ paneExists: zellijCliApi.paneExists
1454
+ });
1455
+ if (!closeResult.cleanupReady) {
1456
+ warnings.push(`close-pane failed: ${closeResult.closeErrorMessage ?? "unknown error"}`);
1457
+ return {
1458
+ killed: false,
1459
+ cleanedUp: false,
1460
+ session: publicSession(sessionManager.updateStatus(session.id, "unknown")),
1461
+ output,
1462
+ next: nextAdvice(true, "close-pane failed and the pane may still be running; the session was kept so kill can be retried."),
1463
+ warnings
1464
+ };
1465
+ }
1466
+ return finalizeKilledSession(session.id, session.paneId, output, warnings);
1218
1467
  }
1219
1468
  const zellijPtyKillTool = tool({
1220
1469
  description: "Terminate a known Zellij PTY session by sending Ctrl-C, then closing its pane.",
1221
1470
  args: { id: schema$4.string().describe("zellij-pty session id.") },
1222
1471
  async execute(args) {
1223
- const session = sessionManager.get(args.id);
1224
- const warnings = [];
1225
- const output = subscriberManager.has(session.id) ? readOutputSnapshot(session.id) : void 0;
1226
- try {
1227
- await zellijCli.sendCtrlC(session.paneId);
1228
- await setTimeout$1(500);
1229
- } catch (error) {
1230
- warnings.push(`Ctrl-C failed or pane was already gone: ${error instanceof Error ? error.message : String(error)}`);
1231
- }
1232
- try {
1233
- await zellijCli.closePane(session.paneId);
1234
- } catch (error) {
1235
- const message = error instanceof Error ? error.message : String(error);
1236
- warnings.push(`close-pane failed: ${message}`);
1237
- if (!closeFailureMeansGone(message)) return jsonResponse({
1238
- killed: false,
1239
- cleanedUp: false,
1240
- session: publicSession(sessionManager.updateStatus(session.id, "unknown")),
1241
- output,
1242
- next: nextAdvice(true, "close-pane failed and the pane may still be running; the session was kept so kill can be retried."),
1243
- warnings
1244
- });
1245
- }
1246
- subscriberManager.stop(session.id);
1247
- subscriberManager.forget(session.id);
1248
- unregisterPaneFromWatchdog(session.id);
1249
- sessionManager.remove(session.id);
1250
- return jsonResponse({
1251
- killed: true,
1252
- cleanedUp: true,
1253
- id: session.id,
1254
- paneId: session.paneId,
1255
- output,
1256
- next: nextAdvice(false, "Session was closed and removed from the in-memory registry."),
1257
- warnings
1258
- });
1472
+ return jsonResponse(await executeZellijPtyKill(args));
1259
1473
  }
1260
1474
  });
1475
+ async function finalizeKilledSession(sessionId, paneId, output, warnings) {
1476
+ subscriberManager.stop(sessionId);
1477
+ subscriberManager.forget(sessionId);
1478
+ unregisterPaneFromWatchdog(sessionId);
1479
+ sessionManager.remove(sessionId);
1480
+ return {
1481
+ killed: true,
1482
+ cleanedUp: true,
1483
+ id: sessionId,
1484
+ paneId,
1485
+ output,
1486
+ next: nextAdvice(false, "Session was closed and removed from the in-memory registry."),
1487
+ warnings
1488
+ };
1489
+ }
1261
1490
  //#endregion
1262
1491
  //#region src/tools/list.ts
1263
1492
  const zellijPtyListTool = tool({
@@ -1273,55 +1502,123 @@ const zellijPtyListTool = tool({
1273
1502
  //#endregion
1274
1503
  //#region src/tools/read.ts
1275
1504
  const schema$3 = tool.schema;
1276
- const zellijPtyReadTool = tool({
1277
- description: "Read recent rendered output from a Zellij PTY session. Supports regex grep filtering.",
1278
- args: {
1279
- id: schema$3.string().describe("zellij-pty session id."),
1280
- maxLines: schema$3.number().int().positive().max(5e3).optional().describe("Maximum recent output lines to return. Defaults to 200."),
1281
- grep: schema$3.string().optional().describe("Regex used to filter returned lines."),
1282
- ignoreCase: schema$3.boolean().optional().describe("Use case-insensitive regex matching.")
1283
- },
1284
- async execute(args) {
1285
- const session = sessionManager.get(args.id);
1286
- const grepError = validateGrep(args.grep);
1287
- if (grepError) return jsonResponse({
1288
- session: publicSession(session),
1289
- output: {
1290
- text: "",
1291
- lines: [],
1292
- lineCount: session.lineCount,
1293
- returned: 0,
1294
- truncated: false
1295
- },
1296
- next: nextAdvice(false, `Invalid grep regex: ${grepError}`),
1297
- warnings: []
1298
- });
1299
- const subscriberStatus = subscriberManager.status(session.id);
1300
- if (!subscriberStatus.hasBuffer || !subscriberStatus.active && (session.status === "running" || session.status === "unknown")) await subscriberManager.start(session);
1301
- const statusAfterStart = subscriberManager.status(session.id);
1302
- const warnings = [];
1303
- if (session.humanInputOnly) warnings.push("This pane is human-input-only: agent writes are forbidden, but rendered output is visible to the agent.");
1304
- if (!statusAfterStart.active) {
1305
- warnings.push("Subscriber is inactive; returned output may be stale.");
1306
- if (session.status === "running") sessionManager.updateStatus(session.id, "unknown");
1505
+ async function executeZellijPtyRead(args, dependencies = {}) {
1506
+ const sessionManagerApi = dependencies.sessionManager ?? sessionManager;
1507
+ const subscriberManagerApi = dependencies.subscriberManager ?? subscriberManager;
1508
+ const publicSessionApi = dependencies.publicSession ?? publicSession;
1509
+ const nextAdviceApi = dependencies.nextAdvice ?? nextAdvice;
1510
+ const readOutputSnapshotApi = dependencies.readOutputSnapshot ?? readOutputSnapshot;
1511
+ const validateGrepApi = dependencies.validateGrep ?? validateGrep;
1512
+ const paneExistsApi = dependencies.paneExists ?? zellijCli.paneExists;
1513
+ const session = sessionManagerApi.get(args.id);
1514
+ const grepError = validateGrepApi(args.grep);
1515
+ if (grepError) return {
1516
+ session: publicSessionApi(session),
1517
+ output: {
1518
+ text: "",
1519
+ lines: [],
1520
+ lineCount: session.lineCount,
1521
+ returned: 0,
1522
+ truncated: false
1523
+ },
1524
+ next: nextAdviceApi(false, `Invalid grep regex: ${grepError}`),
1525
+ subscriberActive: false,
1526
+ subscriberLastExitedAt: null,
1527
+ subscriberErrors: [],
1528
+ warnings: [],
1529
+ cleanup: {
1530
+ requested: false,
1531
+ performed: false,
1532
+ alreadyClosed: false
1307
1533
  }
1308
- const output = readOutputSnapshot(session.id, {
1309
- maxLines: args.maxLines,
1310
- grep: args.grep,
1311
- ignoreCase: args.ignoreCase
1312
- });
1313
- return jsonResponse({
1314
- session: publicSession(session),
1315
- output,
1316
- next: nextAdvice(session.status !== "exited" && session.status !== "killed", nextReadReason(session.status)),
1317
- subscriberActive: statusAfterStart.active,
1318
- subscriberLastExitedAt: statusAfterStart.lastExitedAt,
1319
- subscriberErrors: subscriberManager.stderr(session.id),
1320
- warnings
1321
- });
1534
+ };
1535
+ const subscriberStatus = subscriberManagerApi.status(session.id);
1536
+ if (!subscriberStatus.hasBuffer || !subscriberStatus.active && (session.status === "running" || session.status === "unknown")) await subscriberManagerApi.start(session);
1537
+ const statusAfterStart = subscriberManagerApi.status(session.id);
1538
+ const warnings = [];
1539
+ if (session.humanInputOnly) warnings.push("This pane is human-input-only: agent writes are forbidden, but rendered output is visible to the agent.");
1540
+ if (!statusAfterStart.active && session.status === "running") {
1541
+ warnings.push("Subscriber is inactive; returned output may be stale.");
1542
+ sessionManagerApi.updateStatus(session.id, "unknown");
1543
+ }
1544
+ const output = readOutputSnapshotApi(session.id, {
1545
+ maxLines: args.maxLines,
1546
+ grep: args.grep,
1547
+ ignoreCase: args.ignoreCase
1548
+ });
1549
+ const cleanup = await cleanupExitedPaneOnRead(session.id, session.status, args.cleanupExitedPaneOnRead ?? dependencies.defaultCleanupExitedPaneOnRead ?? true, {
1550
+ sessionManager: sessionManagerApi,
1551
+ subscriberManager: subscriberManagerApi,
1552
+ paneExists: paneExistsApi
1553
+ });
1554
+ if (cleanup.warning) warnings.push(cleanup.warning);
1555
+ return {
1556
+ session: publicSessionApi(session),
1557
+ output,
1558
+ next: nextAdviceApi(!isCompletedSession(session.status), nextReadReason(session.status)),
1559
+ subscriberActive: statusAfterStart.active,
1560
+ subscriberLastExitedAt: statusAfterStart.lastExitedAt,
1561
+ subscriberErrors: subscriberManagerApi.stderr(session.id),
1562
+ warnings,
1563
+ cleanup
1564
+ };
1565
+ }
1566
+ async function cleanupExitedPaneOnRead(sessionId, status, enabled, dependencies) {
1567
+ if (!(enabled && isCompletedSession(status))) return {
1568
+ requested: false,
1569
+ performed: false,
1570
+ alreadyClosed: false
1571
+ };
1572
+ const session = dependencies.sessionManager.get(sessionId);
1573
+ if (session.tombstone?.paneClosedAt) return {
1574
+ requested: true,
1575
+ performed: false,
1576
+ alreadyClosed: true
1577
+ };
1578
+ const closeResult = await closePaneOrVerifyGone({
1579
+ paneId: session.paneId,
1580
+ closePane: () => dependencies.subscriberManager.closeSessionPane(sessionId, { throwOnFailure: true }),
1581
+ paneExists: dependencies.paneExists
1582
+ });
1583
+ if (closeResult.cleanupReady) {
1584
+ dependencies.sessionManager.markTerminalPaneClosed(sessionId);
1585
+ return {
1586
+ requested: true,
1587
+ performed: true,
1588
+ alreadyClosed: closeResult.alreadyGone
1589
+ };
1322
1590
  }
1323
- });
1591
+ return {
1592
+ requested: true,
1593
+ performed: false,
1594
+ alreadyClosed: false,
1595
+ warning: `Completed pane cleanup failed: ${closeResult.closeErrorMessage ?? "unknown error"}`
1596
+ };
1597
+ }
1598
+ function createZellijPtyReadTool(options = {}) {
1599
+ return tool({
1600
+ description: "Read recent rendered output from a Zellij PTY session. Supports regex grep filtering.",
1601
+ args: {
1602
+ id: schema$3.string().describe("zellij-pty session id."),
1603
+ maxLines: schema$3.number().int().positive().max(5e3).optional().describe("Maximum recent output lines to return. Defaults to 200."),
1604
+ grep: schema$3.string().optional().describe("Regex used to filter returned lines."),
1605
+ ignoreCase: schema$3.boolean().optional().describe("Use case-insensitive regex matching."),
1606
+ cleanupExitedPaneOnRead: schema$3.boolean().optional().describe("Close completed panes after returning the final output. Defaults to true.")
1607
+ },
1608
+ async execute(args) {
1609
+ return jsonResponse(await executeZellijPtyRead(args, {
1610
+ ...options.dependencies,
1611
+ defaultCleanupExitedPaneOnRead: options.defaultCleanupExitedPaneOnRead
1612
+ }));
1613
+ }
1614
+ });
1615
+ }
1616
+ createZellijPtyReadTool();
1617
+ function isCompletedSession(status) {
1618
+ return status === "terminal" || status === "exited" || status === "killed";
1619
+ }
1324
1620
  function nextReadReason(status) {
1621
+ if (status === "terminal") return "Session has finished; the final output is retained until the completed pane is read and cleaned up.";
1325
1622
  if (status === "running") return "Session is still running; read again later if more output is expected.";
1326
1623
  if (status === "unknown") return "Session state is unknown because the subscriber is inactive; output may be stale, but retrying read may restart observation.";
1327
1624
  return "Session is no longer running.";
@@ -1616,6 +1913,395 @@ const zellijPtyWriteTool = tool({
1616
1913
  }
1617
1914
  });
1618
1915
  //#endregion
1916
+ //#region src/zellij/tab-title-status-snapshot.ts
1917
+ /**
1918
+ * Events that should trigger a debounced refresh of the tab title base status
1919
+ * via the `/session/status` snapshot API.
1920
+ *
1921
+ * Base state (running vs idle) is sourced from the snapshot rather than
1922
+ * individual `session.idle` events because testing showed that both parent and
1923
+ * child sessions report busy during subagent execution, and the parent remains
1924
+ * busy even after the child completes. The snapshot gives a consistent,
1925
+ * server-authoritative view. `needs-input` has no REST API, so it continues
1926
+ * to be managed purely through events.
1927
+ *
1928
+ * The snapshot reconciliation is intentionally *not* optimistic for idle-like
1929
+ * transitions: a lone parent/child idle event can be stale during subagent
1930
+ * handoff. The debounce coalesces high-frequency event streams to avoid
1931
+ * hammering the API on every individual status change.
1932
+ */
1933
+ function shouldRefreshTabTitleStatusSnapshot(event) {
1934
+ switch (event.type) {
1935
+ case "session.status":
1936
+ case "session.idle":
1937
+ case "session.error":
1938
+ case "session.created":
1939
+ case "session.deleted":
1940
+ case "question.asked":
1941
+ case "question.replied":
1942
+ case "question.rejected":
1943
+ case "permission.asked":
1944
+ case "permission.replied":
1945
+ case "permission.updated": return true;
1946
+ default: return false;
1947
+ }
1948
+ }
1949
+ /**
1950
+ * Best-effort fetch of session statuses for a workspace.
1951
+ *
1952
+ * Only accepts object-keyed maps: `{ [sessionID]: SessionStatus }` directly,
1953
+ * or the generated-client envelope `{ data: { [sessionID]: SessionStatus } }`.
1954
+ *
1955
+ * An empty map `{}` is a valid snapshot (all sessions ended / none tracked).
1956
+ * Arrays are never accepted — they always return undefined.
1957
+ * If any single status entry fails to parse, the entire snapshot is rejected
1958
+ * (no partial apply) to avoid incorrectly clearing session states.
1959
+ *
1960
+ * Failures are swallowed and return undefined so the caller never throws.
1961
+ */
1962
+ async function fetchSessionStatusSnapshot(client, workspaceRoot) {
1963
+ try {
1964
+ if (!client.session?.status) {
1965
+ debug("fetchSessionStatusSnapshot: client.session.status not available");
1966
+ return;
1967
+ }
1968
+ const result = await client.session.status({ query: { directory: workspaceRoot } });
1969
+ const payload = result && typeof result === "object" && "data" in result ? result.data : result;
1970
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
1971
+ debug("fetchSessionStatusSnapshot received non-object payload");
1972
+ return;
1973
+ }
1974
+ const entries = Object.entries(payload);
1975
+ if (entries.length === 0) return {};
1976
+ const snapshot = {};
1977
+ for (const [sessionID, status] of entries) {
1978
+ const parsed = parseSessionStatus(status);
1979
+ if (parsed === void 0) {
1980
+ debug("fetchSessionStatusSnapshot received invalid status entry, rejecting entire snapshot");
1981
+ return;
1982
+ }
1983
+ snapshot[sessionID] = parsed;
1984
+ }
1985
+ return snapshot;
1986
+ } catch (err) {
1987
+ debug("fetchSessionStatusSnapshot failed", errorMessage(err));
1988
+ return;
1989
+ }
1990
+ }
1991
+ function parseSessionStatus(value) {
1992
+ if (!value || typeof value !== "object" || !("type" in value)) return void 0;
1993
+ const status = value;
1994
+ if (status.type === "idle" || status.type === "busy") return { type: status.type };
1995
+ if (status.type === "retry") return {
1996
+ type: "retry",
1997
+ attempt: typeof status.attempt === "number" ? status.attempt : 0,
1998
+ message: typeof status.message === "string" ? status.message : "",
1999
+ next: typeof status.next === "number" ? status.next : 0
2000
+ };
2001
+ }
2002
+ const DEFAULT_DEBOUNCE_MS = 1e3;
2003
+ /**
2004
+ * Encapsulates tab title session-status snapshot fetching with debounced refresh.
2005
+ *
2006
+ * This class replaces the nested `refreshTabTitleSnapshot` /
2007
+ * `scheduleTabTitleSnapshotRefresh` functions that previously lived inside
2008
+ * `createZellijPtyPlugin`. It manages its own timer so the plugin factory
2009
+ * remains a simple composition root.
2010
+ *
2011
+ * Usage:
2012
+ * ```
2013
+ * const refresher = tabTitleManager
2014
+ * ? new TabTitleStatusSnapshotRefresher({ client, workspaceRoot, manager: tabTitleManager })
2015
+ * : undefined
2016
+ *
2017
+ * await refresher?.refreshNow() // initial snapshot
2018
+ * refresher?.scheduleRefresh() // on relevant events
2019
+ * refresher?.dispose() // on shutdown
2020
+ * ```
2021
+ */
2022
+ var TabTitleStatusSnapshotRefresher = class {
2023
+ client;
2024
+ workspaceRoot;
2025
+ manager;
2026
+ debounceMs;
2027
+ timer;
2028
+ constructor(options) {
2029
+ this.client = options.client;
2030
+ this.workspaceRoot = options.workspaceRoot;
2031
+ this.manager = options.manager;
2032
+ this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
2033
+ }
2034
+ /**
2035
+ * Fetches and applies the snapshot immediately, cancelling any pending
2036
+ * debounced refresh.
2037
+ */
2038
+ async refreshNow() {
2039
+ this.clearTimer();
2040
+ const snapshot = await fetchSessionStatusSnapshot(this.client, this.workspaceRoot);
2041
+ if (snapshot !== void 0) this.manager.applySessionStatusSnapshot(snapshot);
2042
+ }
2043
+ /**
2044
+ * Schedules a debounced snapshot refresh. Subsequent calls while a timer
2045
+ * is pending coalesce into a single refresh.
2046
+ */
2047
+ scheduleRefresh() {
2048
+ if (this.timer) return;
2049
+ this.timer = setTimeout(() => {
2050
+ this.timer = void 0;
2051
+ this.refreshNow().catch((err) => debug("tab title snapshot refresh failed", errorMessage(err)));
2052
+ }, this.debounceMs);
2053
+ }
2054
+ /**
2055
+ * Clears any pending debounced refresh. Use this during shutdown so a
2056
+ * pending timer does not fire after the manager has been destroyed.
2057
+ */
2058
+ dispose() {
2059
+ this.clearTimer();
2060
+ }
2061
+ clearTimer() {
2062
+ if (this.timer) {
2063
+ clearTimeout(this.timer);
2064
+ this.timer = void 0;
2065
+ }
2066
+ }
2067
+ };
2068
+ //#endregion
2069
+ //#region src/zellij/completion-notifications.ts
2070
+ const completionTitle = "Zellij PTY session completed";
2071
+ const completionMessage = "A Zellij PTY session completed. Review the finished pane if needed.";
2072
+ const queuedNoticeHeader = "[OpenCode] Zellij PTY completion notice";
2073
+ function buildQueuedCompletionNotice(events) {
2074
+ return [queuedNoticeHeader, ...events.map((event) => `- ${event.session.id} (${event.session.paneId}) 已完成,請使用 zellij_pty_read 讀取最終輸出並清理 pane。`)].join("\n");
2075
+ }
2076
+ function buildCompletionPromptRequest(event) {
2077
+ return {
2078
+ path: { id: event.session.openCodeSessionId },
2079
+ body: { parts: [{
2080
+ type: "text",
2081
+ text: completionMessage
2082
+ }] }
2083
+ };
2084
+ }
2085
+ function injectQueuedCompletionNotice(input, notice) {
2086
+ if (typeof input === "string") return `${notice}\n\n${input}`;
2087
+ if (!input || typeof input !== "object") return input;
2088
+ const record = input;
2089
+ if (Array.isArray(record.parts)) return {
2090
+ ...record,
2091
+ parts: [{
2092
+ type: "text",
2093
+ text: notice
2094
+ }, ...record.parts]
2095
+ };
2096
+ if (typeof record.message === "string") return {
2097
+ ...record,
2098
+ message: `${notice}\n\n${record.message}`
2099
+ };
2100
+ if (typeof record.content === "string") return {
2101
+ ...record,
2102
+ content: `${notice}\n\n${record.content}`
2103
+ };
2104
+ if (typeof record.text === "string") return {
2105
+ ...record,
2106
+ text: `${notice}\n\n${record.text}`
2107
+ };
2108
+ return {
2109
+ ...record,
2110
+ message: notice
2111
+ };
2112
+ }
2113
+ function evaluateCompletionPromptDecision(input) {
2114
+ if (input.config.mode !== "prompt") return {
2115
+ shouldPrompt: false,
2116
+ shouldQueue: false,
2117
+ reason: "prompt mode disabled"
2118
+ };
2119
+ if (input.event.session.humanInputOnly || !input.event.session.allowAgentInput) return {
2120
+ shouldPrompt: false,
2121
+ shouldQueue: true,
2122
+ reason: "human-input-only session"
2123
+ };
2124
+ if (!input.promptClientAvailable) return {
2125
+ shouldPrompt: false,
2126
+ shouldQueue: true,
2127
+ reason: "prompt client unavailable"
2128
+ };
2129
+ if (!input.event.session.openCodeSessionId) return {
2130
+ shouldPrompt: false,
2131
+ shouldQueue: true,
2132
+ reason: "session id unavailable"
2133
+ };
2134
+ if (!input.snapshotAvailable) return {
2135
+ shouldPrompt: false,
2136
+ shouldQueue: true,
2137
+ reason: "session status snapshot unavailable"
2138
+ };
2139
+ if (input.config.prompt.maxAttempts <= 0 || input.promptAttemptCount >= input.config.prompt.maxAttempts) return {
2140
+ shouldPrompt: false,
2141
+ shouldQueue: true,
2142
+ reason: "prompt max attempts reached"
2143
+ };
2144
+ if (input.config.prompt.cooldownMs > 0 && input.lastPromptAttemptAt !== null && input.now - input.lastPromptAttemptAt < input.config.prompt.cooldownMs) return {
2145
+ shouldPrompt: false,
2146
+ shouldQueue: true,
2147
+ reason: "prompt cooldown active"
2148
+ };
2149
+ if (input.config.prompt.requireIdle) {
2150
+ const sessionId = input.event.session.openCodeSessionId;
2151
+ if (!sessionId) return {
2152
+ shouldPrompt: false,
2153
+ shouldQueue: true,
2154
+ reason: "session status unavailable"
2155
+ };
2156
+ const status = sessionId ? input.snapshot?.[sessionId] : void 0;
2157
+ if (!status) return {
2158
+ shouldPrompt: false,
2159
+ shouldQueue: true,
2160
+ reason: "session status unavailable"
2161
+ };
2162
+ if (status && status.type !== "idle") return {
2163
+ shouldPrompt: false,
2164
+ shouldQueue: true,
2165
+ reason: "session not idle"
2166
+ };
2167
+ }
2168
+ return {
2169
+ shouldPrompt: true,
2170
+ shouldQueue: false,
2171
+ reason: "prompt allowed"
2172
+ };
2173
+ }
2174
+ var SessionCompletionNotificationQueue = class {
2175
+ states = /* @__PURE__ */ new Map();
2176
+ constructor(context, hooks = {}, clock = () => Date.now()) {
2177
+ this.context = context;
2178
+ this.hooks = hooks;
2179
+ this.clock = clock;
2180
+ }
2181
+ hasPending(sessionId) {
2182
+ return this.states.get(sessionId)?.queued ?? false;
2183
+ }
2184
+ clearSession(sessionId) {
2185
+ this.states.delete(sessionId);
2186
+ }
2187
+ clearAll() {
2188
+ this.states.clear();
2189
+ }
2190
+ dispose() {
2191
+ this.clearAll();
2192
+ }
2193
+ async handleSessionTerminal(event) {
2194
+ if (this.context.config.mode === "off") return;
2195
+ if (this.states.has(event.sessionId) || event.session.tombstone?.notificationSentAt) return;
2196
+ const state = {
2197
+ event,
2198
+ queued: false,
2199
+ toastSent: false,
2200
+ promptAttempts: 0,
2201
+ promptAttemptedAt: null
2202
+ };
2203
+ this.states.set(event.sessionId, state);
2204
+ switch (this.context.config.mode) {
2205
+ case "queue":
2206
+ state.queued = true;
2207
+ return;
2208
+ case "toast":
2209
+ await this.sendToast(state);
2210
+ this.finalize(state);
2211
+ return;
2212
+ case "queue+toast":
2213
+ state.queued = true;
2214
+ await this.sendToast(state);
2215
+ return;
2216
+ case "prompt":
2217
+ await this.tryPromptOrQueue(state);
2218
+ break;
2219
+ default:
2220
+ }
2221
+ }
2222
+ injectQueuedChatMessage(input) {
2223
+ const pending = [...this.states.values()].filter((state) => state.queued);
2224
+ if (pending.length === 0) return input;
2225
+ const notice = buildQueuedCompletionNotice(pending.map((state) => state.event));
2226
+ for (const state of pending) {
2227
+ if (!state.toastSent) this.context.markSent(state.event.sessionId);
2228
+ this.finalize(state);
2229
+ }
2230
+ return injectQueuedCompletionNotice(input, notice);
2231
+ }
2232
+ async tryPromptOrQueue(state) {
2233
+ if (!state.event.session.openCodeSessionId) {
2234
+ state.queued = true;
2235
+ return;
2236
+ }
2237
+ const session = this.context.client.session;
2238
+ const prompt = session?.prompt ?? session?.promptAsync;
2239
+ const statusSnapshot = await fetchSessionStatusSnapshot(this.context.client, this.context.workspaceRoot);
2240
+ const decision = evaluateCompletionPromptDecision({
2241
+ event: state.event,
2242
+ config: this.context.config,
2243
+ snapshot: statusSnapshot,
2244
+ snapshotAvailable: statusSnapshot !== void 0,
2245
+ now: this.clock(),
2246
+ lastPromptAttemptAt: state.promptAttemptedAt,
2247
+ promptAttemptCount: state.promptAttempts,
2248
+ promptClientAvailable: Boolean(prompt)
2249
+ });
2250
+ if (!decision.shouldPrompt) {
2251
+ state.queued = decision.shouldQueue;
2252
+ return;
2253
+ }
2254
+ if (this.hooks.prompt) try {
2255
+ const maybePromise = this.hooks.prompt(state.event);
2256
+ if (maybePromise && typeof maybePromise.then === "function") await maybePromise;
2257
+ } catch (error) {
2258
+ debug("completion notification prompt hook failed", errorMessage(error));
2259
+ }
2260
+ if (!session || !prompt) {
2261
+ state.queued = true;
2262
+ return;
2263
+ }
2264
+ state.promptAttempts += 1;
2265
+ state.promptAttemptedAt = this.clock();
2266
+ try {
2267
+ if (session.prompt) await session.prompt(buildCompletionPromptRequest(state.event));
2268
+ else if (session.promptAsync) await session.promptAsync(buildCompletionPromptRequest(state.event));
2269
+ else {
2270
+ state.queued = true;
2271
+ return;
2272
+ }
2273
+ this.context.markSent(state.event.sessionId);
2274
+ this.finalize(state);
2275
+ } catch (error) {
2276
+ debug("completion notification prompt failed", errorMessage(error));
2277
+ state.queued = true;
2278
+ }
2279
+ }
2280
+ async sendToast(state) {
2281
+ const toast = this.context.client.tui?.showToast;
2282
+ if (!toast) {
2283
+ debug("completion notification toast skipped: client.tui.showToast unavailable");
2284
+ return;
2285
+ }
2286
+ try {
2287
+ await toast({ body: {
2288
+ title: completionTitle,
2289
+ message: completionMessage,
2290
+ variant: "success",
2291
+ duration: 1e4
2292
+ } });
2293
+ state.toastSent = true;
2294
+ this.context.markSent(state.event.sessionId);
2295
+ if (!state.queued) this.finalize(state);
2296
+ } catch (error) {
2297
+ debug("completion notification toast failed", errorMessage(error));
2298
+ }
2299
+ }
2300
+ finalize(state) {
2301
+ this.states.delete(state.event.sessionId);
2302
+ }
2303
+ };
2304
+ //#endregion
1619
2305
  //#region src/zellij/shutdown-cleanup.ts
1620
2306
  let registered = false;
1621
2307
  let cleanedUp = false;
@@ -1712,25 +2398,18 @@ function shouldReadInitialBranch(zellij) {
1712
2398
  return Boolean(zellij);
1713
2399
  }
1714
2400
  function handleTabTitleEvent(tabTitleManager, event) {
2401
+ if (event.type === "server.instance.disposed" || event.type === "global.disposed") return tabTitleManager.destroy?.();
1715
2402
  if (!isRecord(event.properties)) return;
1716
2403
  const properties = event.properties;
1717
2404
  switch (event.type) {
1718
2405
  case "session.status": {
1719
2406
  const sessionID = stringProperty(properties, "sessionID");
1720
2407
  const status = sessionStatusProperty(properties);
1721
- if (sessionID && status) tabTitleManager.updateSessionStatus(sessionID, status);
1722
- break;
1723
- }
1724
- case "session.idle": {
1725
- const sessionID = stringProperty(properties, "sessionID");
1726
- if (sessionID) tabTitleManager.markSessionIdle(sessionID);
1727
- break;
1728
- }
1729
- case "session.error": {
1730
- const sessionID = stringProperty(properties, "sessionID");
1731
- if (sessionID) tabTitleManager.markSessionIdle(sessionID);
2408
+ if (sessionID && status && status.type !== "idle") tabTitleManager.updateSessionStatus(sessionID, status);
1732
2409
  break;
1733
2410
  }
2411
+ case "session.idle": break;
2412
+ case "session.error": break;
1734
2413
  case "vcs.branch.updated":
1735
2414
  tabTitleManager.setBranch(stringProperty(properties, "branch"));
1736
2415
  break;
@@ -1738,22 +2417,32 @@ function handleTabTitleEvent(tabTitleManager, event) {
1738
2417
  case "permission.asked": {
1739
2418
  const id = inputRequestID(properties);
1740
2419
  const sessionID = stringProperty(properties, "sessionID");
1741
- if (id && sessionID) tabTitleManager.markNeedsInput(id, sessionID);
2420
+ if (id && sessionID) {
2421
+ tabTitleManager.markNeedsInput(id, sessionID);
2422
+ tabTitleManager.updateSessionStatus(sessionID, { type: "busy" });
2423
+ }
1742
2424
  break;
1743
2425
  }
1744
2426
  case "permission.updated": {
1745
2427
  const id = inputRequestID(properties);
1746
2428
  const sessionID = stringProperty(properties, "sessionID");
1747
2429
  const state = inputState(properties);
1748
- if (id && isResolvedInputState(state)) tabTitleManager.clearNeedsInput(id);
1749
- else if (id && sessionID) tabTitleManager.markNeedsInput(id, sessionID);
2430
+ if (id && isResolvedInputState(state)) {
2431
+ tabTitleManager.clearNeedsInput(id);
2432
+ if (sessionID) tabTitleManager.updateSessionStatus(sessionID, { type: "busy" });
2433
+ } else if (id && sessionID) {
2434
+ tabTitleManager.markNeedsInput(id, sessionID);
2435
+ tabTitleManager.updateSessionStatus(sessionID, { type: "busy" });
2436
+ }
1750
2437
  break;
1751
2438
  }
1752
2439
  case "question.replied":
1753
2440
  case "question.rejected":
1754
2441
  case "permission.replied": {
1755
2442
  const id = inputRequestID(properties);
2443
+ const sessionID = stringProperty(properties, "sessionID");
1756
2444
  if (id) tabTitleManager.clearNeedsInput(id);
2445
+ if (sessionID) tabTitleManager.updateSessionStatus(sessionID, { type: "busy" });
1757
2446
  break;
1758
2447
  }
1759
2448
  case "session.deleted": {
@@ -1761,10 +2450,6 @@ function handleTabTitleEvent(tabTitleManager, event) {
1761
2450
  if (sessionID) tabTitleManager.removeSession(sessionID);
1762
2451
  break;
1763
2452
  }
1764
- case "server.instance.disposed":
1765
- case "global.disposed":
1766
- tabTitleManager.destroy?.();
1767
- break;
1768
2453
  }
1769
2454
  }
1770
2455
  //#endregion
@@ -1795,6 +2480,7 @@ var TabTitleManager = class {
1795
2480
  retryTimer;
1796
2481
  retryAttempt = 0;
1797
2482
  syncInFlight = false;
2483
+ syncPromise;
1798
2484
  debounceMs;
1799
2485
  retryInitialMs;
1800
2486
  retryMaxMs;
@@ -1803,6 +2489,10 @@ var TabTitleManager = class {
1803
2489
  emojis;
1804
2490
  enabled;
1805
2491
  destroyed = false;
2492
+ originalTabTitle;
2493
+ originalTabTitleLoaded = false;
2494
+ originalTabTitlePromise;
2495
+ destroyPromise;
1806
2496
  constructor(options) {
1807
2497
  this.projectName = options.projectName;
1808
2498
  this.branchName = options.branchName?.trim() || void 0;
@@ -1822,6 +2512,27 @@ var TabTitleManager = class {
1822
2512
  this.branchName = trimmed;
1823
2513
  this.scheduleUpdate();
1824
2514
  }
2515
+ /**
2516
+ * Applies a snapshot of session statuses from the server.
2517
+ *
2518
+ * This replaces the entire base status map. Sessions absent from the snapshot
2519
+ * (e.g. because they ended) are removed so stale busy/idle entries do not
2520
+ * persist. This is the authoritative source for the "running vs idle" base
2521
+ * state; individual session.status events still perform optimistic updates
2522
+ * for immediacy but the snapshot corrects drift.
2523
+ *
2524
+ * The `needs-input` overlay remains independent — it is managed purely by
2525
+ * events (question/permission asked/replied/etc.) and always takes priority
2526
+ * over the snapshot base when computing the displayed title.
2527
+ */
2528
+ applySessionStatusSnapshot(statuses) {
2529
+ for (const sessionID of this.sessionStatuses.keys()) if (!(sessionID in statuses)) this.sessionStatuses.delete(sessionID);
2530
+ for (const [sessionID, status] of Object.entries(statuses)) {
2531
+ const activity = status.type === "idle" ? "idle" : "running";
2532
+ if (this.sessionStatuses.get(sessionID) !== activity) this.sessionStatuses.set(sessionID, activity);
2533
+ }
2534
+ this.scheduleUpdate();
2535
+ }
1825
2536
  updateSessionStatus(sessionID, status) {
1826
2537
  const activity = status.type === "idle" ? "idle" : "running";
1827
2538
  if (this.sessionStatuses.get(sessionID) === activity) return;
@@ -1875,6 +2586,8 @@ var TabTitleManager = class {
1875
2586
  }
1876
2587
  async renderImmediate() {
1877
2588
  if (!this.enabled || this.destroyed) return;
2589
+ await this.ensureOriginalTabTitle();
2590
+ if (this.destroyed) return;
1878
2591
  this.desiredTitle = this.buildTitle();
1879
2592
  this.clearDebounceTimer();
1880
2593
  await this.syncDesiredTitle();
@@ -1895,8 +2608,14 @@ var TabTitleManager = class {
1895
2608
  }
1896
2609
  async syncDesiredTitle() {
1897
2610
  if (!this.enabled || this.destroyed) return;
1898
- if (this.syncInFlight) return;
2611
+ await this.ensureOriginalTabTitle();
2612
+ if (this.destroyed) return;
2613
+ if (this.syncInFlight) return this.syncPromise;
1899
2614
  this.syncInFlight = true;
2615
+ this.syncPromise = this.runTitleSync();
2616
+ return this.syncPromise;
2617
+ }
2618
+ async runTitleSync() {
1900
2619
  try {
1901
2620
  while (this.desiredTitle && this.desiredTitle !== this.lastSyncedTitle) {
1902
2621
  const title = this.desiredTitle;
@@ -1913,6 +2632,7 @@ var TabTitleManager = class {
1913
2632
  }
1914
2633
  } finally {
1915
2634
  this.syncInFlight = false;
2635
+ this.syncPromise = void 0;
1916
2636
  }
1917
2637
  }
1918
2638
  scheduleRetry() {
@@ -1936,21 +2656,52 @@ var TabTitleManager = class {
1936
2656
  if (this.debounceTimer) clearTimeout(this.debounceTimer);
1937
2657
  this.debounceTimer = void 0;
1938
2658
  }
2659
+ async ensureOriginalTabTitle() {
2660
+ if (!this.enabled || this.originalTabTitleLoaded) return;
2661
+ if (this.originalTabTitlePromise) return this.originalTabTitlePromise;
2662
+ this.originalTabTitlePromise = this.saveOriginalTabTitle();
2663
+ return this.originalTabTitlePromise;
2664
+ }
2665
+ async saveOriginalTabTitle() {
2666
+ try {
2667
+ const title = await this.cli.currentTabTitle();
2668
+ if (title !== void 0) this.originalTabTitle = title;
2669
+ } catch (error) {
2670
+ debug("TabTitleManager failed to save original tab title", errorMessage(error));
2671
+ } finally {
2672
+ this.originalTabTitleLoaded = true;
2673
+ this.originalTabTitlePromise = void 0;
2674
+ }
2675
+ }
1939
2676
  destroy() {
2677
+ if (this.destroyed) return this.destroyPromise ?? Promise.resolve();
1940
2678
  this.destroyed = true;
1941
2679
  this.clearDebounceTimer();
1942
2680
  this.clearRetryTimer();
2681
+ if (!this.enabled) return Promise.resolve();
2682
+ this.destroyPromise = this.restoreOriginalTabTitle().catch((error) => debug("TabTitleManager failed to restore original tab title", errorMessage(error)));
2683
+ return this.destroyPromise;
2684
+ }
2685
+ async restoreOriginalTabTitle() {
2686
+ await this.originalTabTitlePromise;
2687
+ await this.syncPromise;
2688
+ const originalTitle = this.originalTabTitle;
2689
+ this.originalTabTitle = void 0;
2690
+ if (originalTitle === void 0) return;
2691
+ await this.cli.renameTab(originalTitle);
1943
2692
  }
1944
2693
  };
1945
2694
  //#endregion
1946
2695
  //#region src/plugin.ts
1947
- const ptyTools = {
1948
- zellij_pty_spawn: zellijPtySpawnTool,
1949
- zellij_pty_list: zellijPtyListTool,
1950
- zellij_pty_write: zellijPtyWriteTool,
1951
- zellij_pty_read: zellijPtyReadTool,
1952
- zellij_pty_kill: zellijPtyKillTool
1953
- };
2696
+ function createPtyTools(defaultCleanupExitedPaneOnRead) {
2697
+ return {
2698
+ zellij_pty_spawn: zellijPtySpawnTool,
2699
+ zellij_pty_list: zellijPtyListTool,
2700
+ zellij_pty_write: zellijPtyWriteTool,
2701
+ zellij_pty_read: createZellijPtyReadTool({ defaultCleanupExitedPaneOnRead }),
2702
+ zellij_pty_kill: zellijPtyKillTool
2703
+ };
2704
+ }
1954
2705
  function getProjectName(path) {
1955
2706
  return path.split(/[/\\]/).filter(Boolean).pop() || "opencode";
1956
2707
  }
@@ -2014,22 +2765,67 @@ function createZellijPtyPlugin(dependencies = {}) {
2014
2765
  branch: config.tabTitle.emojiBranch
2015
2766
  }
2016
2767
  }) : void 0;
2017
- tabTitleManager?.renderImmediate().catch((error) => debug("initial tab title render failed", errorMessage(error)));
2018
2768
  const client = input.client;
2769
+ const completionNotifications = config.pty.completionNotification.mode === "off" ? void 0 : dependencies.createCompletionNotifications?.({
2770
+ client,
2771
+ workspaceRoot,
2772
+ config: config.pty.completionNotification,
2773
+ markSent(sessionId) {
2774
+ try {
2775
+ sessionManager.markTerminalNotificationSent(sessionId);
2776
+ } catch (error) {
2777
+ debug("mark terminal notification sent failed", errorMessage(error));
2778
+ }
2779
+ }
2780
+ }) ?? new SessionCompletionNotificationQueue({
2781
+ client,
2782
+ workspaceRoot,
2783
+ config: config.pty.completionNotification,
2784
+ markSent(sessionId) {
2785
+ try {
2786
+ sessionManager.markTerminalNotificationSent(sessionId);
2787
+ } catch (error) {
2788
+ debug("mark terminal notification sent failed", errorMessage(error));
2789
+ }
2790
+ }
2791
+ });
2792
+ subscriberManager.setLifecycleHooks(completionNotifications ? { onSessionTerminal: (event) => void completionNotifications.handleSessionTerminal(event).catch((error) => debug("completion notification lifecycle hook failed", errorMessage(error))) } : void 0);
2793
+ const tabTitleSnapshotRefresher = tabTitleManager ? new TabTitleStatusSnapshotRefresher({
2794
+ client,
2795
+ workspaceRoot,
2796
+ manager: tabTitleManager,
2797
+ debounceMs: 1e3
2798
+ }) : void 0;
2799
+ await tabTitleSnapshotRefresher?.refreshNow();
2800
+ tabTitleManager?.renderImmediate().catch((error) => debug("initial tab title render failed", errorMessage(error)));
2019
2801
  if (config.autoUpdate) (dependencies.startAutoUpdateCheck ?? startAutoUpdateCheck)(client, dependencies.importMetaUrl ?? import.meta.url);
2020
2802
  return {
2021
2803
  async event(input) {
2022
2804
  const event = input.event;
2023
- if (tabTitleManager) handleTabTitleEvent(tabTitleManager, event);
2805
+ if (tabTitleManager) {
2806
+ if (event.type === "server.instance.disposed" || event.type === "global.disposed") tabTitleSnapshotRefresher?.dispose();
2807
+ await handleTabTitleEvent(tabTitleManager, event);
2808
+ if (shouldRefreshTabTitleStatusSnapshot(event)) tabTitleSnapshotRefresher?.scheduleRefresh();
2809
+ }
2810
+ if (event.type === "server.instance.disposed" || event.type === "global.disposed") {
2811
+ completionNotifications?.clearAll();
2812
+ completionNotifications?.dispose();
2813
+ subscriberManager.setLifecycleHooks(void 0);
2814
+ }
2024
2815
  if (event.type === "session.deleted") {
2025
2816
  const sessionID = deletedSessionID(event);
2026
2817
  if (!sessionID) return;
2027
2818
  const sessions = sessionManager.listByOpenCodeSession(sessionID);
2819
+ for (const session of sessions) completionNotifications?.clearSession(session.id);
2028
2820
  await Promise.all(sessions.map((session) => cleanupDeletedSession(session.id)));
2029
2821
  }
2030
2822
  },
2031
- tool: config.pty.enabled ? {
2032
- ...ptyTools,
2823
+ "chat.message": async (_input, output) => {
2824
+ const injected = completionNotifications?.injectQueuedChatMessage(output) ?? output;
2825
+ if (injected !== output && injected && typeof injected === "object" && Array.isArray(injected.parts)) output.parts = injected.parts;
2826
+ },
2827
+ "tool": config.pty.enabled ? {
2828
+ ...createPtyTools(config.pty.cleanupExitedPaneOnRead),
2033
2829
  ...config.pty.sudoPane === "hide" ? {} : { zellij_pty_request_sudo: requestSudoTool }
2034
2830
  } : {}
2035
2831
  };