opencode-zellij 0.0.10 → 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) {
515
+ const session = this.get(id);
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) {
442
525
  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;
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) {
@@ -512,19 +597,19 @@ function paneMatches(object, paneId) {
512
597
  "paneId"
513
598
  ]) === paneId && object.is_plugin !== true;
514
599
  }
515
- function findPaneTabId(value, paneId) {
600
+ function findPaneRecord(value, paneId) {
516
601
  if (Array.isArray(value)) {
517
602
  for (const item of value) {
518
- const found = findPaneTabId(item, paneId);
603
+ const found = findPaneRecord(item, paneId);
519
604
  if (found !== void 0) return found;
520
605
  }
521
606
  return;
522
607
  }
523
608
  if (typeof value !== "object" || value === null) return void 0;
524
609
  const object = value;
525
- if (paneMatches(object, paneId)) return numericProperty(object, ["tab_id", "tabId"]);
610
+ if (paneMatches(object, paneId)) return object;
526
611
  for (const nested of Object.values(object)) {
527
- const found = findPaneTabId(nested, paneId);
612
+ const found = findPaneRecord(nested, paneId);
528
613
  if (found !== void 0) return found;
529
614
  }
530
615
  }
@@ -533,12 +618,28 @@ function parseCurrentPaneTabId(listPanesJson, paneId) {
533
618
  const parsedPaneId = Number(paneId);
534
619
  if (!Number.isInteger(parsedPaneId)) return void 0;
535
620
  try {
536
- return findPaneTabId(JSON.parse(listPanesJson), parsedPaneId);
621
+ const pane = findPaneRecord(JSON.parse(listPanesJson), parsedPaneId);
622
+ return pane ? numericProperty(pane, ["tab_id", "tabId"]) : void 0;
537
623
  } catch (error) {
538
624
  debug("parseCurrentPaneTabId failed", errorMessage(error));
539
625
  return;
540
626
  }
541
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
+ }
542
643
  function tabNameProperty(object, tabId) {
543
644
  if (tabId === void 0) return void 0;
544
645
  if (numericProperty(object, ["tab_id", "tabId"]) !== tabId) return void 0;
@@ -680,6 +781,9 @@ var ZellijCli = class {
680
781
  if (!paneId) return void 0;
681
782
  return parseCurrentPaneTabId((await runZellij(zellijActionArgs("list-panes", ["--json"]), { timeoutMs: 5e3 })).stdout, paneId);
682
783
  }
784
+ async paneExists(paneId) {
785
+ return parsePaneExists((await runZellij(zellijActionArgs("list-panes", ["--json"]), { timeoutMs: 5e3 })).stdout, paneId);
786
+ }
683
787
  async renameTab(title) {
684
788
  const tabId = await this.currentPaneTabId();
685
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>"}`);
@@ -993,11 +1097,25 @@ function extractRenderedLines(event) {
993
1097
  var SubscriberManager = class {
994
1098
  subscribers = /* @__PURE__ */ new Map();
995
1099
  startingSessions = /* @__PURE__ */ new Map();
996
- 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 = {}) {
997
1106
  this.sessions = sessions;
998
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;
999
1116
  }
1000
1117
  async start(session) {
1118
+ if ((this.sessions.find(session.id) ?? session).status === "terminal") return;
1001
1119
  if (this.subscribers.get(session.id)?.child) return;
1002
1120
  const inProgress = this.startingSessions.get(session.id);
1003
1121
  if (inProgress) return inProgress;
@@ -1021,7 +1139,7 @@ var SubscriberManager = class {
1021
1139
  lastExitedAt: null
1022
1140
  };
1023
1141
  if (!existing) this.subscribers.set(session.id, state);
1024
- const child = spawn("zellij", zellijCommandArgs([
1142
+ const child = this.spawnProcess("zellij", zellijCommandArgs([
1025
1143
  "subscribe",
1026
1144
  "--pane-id",
1027
1145
  session.paneId,
@@ -1048,7 +1166,7 @@ var SubscriberManager = class {
1048
1166
  child.on("exit", () => this.handleSubscriberExit(session.id, child));
1049
1167
  child.on("error", (error) => this.handleSubscriberError(session.id, child, error));
1050
1168
  if (!existing) try {
1051
- const snapshot = await zellijCli.dumpScreen(session.paneId);
1169
+ const snapshot = await this.dumpScreen(session.paneId);
1052
1170
  if (this.subscribers.get(session.id) !== state || state.child !== child) return;
1053
1171
  state.buffer.appendSnapshot(snapshot);
1054
1172
  this.sessions.updateLineCount(session.id, state.buffer.lineCount);
@@ -1069,7 +1187,8 @@ var SubscriberManager = class {
1069
1187
  return {
1070
1188
  hasBuffer: Boolean(state),
1071
1189
  active: Boolean(state?.child),
1072
- lastExitedAt: state?.lastExitedAt ?? null
1190
+ lastExitedAt: state?.lastExitedAt ?? null,
1191
+ terminal: this.sessions.find(sessionId)?.status === "terminal"
1073
1192
  };
1074
1193
  }
1075
1194
  stderr(sessionId) {
@@ -1089,13 +1208,14 @@ var SubscriberManager = class {
1089
1208
  stopAll() {
1090
1209
  for (const sessionId of this.subscribers.keys()) this.forget(sessionId);
1091
1210
  }
1092
- async closeSessionPane(sessionId) {
1211
+ async closeSessionPane(sessionId, options = {}) {
1093
1212
  const session = this.sessions.get(sessionId);
1094
1213
  this.stop(sessionId);
1095
1214
  try {
1096
- await zellijCli.closePane(session.paneId);
1215
+ await this.closePane(session.paneId);
1097
1216
  } catch (error) {
1098
1217
  debug("closePane failed", errorMessage(error));
1218
+ if (options.throwOnFailure) throw error;
1099
1219
  }
1100
1220
  }
1101
1221
  handleStdout(sessionId, child, chunk) {
@@ -1133,11 +1253,8 @@ var SubscriberManager = class {
1133
1253
  if (paneId && paneId !== session.paneId) return;
1134
1254
  const type = eventType(event);
1135
1255
  if (type === "pane_closed" || type === "PaneClosed") {
1136
- state.buffer.append(`[zellij-pty] Pane ${session.paneId} closed at ${(/* @__PURE__ */ new Date()).toISOString()}`);
1137
- this.sessions.updateLineCount(sessionId, state.buffer.lineCount);
1138
- this.sessions.updateStatus(sessionId, session.status === "killed" ? "killed" : "exited");
1256
+ this.markSessionTerminal(sessionId, "pane_closed");
1139
1257
  unregisterPaneFromWatchdog(sessionId);
1140
- this.stop(sessionId);
1141
1258
  return;
1142
1259
  }
1143
1260
  const lines = extractRenderedLines(event);
@@ -1152,7 +1269,7 @@ var SubscriberManager = class {
1152
1269
  for (const line of lines) {
1153
1270
  const marker = parseExitCodeMarker(line);
1154
1271
  if (!marker || marker.token !== session.exitCodeToken) continue;
1155
- this.sessions.markExited(sessionId, marker.exitCode);
1272
+ this.markSessionTerminal(sessionId, "exit_marker", { exitCode: marker.exitCode });
1156
1273
  return;
1157
1274
  }
1158
1275
  }
@@ -1168,7 +1285,7 @@ var SubscriberManager = class {
1168
1285
  if (state.child !== child) return;
1169
1286
  state.child = null;
1170
1287
  state.lastExitedAt = (/* @__PURE__ */ new Date()).toISOString();
1171
- 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.`);
1172
1289
  if (state.stderr.length > maxStderrLines) state.stderr = state.stderr.slice(state.stderr.length - maxStderrLines);
1173
1290
  }
1174
1291
  handleSubscriberError(sessionId, child, error) {
@@ -1180,6 +1297,29 @@ var SubscriberManager = class {
1180
1297
  this.sessions.updateStatus(sessionId, "unknown");
1181
1298
  }
1182
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
+ }
1183
1323
  };
1184
1324
  const subscriberManager = new SubscriberManager(sessionManager);
1185
1325
  //#endregion
@@ -1192,14 +1332,21 @@ function publicSession(session) {
1192
1332
  command: session.command,
1193
1333
  args: session.args,
1194
1334
  cwd: session.cwd,
1195
- status: session.status,
1335
+ status: session.status === "terminal" ? "exited" : session.status,
1196
1336
  lineCount: session.lineCount,
1197
1337
  createdAt: session.createdAt,
1198
1338
  updatedAt: session.updatedAt,
1199
1339
  agentWritable: session.allowAgentInput,
1200
1340
  humanInputOnly: session.humanInputOnly,
1201
1341
  exitCode: session.exitCode,
1202
- 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
1203
1350
  };
1204
1351
  }
1205
1352
  function nextAdvice(retryable, reason) {
@@ -1256,53 +1403,90 @@ function outputMatches(sessionId, grep, ignoreCase) {
1256
1403
  }).returned > 0;
1257
1404
  }
1258
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
1259
1437
  //#region src/tools/kill.ts
1260
1438
  const schema$4 = tool.schema;
1261
- function closeFailureMeansGone(message) {
1262
- 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);
1263
1467
  }
1264
1468
  const zellijPtyKillTool = tool({
1265
1469
  description: "Terminate a known Zellij PTY session by sending Ctrl-C, then closing its pane.",
1266
1470
  args: { id: schema$4.string().describe("zellij-pty session id.") },
1267
1471
  async execute(args) {
1268
- const session = sessionManager.get(args.id);
1269
- const warnings = [];
1270
- const output = subscriberManager.has(session.id) ? readOutputSnapshot(session.id) : void 0;
1271
- try {
1272
- await zellijCli.sendCtrlC(session.paneId);
1273
- await setTimeout$1(500);
1274
- } catch (error) {
1275
- warnings.push(`Ctrl-C failed or pane was already gone: ${error instanceof Error ? error.message : String(error)}`);
1276
- }
1277
- try {
1278
- await zellijCli.closePane(session.paneId);
1279
- } catch (error) {
1280
- const message = error instanceof Error ? error.message : String(error);
1281
- warnings.push(`close-pane failed: ${message}`);
1282
- if (!closeFailureMeansGone(message)) return jsonResponse({
1283
- killed: false,
1284
- cleanedUp: false,
1285
- session: publicSession(sessionManager.updateStatus(session.id, "unknown")),
1286
- output,
1287
- next: nextAdvice(true, "close-pane failed and the pane may still be running; the session was kept so kill can be retried."),
1288
- warnings
1289
- });
1290
- }
1291
- subscriberManager.stop(session.id);
1292
- subscriberManager.forget(session.id);
1293
- unregisterPaneFromWatchdog(session.id);
1294
- sessionManager.remove(session.id);
1295
- return jsonResponse({
1296
- killed: true,
1297
- cleanedUp: true,
1298
- id: session.id,
1299
- paneId: session.paneId,
1300
- output,
1301
- next: nextAdvice(false, "Session was closed and removed from the in-memory registry."),
1302
- warnings
1303
- });
1472
+ return jsonResponse(await executeZellijPtyKill(args));
1304
1473
  }
1305
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
+ }
1306
1490
  //#endregion
1307
1491
  //#region src/tools/list.ts
1308
1492
  const zellijPtyListTool = tool({
@@ -1318,55 +1502,123 @@ const zellijPtyListTool = tool({
1318
1502
  //#endregion
1319
1503
  //#region src/tools/read.ts
1320
1504
  const schema$3 = tool.schema;
1321
- const zellijPtyReadTool = tool({
1322
- description: "Read recent rendered output from a Zellij PTY session. Supports regex grep filtering.",
1323
- args: {
1324
- id: schema$3.string().describe("zellij-pty session id."),
1325
- maxLines: schema$3.number().int().positive().max(5e3).optional().describe("Maximum recent output lines to return. Defaults to 200."),
1326
- grep: schema$3.string().optional().describe("Regex used to filter returned lines."),
1327
- ignoreCase: schema$3.boolean().optional().describe("Use case-insensitive regex matching.")
1328
- },
1329
- async execute(args) {
1330
- const session = sessionManager.get(args.id);
1331
- const grepError = validateGrep(args.grep);
1332
- if (grepError) return jsonResponse({
1333
- session: publicSession(session),
1334
- output: {
1335
- text: "",
1336
- lines: [],
1337
- lineCount: session.lineCount,
1338
- returned: 0,
1339
- truncated: false
1340
- },
1341
- next: nextAdvice(false, `Invalid grep regex: ${grepError}`),
1342
- warnings: []
1343
- });
1344
- const subscriberStatus = subscriberManager.status(session.id);
1345
- if (!subscriberStatus.hasBuffer || !subscriberStatus.active && (session.status === "running" || session.status === "unknown")) await subscriberManager.start(session);
1346
- const statusAfterStart = subscriberManager.status(session.id);
1347
- const warnings = [];
1348
- if (session.humanInputOnly) warnings.push("This pane is human-input-only: agent writes are forbidden, but rendered output is visible to the agent.");
1349
- if (!statusAfterStart.active) {
1350
- warnings.push("Subscriber is inactive; returned output may be stale.");
1351
- 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
1352
1533
  }
1353
- const output = readOutputSnapshot(session.id, {
1354
- maxLines: args.maxLines,
1355
- grep: args.grep,
1356
- ignoreCase: args.ignoreCase
1357
- });
1358
- return jsonResponse({
1359
- session: publicSession(session),
1360
- output,
1361
- next: nextAdvice(session.status !== "exited" && session.status !== "killed", nextReadReason(session.status)),
1362
- subscriberActive: statusAfterStart.active,
1363
- subscriberLastExitedAt: statusAfterStart.lastExitedAt,
1364
- subscriberErrors: subscriberManager.stderr(session.id),
1365
- warnings
1366
- });
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
+ };
1367
1590
  }
1368
- });
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
+ }
1369
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.";
1370
1622
  if (status === "running") return "Session is still running; read again later if more output is expected.";
1371
1623
  if (status === "unknown") return "Session state is unknown because the subscriber is inactive; output may be stale, but retrying read may restart observation.";
1372
1624
  return "Session is no longer running.";
@@ -1661,6 +1913,395 @@ const zellijPtyWriteTool = tool({
1661
1913
  }
1662
1914
  });
1663
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
1664
2305
  //#region src/zellij/shutdown-cleanup.ts
1665
2306
  let registered = false;
1666
2307
  let cleanedUp = false;
@@ -1764,19 +2405,11 @@ function handleTabTitleEvent(tabTitleManager, event) {
1764
2405
  case "session.status": {
1765
2406
  const sessionID = stringProperty(properties, "sessionID");
1766
2407
  const status = sessionStatusProperty(properties);
1767
- if (sessionID && status) tabTitleManager.updateSessionStatus(sessionID, status);
1768
- break;
1769
- }
1770
- case "session.idle": {
1771
- const sessionID = stringProperty(properties, "sessionID");
1772
- if (sessionID) tabTitleManager.markSessionIdle(sessionID);
1773
- break;
1774
- }
1775
- case "session.error": {
1776
- const sessionID = stringProperty(properties, "sessionID");
1777
- if (sessionID) tabTitleManager.markSessionIdle(sessionID);
2408
+ if (sessionID && status && status.type !== "idle") tabTitleManager.updateSessionStatus(sessionID, status);
1778
2409
  break;
1779
2410
  }
2411
+ case "session.idle": break;
2412
+ case "session.error": break;
1780
2413
  case "vcs.branch.updated":
1781
2414
  tabTitleManager.setBranch(stringProperty(properties, "branch"));
1782
2415
  break;
@@ -1784,22 +2417,32 @@ function handleTabTitleEvent(tabTitleManager, event) {
1784
2417
  case "permission.asked": {
1785
2418
  const id = inputRequestID(properties);
1786
2419
  const sessionID = stringProperty(properties, "sessionID");
1787
- if (id && sessionID) tabTitleManager.markNeedsInput(id, sessionID);
2420
+ if (id && sessionID) {
2421
+ tabTitleManager.markNeedsInput(id, sessionID);
2422
+ tabTitleManager.updateSessionStatus(sessionID, { type: "busy" });
2423
+ }
1788
2424
  break;
1789
2425
  }
1790
2426
  case "permission.updated": {
1791
2427
  const id = inputRequestID(properties);
1792
2428
  const sessionID = stringProperty(properties, "sessionID");
1793
2429
  const state = inputState(properties);
1794
- if (id && isResolvedInputState(state)) tabTitleManager.clearNeedsInput(id);
1795
- 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
+ }
1796
2437
  break;
1797
2438
  }
1798
2439
  case "question.replied":
1799
2440
  case "question.rejected":
1800
2441
  case "permission.replied": {
1801
2442
  const id = inputRequestID(properties);
2443
+ const sessionID = stringProperty(properties, "sessionID");
1802
2444
  if (id) tabTitleManager.clearNeedsInput(id);
2445
+ if (sessionID) tabTitleManager.updateSessionStatus(sessionID, { type: "busy" });
1803
2446
  break;
1804
2447
  }
1805
2448
  case "session.deleted": {
@@ -1869,6 +2512,27 @@ var TabTitleManager = class {
1869
2512
  this.branchName = trimmed;
1870
2513
  this.scheduleUpdate();
1871
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
+ }
1872
2536
  updateSessionStatus(sessionID, status) {
1873
2537
  const activity = status.type === "idle" ? "idle" : "running";
1874
2538
  if (this.sessionStatuses.get(sessionID) === activity) return;
@@ -2029,13 +2693,15 @@ var TabTitleManager = class {
2029
2693
  };
2030
2694
  //#endregion
2031
2695
  //#region src/plugin.ts
2032
- const ptyTools = {
2033
- zellij_pty_spawn: zellijPtySpawnTool,
2034
- zellij_pty_list: zellijPtyListTool,
2035
- zellij_pty_write: zellijPtyWriteTool,
2036
- zellij_pty_read: zellijPtyReadTool,
2037
- zellij_pty_kill: zellijPtyKillTool
2038
- };
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
+ }
2039
2705
  function getProjectName(path) {
2040
2706
  return path.split(/[/\\]/).filter(Boolean).pop() || "opencode";
2041
2707
  }
@@ -2099,22 +2765,67 @@ function createZellijPtyPlugin(dependencies = {}) {
2099
2765
  branch: config.tabTitle.emojiBranch
2100
2766
  }
2101
2767
  }) : void 0;
2102
- tabTitleManager?.renderImmediate().catch((error) => debug("initial tab title render failed", errorMessage(error)));
2103
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)));
2104
2801
  if (config.autoUpdate) (dependencies.startAutoUpdateCheck ?? startAutoUpdateCheck)(client, dependencies.importMetaUrl ?? import.meta.url);
2105
2802
  return {
2106
2803
  async event(input) {
2107
2804
  const event = input.event;
2108
- if (tabTitleManager) await 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
+ }
2109
2815
  if (event.type === "session.deleted") {
2110
2816
  const sessionID = deletedSessionID(event);
2111
2817
  if (!sessionID) return;
2112
2818
  const sessions = sessionManager.listByOpenCodeSession(sessionID);
2819
+ for (const session of sessions) completionNotifications?.clearSession(session.id);
2113
2820
  await Promise.all(sessions.map((session) => cleanupDeletedSession(session.id)));
2114
2821
  }
2115
2822
  },
2116
- tool: config.pty.enabled ? {
2117
- ...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),
2118
2829
  ...config.pty.sudoPane === "hide" ? {} : { zellij_pty_request_sudo: requestSudoTool }
2119
2830
  } : {}
2120
2831
  };