aicodeman 1.32.0 → 1.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/dist/config/cli-registry/schema.d.ts +3 -0
  2. package/dist/config/cli-registry/schema.d.ts.map +1 -1
  3. package/dist/config/cli-registry/schema.js +32 -0
  4. package/dist/config/cli-registry/schema.js.map +1 -1
  5. package/dist/config/cli-registry/stock.d.ts.map +1 -1
  6. package/dist/config/cli-registry/stock.js +82 -13
  7. package/dist/config/cli-registry/stock.js.map +1 -1
  8. package/dist/config/cli-registry/types.d.ts +48 -1
  9. package/dist/config/cli-registry/types.d.ts.map +1 -1
  10. package/dist/docker-hosts.d.ts +14 -3
  11. package/dist/docker-hosts.d.ts.map +1 -1
  12. package/dist/docker-hosts.js +66 -5
  13. package/dist/docker-hosts.js.map +1 -1
  14. package/dist/git-clone.d.ts +22 -2
  15. package/dist/git-clone.d.ts.map +1 -1
  16. package/dist/git-clone.js +26 -6
  17. package/dist/git-clone.js.map +1 -1
  18. package/dist/mux-interface.d.ts +33 -1
  19. package/dist/mux-interface.d.ts.map +1 -1
  20. package/dist/session-activity.d.ts +41 -0
  21. package/dist/session-activity.d.ts.map +1 -1
  22. package/dist/session-activity.js +62 -0
  23. package/dist/session-activity.js.map +1 -1
  24. package/dist/session.d.ts +225 -1
  25. package/dist/session.d.ts.map +1 -1
  26. package/dist/session.js +402 -22
  27. package/dist/session.js.map +1 -1
  28. package/dist/tmux-manager.d.ts +183 -4
  29. package/dist/tmux-manager.d.ts.map +1 -1
  30. package/dist/tmux-manager.js +333 -15
  31. package/dist/tmux-manager.js.map +1 -1
  32. package/dist/tui/tui-approvals.d.ts +10 -2
  33. package/dist/tui/tui-approvals.d.ts.map +1 -1
  34. package/dist/tui/tui-approvals.js +14 -3
  35. package/dist/tui/tui-approvals.js.map +1 -1
  36. package/dist/tui/tui-model.d.ts +12 -0
  37. package/dist/tui/tui-model.d.ts.map +1 -1
  38. package/dist/tui/tui-model.js +19 -2
  39. package/dist/tui/tui-model.js.map +1 -1
  40. package/dist/tui/tui-render.d.ts +11 -2
  41. package/dist/tui/tui-render.d.ts.map +1 -1
  42. package/dist/tui/tui-render.js +14 -5
  43. package/dist/tui/tui-render.js.map +1 -1
  44. package/dist/types/session.d.ts +62 -0
  45. package/dist/types/session.d.ts.map +1 -1
  46. package/dist/types/session.js.map +1 -1
  47. package/dist/utils/claude-transcript.d.ts +49 -0
  48. package/dist/utils/claude-transcript.d.ts.map +1 -0
  49. package/dist/utils/claude-transcript.js +76 -0
  50. package/dist/utils/claude-transcript.js.map +1 -0
  51. package/dist/web/approval-inbox.d.ts +31 -0
  52. package/dist/web/approval-inbox.d.ts.map +1 -1
  53. package/dist/web/approval-inbox.js +20 -0
  54. package/dist/web/approval-inbox.js.map +1 -1
  55. package/dist/web/public/admin-ui.js.gz +0 -0
  56. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  57. package/dist/web/public/app.3ea688d2.js +43 -0
  58. package/dist/web/public/app.3ea688d2.js.br +0 -0
  59. package/dist/web/public/app.3ea688d2.js.gz +0 -0
  60. package/dist/web/public/approvals-ui.js +26 -1
  61. package/dist/web/public/approvals-ui.js.br +0 -0
  62. package/dist/web/public/approvals-ui.js.gz +0 -0
  63. package/dist/web/public/{constants.b26748e9.js → constants.dee9dcc7.js} +288 -24
  64. package/dist/web/public/constants.dee9dcc7.js.br +0 -0
  65. package/dist/web/public/constants.dee9dcc7.js.gz +0 -0
  66. package/dist/web/public/cron-ui.js.gz +0 -0
  67. package/dist/web/public/entrance-animations.js.gz +0 -0
  68. package/dist/web/public/home-sessions.js +9 -0
  69. package/dist/web/public/home-sessions.js.br +0 -0
  70. package/dist/web/public/home-sessions.js.gz +0 -0
  71. package/dist/web/public/host-wake-ui.js.gz +0 -0
  72. package/dist/web/public/i18n.d22be5c4.js.gz +0 -0
  73. package/dist/web/public/image-input.a216cf9e.js.gz +0 -0
  74. package/dist/web/public/index.html +20 -13
  75. package/dist/web/public/index.html.br +0 -0
  76. package/dist/web/public/index.html.gz +0 -0
  77. package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
  78. package/dist/web/public/keyboard-accessory.c1e7c7b7.js.gz +0 -0
  79. package/dist/web/public/{mobile-handlers.6f354a87.js → mobile-handlers.23a85db1.js} +22 -27
  80. package/dist/web/public/mobile-handlers.23a85db1.js.br +0 -0
  81. package/dist/web/public/mobile-handlers.23a85db1.js.gz +0 -0
  82. package/dist/web/public/mobile-overview.js +48 -0
  83. package/dist/web/public/mobile-overview.js.br +0 -0
  84. package/dist/web/public/mobile-overview.js.gz +0 -0
  85. package/dist/web/public/mobile.eeadfbbe.css +1 -0
  86. package/dist/web/public/mobile.eeadfbbe.css.br +0 -0
  87. package/dist/web/public/mobile.eeadfbbe.css.gz +0 -0
  88. package/dist/web/public/{notification-manager.36ea4624.js → notification-manager.50bb87d8.js} +4 -2
  89. package/dist/web/public/notification-manager.50bb87d8.js.br +0 -0
  90. package/dist/web/public/notification-manager.50bb87d8.js.gz +0 -0
  91. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  92. package/dist/web/public/{panels-ui.28df5053.js → panels-ui.24ea8932.js} +1 -1
  93. package/dist/web/public/panels-ui.24ea8932.js.br +0 -0
  94. package/dist/web/public/{panels-ui.28df5053.js.gz → panels-ui.24ea8932.js.gz} +0 -0
  95. package/dist/web/public/{ralph-panel.6de2d0f8.js → ralph-panel.b350e2ef.js} +11 -11
  96. package/dist/web/public/ralph-panel.b350e2ef.js.br +0 -0
  97. package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
  98. package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
  99. package/dist/web/public/readmymind-ui.js.gz +0 -0
  100. package/dist/web/public/reboot-restore-ui.js.gz +0 -0
  101. package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
  102. package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
  103. package/dist/web/public/session-lineage.js.gz +0 -0
  104. package/dist/web/public/session-ui.7df17fd3.js.gz +0 -0
  105. package/dist/web/public/settings-ui.336cf5da.js +78 -0
  106. package/dist/web/public/settings-ui.336cf5da.js.br +0 -0
  107. package/dist/web/public/settings-ui.336cf5da.js.gz +0 -0
  108. package/dist/web/public/styles.dd278987.css +1 -0
  109. package/dist/web/public/styles.dd278987.css.br +0 -0
  110. package/dist/web/public/styles.dd278987.css.gz +0 -0
  111. package/dist/web/public/subagent-windows.e6ca799f.js.gz +0 -0
  112. package/dist/web/public/sw.js +36 -17
  113. package/dist/web/public/sw.js.br +0 -0
  114. package/dist/web/public/sw.js.gz +0 -0
  115. package/dist/web/public/tab-rail-resize.dcdc5772.js +1 -0
  116. package/dist/web/public/tab-rail-resize.dcdc5772.js.br +0 -0
  117. package/dist/web/public/tab-rail-resize.dcdc5772.js.gz +0 -0
  118. package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js.gz +0 -0
  119. package/dist/web/public/terminal-split.js +12 -1
  120. package/dist/web/public/terminal-split.js.br +0 -0
  121. package/dist/web/public/terminal-split.js.gz +0 -0
  122. package/dist/web/public/terminal-ui.baf7c9c6.js +2 -0
  123. package/dist/web/public/terminal-ui.baf7c9c6.js.br +0 -0
  124. package/dist/web/public/terminal-ui.baf7c9c6.js.gz +0 -0
  125. package/dist/web/public/ultracode-panel.js.gz +0 -0
  126. package/dist/web/public/ultracode-windows.js.gz +0 -0
  127. package/dist/web/public/upload.html.gz +0 -0
  128. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  129. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  130. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  131. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  132. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  133. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  134. package/dist/web/public/vendor/xterm-predictive-echo.bd6882b8.js.gz +0 -0
  135. package/dist/web/public/vendor/xterm-zerolag-input.6fee72f2.js.gz +0 -0
  136. package/dist/web/public/vendor/xterm.css.gz +0 -0
  137. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  138. package/dist/web/public/voice-input.c4b51eb6.js.gz +0 -0
  139. package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
  140. package/dist/web/public/webview-tabs.js.gz +0 -0
  141. package/dist/web/routes/case-routes.d.ts +10 -0
  142. package/dist/web/routes/case-routes.d.ts.map +1 -1
  143. package/dist/web/routes/case-routes.js +19 -3
  144. package/dist/web/routes/case-routes.js.map +1 -1
  145. package/dist/web/routes/hook-event-routes.d.ts.map +1 -1
  146. package/dist/web/routes/hook-event-routes.js +23 -6
  147. package/dist/web/routes/hook-event-routes.js.map +1 -1
  148. package/dist/web/routes/session-routes.d.ts.map +1 -1
  149. package/dist/web/routes/session-routes.js +16 -1
  150. package/dist/web/routes/session-routes.js.map +1 -1
  151. package/dist/web/routes/ws-routes.d.ts +3 -0
  152. package/dist/web/routes/ws-routes.d.ts.map +1 -1
  153. package/dist/web/routes/ws-routes.js +16 -0
  154. package/dist/web/routes/ws-routes.js.map +1 -1
  155. package/dist/web/server.d.ts +17 -0
  156. package/dist/web/server.d.ts.map +1 -1
  157. package/dist/web/server.js +73 -0
  158. package/dist/web/server.js.map +1 -1
  159. package/dist/web/session-listener-wiring.d.ts +1 -0
  160. package/dist/web/session-listener-wiring.d.ts.map +1 -1
  161. package/dist/web/session-listener-wiring.js +12 -0
  162. package/dist/web/session-listener-wiring.js.map +1 -1
  163. package/package.json +2 -1
  164. package/dist/web/public/app.a792bf71.js +0 -38
  165. package/dist/web/public/app.a792bf71.js.br +0 -0
  166. package/dist/web/public/app.a792bf71.js.gz +0 -0
  167. package/dist/web/public/constants.b26748e9.js.br +0 -0
  168. package/dist/web/public/constants.b26748e9.js.gz +0 -0
  169. package/dist/web/public/mobile-handlers.6f354a87.js.br +0 -0
  170. package/dist/web/public/mobile-handlers.6f354a87.js.gz +0 -0
  171. package/dist/web/public/mobile.6caaa28a.css +0 -1
  172. package/dist/web/public/mobile.6caaa28a.css.br +0 -0
  173. package/dist/web/public/mobile.6caaa28a.css.gz +0 -0
  174. package/dist/web/public/notification-manager.36ea4624.js.br +0 -0
  175. package/dist/web/public/notification-manager.36ea4624.js.gz +0 -0
  176. package/dist/web/public/panels-ui.28df5053.js.br +0 -0
  177. package/dist/web/public/ralph-panel.6de2d0f8.js.br +0 -0
  178. package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
  179. package/dist/web/public/settings-ui.df5ffb68.js +0 -78
  180. package/dist/web/public/settings-ui.df5ffb68.js.br +0 -0
  181. package/dist/web/public/settings-ui.df5ffb68.js.gz +0 -0
  182. package/dist/web/public/styles.74f312e5.css +0 -1
  183. package/dist/web/public/styles.74f312e5.css.br +0 -0
  184. package/dist/web/public/styles.74f312e5.css.gz +0 -0
  185. package/dist/web/public/tab-rail-resize.42c24949.js +0 -1
  186. package/dist/web/public/tab-rail-resize.42c24949.js.br +0 -0
  187. package/dist/web/public/tab-rail-resize.42c24949.js.gz +0 -0
  188. package/dist/web/public/terminal-ui.32fbeaf6.js +0 -2
  189. package/dist/web/public/terminal-ui.32fbeaf6.js.br +0 -0
  190. package/dist/web/public/terminal-ui.32fbeaf6.js.gz +0 -0
@@ -71,6 +71,15 @@ const TMUX_KILL_WAIT_MS = 200;
71
71
  const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
72
72
  /** Default stats collection interval (2 seconds) */
73
73
  const DEFAULT_STATS_INTERVAL_MS = 2000;
74
+ /**
75
+ * How often the pane-exit watcher re-reads every pane on the socket. The
76
+ * watcher owns this cadence: it does NOT ride `startStatsCollection()`, whose
77
+ * lifetime a browser panel controls (see {@link TmuxManager.startPaneExitWatcher}).
78
+ * Matched to the stats cadence above because both cost one batched tmux read.
79
+ * ⚠ It does NOT bound a read: EXEC_TIMEOUT_MS is 5000 ms, so a slow read can
80
+ * outlive two ticks, which is exactly why `paneExitReadInFlight` exists.
81
+ */
82
+ const DEFAULT_PANE_EXIT_INTERVAL_MS = 2000;
74
83
  /** Default remote-reconnect watcher poll interval (5 seconds) — COD-108 */
75
84
  const DEFAULT_REMOTE_RECONNECT_INTERVAL_MS = 5000;
76
85
  /** Stable cwd for tmux server/pane launch; actual session cwd is reached inside the pane. */
@@ -128,8 +137,17 @@ const DEFAULT_CODEMAN_TMUX_SOCKET = DEFAULT_TMUX_SOCKET;
128
137
  * so it cannot appear inside `#{session_name}` and cause a false split.
129
138
  */
130
139
  const PANE_LIST_SEP = '|';
131
- /** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneList}. */
132
- const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}`;
140
+ /**
141
+ * Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneRows}.
142
+ *
143
+ * The three `pane_dead*` fields carry the agent-exit signal of Ark0N/Codeman#446.
144
+ * Appending them is backward compatible in both directions. A tmux that does not
145
+ * know a variable substitutes the empty string rather than failing, which is how
146
+ * tmux 3.2a answers `#{pane_dead_signal}` (added in 3.4), and the parser reads a
147
+ * short row as "pid known, deadness unknown" rather than discarding it.
148
+ */
149
+ const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}` +
150
+ `${PANE_LIST_SEP}#{pane_dead}${PANE_LIST_SEP}#{pane_dead_status}${PANE_LIST_SEP}#{pane_dead_signal}`;
133
151
  /**
134
152
  * 构建 pane 启动前的 nofile 修复命令。
135
153
  *
@@ -142,28 +160,116 @@ export function buildNofileLimitCommand(targetLimit = CLAUDE_CODE_NOFILE_LIMIT)
142
160
  const safeLimit = Number.isSafeInteger(targetLimit) && targetLimit > 0 ? targetLimit : CLAUDE_CODE_NOFILE_LIMIT;
143
161
  return `ulimit -Sn ${safeLimit} 2>/dev/null || ulimit -n ${safeLimit} 2>/dev/null || true`;
144
162
  }
163
+ /** Read one optional numeric field; a blank or non-numeric value is "not reported". */
164
+ function paneField(fields, index) {
165
+ const raw = fields[index];
166
+ if (raw === undefined || raw === '')
167
+ return undefined;
168
+ const value = parseInt(raw, 10);
169
+ return Number.isNaN(value) ? undefined : value;
170
+ }
145
171
  /**
146
- * Parse the output of `tmux list-panes -a -F '#{session_name}|#{pane_pid}'`
147
- * into a Map of session-name → pane pid. Exported for unit testing.
172
+ * Parse the output of `tmux list-panes -a -F` under {@link PANE_LIST_FORMAT}
173
+ * into one row per pane, in tmux's own order. Exported for unit testing.
148
174
  *
149
175
  * - Skips empty lines and lines without the separator.
150
176
  * - Skips entries with a non-numeric pid or empty name.
177
+ * - Leaves every field after the pid undefined when it is blank or absent, so a
178
+ * row from an older tmux still yields its pid.
151
179
  */
152
- export function parsePaneList(output) {
153
- const result = new Map();
180
+ export function parsePaneRows(output) {
181
+ const rows = [];
154
182
  for (const line of output.split('\n')) {
155
183
  if (!line)
156
184
  continue;
157
- const sep = line.indexOf(PANE_LIST_SEP);
158
- if (sep === -1)
185
+ if (!line.includes(PANE_LIST_SEP))
159
186
  continue;
160
- const name = line.slice(0, sep);
161
- const pid = parseInt(line.slice(sep + 1), 10);
162
- if (name && !Number.isNaN(pid)) {
163
- result.set(name, pid);
164
- }
187
+ const fields = line.split(PANE_LIST_SEP);
188
+ const sessionName = fields[0];
189
+ const pid = parseInt(fields[1] ?? '', 10);
190
+ if (!sessionName || Number.isNaN(pid))
191
+ continue;
192
+ const deadFlag = fields[2];
193
+ rows.push({
194
+ sessionName,
195
+ pid,
196
+ dead: deadFlag === '1' ? true : deadFlag === '0' ? false : undefined,
197
+ exitStatus: paneField(fields, 3),
198
+ exitSignal: paneField(fields, 4),
199
+ });
165
200
  }
166
- return result;
201
+ return rows;
202
+ }
203
+ /**
204
+ * Decide, from every pane tmux listed, which tmux sessions have an exited agent.
205
+ * Exported for unit testing. Returns one entry per session with a known answer;
206
+ * a session absent from the map is UNKNOWN, which must never render as alive.
207
+ *
208
+ * Two rules make a positive answer trustworthy:
209
+ *
210
+ * A session answers only when tmux listed EXACTLY ONE pane for it. Codeman
211
+ * creates one pane per session and `isPaneDead()` reads one pane, so a session
212
+ * the user has split by hand has no single "the agent" to report on, and
213
+ * guessing which of its panes speaks for the session could report a live
214
+ * session as exited.
215
+ *
216
+ * A pane answers only when `#{pane_dead}` said 1 or 0. An empty field is a tmux
217
+ * that did not answer, not a live pane.
218
+ *
219
+ * `status` and `signal` stay absent when tmux did not report them. Measured on
220
+ * tmux 3.2a, a SIGKILLed pane reports neither, so folding an absent status into
221
+ * 0 would turn an unexplained death into a clean exit.
222
+ */
223
+ export function derivePaneExits(rows, now) {
224
+ const panesPerSession = new Map();
225
+ for (const row of rows) {
226
+ panesPerSession.set(row.sessionName, (panesPerSession.get(row.sessionName) ?? 0) + 1);
227
+ }
228
+ const exits = new Map();
229
+ for (const row of rows) {
230
+ if (panesPerSession.get(row.sessionName) !== 1)
231
+ continue;
232
+ if (row.dead !== true)
233
+ continue;
234
+ exits.set(row.sessionName, {
235
+ panePid: row.pid,
236
+ exit: {
237
+ ...(row.exitStatus !== undefined ? { status: row.exitStatus } : {}),
238
+ ...(row.exitSignal !== undefined ? { signal: row.exitSignal } : {}),
239
+ at: now,
240
+ },
241
+ });
242
+ }
243
+ return exits;
244
+ }
245
+ /**
246
+ * Could any of these tmux sessions ever produce a pane-exit answer? Exported
247
+ * for unit testing.
248
+ *
249
+ * Mirrors `Session.paneExitApplies`, which is where the rule is enforced. A
250
+ * remote session's local pane holds the ssh client, a docker case's holds a
251
+ * `docker exec` into the container's own tmux, and a record rebuilt from the
252
+ * socket carries no provenance at all, so the session end forces all three to
253
+ * UNKNOWN whatever tmux reports. A tick that sees only those has nothing to
254
+ * learn, and `refreshPaneExits()` skips its tmux read rather than paying for
255
+ * the answer.
256
+ *
257
+ * ⚠ This gates the READ, never the watcher. The watcher is always-on by
258
+ * design (see {@link TmuxManager.startPaneExitWatcher}), so it keeps ticking
259
+ * with nothing to observe and picks the read straight back up as soon as one
260
+ * local session exists.
261
+ */
262
+ export function hasObservablePaneSession(sessions) {
263
+ for (const session of sessions) {
264
+ if (session.remote)
265
+ continue;
266
+ if (session.docker)
267
+ continue;
268
+ if (session.discovered === true)
269
+ continue;
270
+ return true;
271
+ }
272
+ return false;
167
273
  }
168
274
  /**
169
275
  * Resolve a target pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'`.
@@ -1288,6 +1394,30 @@ export class TmuxManager extends EventEmitter {
1288
1394
  mouseSyncInterval = null;
1289
1395
  /** Track last-known pane count per session to avoid unnecessary tmux set-option calls */
1290
1396
  lastPaneCount = new Map();
1397
+ /**
1398
+ * muxName → the exited agent the pane-exit watcher last observed
1399
+ * (Ark0N/Codeman#446). Absence is the UNKNOWN arm of the tri-state, so an
1400
+ * entry goes the moment tmux stops reporting the pane dead, and the map is
1401
+ * empty until the first read runs. The manager reports what tmux says and
1402
+ * nothing more: the scoping that hides this for remote and docker sessions
1403
+ * lives on `Session`, because the remote-reconnect watcher above needs the
1404
+ * raw pane reading.
1405
+ */
1406
+ paneExits = new Map();
1407
+ /** The pane-exit watcher's own interval. Runs whether or not stats are on. */
1408
+ paneExitInterval = null;
1409
+ /**
1410
+ * True while a pane read is in flight. `EXEC_TIMEOUT_MS` is 5000 ms against a
1411
+ * poll interval of 2000 ms, so without this a slow read overlaps the next two
1412
+ * and the older one can resolve last and win.
1413
+ */
1414
+ paneExitReadInFlight = false;
1415
+ /**
1416
+ * Bumped by every deliberate {@link clearPaneExit}. A read that started before
1417
+ * a clear carries the older generation and is discarded rather than writing
1418
+ * the death back over the pane that has just replaced it.
1419
+ */
1420
+ paneExitGeneration = 0;
1291
1421
  // ── COD-108 remote-reconnect watcher state ────────────────────────────────
1292
1422
  /** Periodic watcher that re-establishes dropped remote sessions. */
1293
1423
  remoteReconnectInterval = null;
@@ -1950,6 +2080,11 @@ export class TmuxManager extends EventEmitter {
1950
2080
  });
1951
2081
  // Wait for the respawned process to start
1952
2082
  await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
2083
+ // The pane now runs a fresh command, so whatever the last read observed of
2084
+ // the old one is history. Clearing it here rather than waiting for the next
2085
+ // poll also invalidates any read already in flight, which would otherwise
2086
+ // write the old death back over the pane that just replaced it.
2087
+ this.clearPaneExit(muxName);
1953
2088
  const pid = this.getPanePid(muxName);
1954
2089
  if (pid)
1955
2090
  session.pid = pid;
@@ -2156,6 +2291,7 @@ export class TmuxManager extends EventEmitter {
2156
2291
  }
2157
2292
  }
2158
2293
  this.lastPaneCount.delete(session.muxName);
2294
+ this.clearPaneExit(session.muxName);
2159
2295
  this.sessions.delete(sessionId);
2160
2296
  this.clearRemoteReconnectState(sessionId);
2161
2297
  this.saveSessions();
@@ -2259,6 +2395,7 @@ export class TmuxManager extends EventEmitter {
2259
2395
  console.error(`[TmuxManager] Warning: Some processes may still be alive for session ${session.muxName}`);
2260
2396
  }
2261
2397
  this.lastPaneCount.delete(session.muxName);
2398
+ this.clearPaneExit(session.muxName);
2262
2399
  this.sessions.delete(sessionId);
2263
2400
  this.clearRemoteReconnectState(sessionId);
2264
2401
  this.saveSessions();
@@ -2305,7 +2442,14 @@ export class TmuxManager extends EventEmitter {
2305
2442
  encoding: 'utf-8',
2306
2443
  timeout: EXEC_TIMEOUT_MS,
2307
2444
  }).trim();
2308
- active = parsePaneList(output);
2445
+ const rows = parsePaneRows(output);
2446
+ active = new Map(rows.map((row) => [row.sessionName, row.pid]));
2447
+ // The same read answers both questions, so recovery starts with a pane-exit
2448
+ // reading rather than waiting for the first stats tick — which may never
2449
+ // come, since the collector only starts when boot found a live session.
2450
+ if (rows.length > 0) {
2451
+ this.applyPaneExits(derivePaneExits(rows, Date.now()));
2452
+ }
2309
2453
  }
2310
2454
  catch (err) {
2311
2455
  console.error('[TmuxManager] Failed to list tmux panes:', err);
@@ -2322,6 +2466,7 @@ export class TmuxManager extends EventEmitter {
2322
2466
  else {
2323
2467
  dead.push(sessionId);
2324
2468
  this.sessions.delete(sessionId);
2469
+ this.clearPaneExit(session.muxName);
2325
2470
  this.clearRemoteReconnectState(sessionId);
2326
2471
  this.emit('sessionDied', { sessionId });
2327
2472
  }
@@ -2357,6 +2502,13 @@ export class TmuxManager extends EventEmitter {
2357
2502
  mode: 'claude',
2358
2503
  attached: false,
2359
2504
  name: `Restored: ${sessionName}`,
2505
+ // Every field above except the name and the pid is a guess: this record
2506
+ // was rebuilt from the socket because Codeman's own bookkeeping did not
2507
+ // have it. The synthetic id also cannot find the session's state.json
2508
+ // entry, so a remote or docker session rediscovered this way arrives
2509
+ // looking local. Consumers that would be wrong about such a session
2510
+ // read this flag and fail closed — see `Session.paneExitApplies`.
2511
+ discovered: true,
2360
2512
  };
2361
2513
  this.sessions.set(sessionId, session);
2362
2514
  knownMuxNames.add(sessionName);
@@ -2494,6 +2646,170 @@ export class TmuxManager extends EventEmitter {
2494
2646
  stats: statsMap.get(session.pid) || undefined,
2495
2647
  }));
2496
2648
  }
2649
+ /**
2650
+ * What the last pane read saw of this tmux session's agent. `undefined` is
2651
+ * the UNKNOWN answer and must never be rendered as "alive": it covers a pane
2652
+ * that is running, a tmux session that no longer exists, a probe that failed,
2653
+ * and every poll that has not run yet. See {@link PaneExit}.
2654
+ */
2655
+ getPaneExit(muxName) {
2656
+ return this.paneExits.get(muxName)?.exit;
2657
+ }
2658
+ /**
2659
+ * Re-read every pane on the socket and refresh {@link paneExits}. ONE batched
2660
+ * `tmux list-panes -a` answers for every session at once, which is why this
2661
+ * polls rather than probing per session.
2662
+ *
2663
+ * A failed or empty probe leaves the previous answers ALONE rather than
2664
+ * clearing them, because the two cannot be told apart: the command ends in
2665
+ * `|| true`, so a tmux that errored and a socket with genuinely no panes both
2666
+ * arrive as empty output. Treating that as "tmux did not answer" is the
2667
+ * conservative reading — clearing on it would turn a transient failure into a
2668
+ * silent retraction of a death Codeman had already observed, and the cost of
2669
+ * being wrong the other way is one stale entry for a socket that no longer
2670
+ * has the pane. A NON-empty read is different: `list-panes -a` lists
2671
+ * every pane on the socket, so it is authoritative and {@link applyPaneExits}
2672
+ * prunes against it.
2673
+ *
2674
+ * Two guards keep a slow read from undoing a fast one. A read already in
2675
+ * flight suppresses the next poll, and a read that started before a
2676
+ * {@link clearPaneExit} is discarded when it lands.
2677
+ *
2678
+ * A third guard skips the read entirely while no session on this manager
2679
+ * could produce an answer ({@link hasObservablePaneSession}). Skipping
2680
+ * retracts nothing, for the same reason a failed read does not: the map
2681
+ * still holds what the last real read saw, and every path that puts a new
2682
+ * command in a pane calls {@link clearPaneExit} itself.
2683
+ */
2684
+ async refreshPaneExits(now = Date.now()) {
2685
+ // Nothing on this socket could answer, so do not read tmux to find that
2686
+ // out. See `hasObservablePaneSession`: the watcher above still ticks.
2687
+ if (!hasObservablePaneSession(this.sessions.values()))
2688
+ return;
2689
+ if (this.paneExitReadInFlight)
2690
+ return;
2691
+ const generation = this.paneExitGeneration;
2692
+ this.paneExitReadInFlight = true;
2693
+ let rows;
2694
+ try {
2695
+ rows = await this.readPaneRows();
2696
+ }
2697
+ finally {
2698
+ this.paneExitReadInFlight = false;
2699
+ }
2700
+ if (rows.length === 0)
2701
+ return;
2702
+ // A pane was respawned or killed while this read was out, so what it saw is
2703
+ // already history. Dropping it is what stops a freshly respawned pane from
2704
+ // being republished as exited.
2705
+ if (generation !== this.paneExitGeneration)
2706
+ return;
2707
+ this.applyPaneExits(derivePaneExits(rows, now));
2708
+ }
2709
+ /**
2710
+ * Read every pane on the socket. The ONLY part of the pane-exit watcher that
2711
+ * touches tmux, which is what lets a test subclass drive the guards in
2712
+ * {@link refreshPaneExits} — the in-flight suppression, the generation
2713
+ * check, the empty-read retraction rule and the read gate — against rows it
2714
+ * chooses. Split out for the reason `runRemoteReconnectTick` is: a guard no
2715
+ * test can reach is a guard that can be deleted without anything failing.
2716
+ *
2717
+ * A failed read answers with NO rows, which the caller treats as "tmux did
2718
+ * not answer" and which therefore retracts nothing.
2719
+ */
2720
+ async readPaneRows() {
2721
+ // The test-mode gate lives HERE rather than at the top of the tick, so that
2722
+ // what tests cannot do is spawn a process, not exercise the bookkeeping.
2723
+ if (IS_TEST_MODE)
2724
+ return [];
2725
+ try {
2726
+ // execAsync, not execSync: this runs on a 2000 ms timer, and a synchronous
2727
+ // exec freezes the port while the process stays alive (see the
2728
+ // event-loop-monitor note in CLAUDE.md). The three `isPaneDead()` callers
2729
+ // stay synchronous because each is answering one request right then.
2730
+ const { stdout } = await execAsync(`${this.tmux()} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, {
2731
+ encoding: 'utf-8',
2732
+ timeout: EXEC_TIMEOUT_MS,
2733
+ });
2734
+ return parsePaneRows(stdout.trim());
2735
+ }
2736
+ catch (err) {
2737
+ console.error('[TmuxManager] Failed to read pane exit state:', err);
2738
+ return [];
2739
+ }
2740
+ }
2741
+ /**
2742
+ * Fold one authoritative observation into {@link paneExits}. Split out from
2743
+ * the tmux call so the merge rules are unit-testable.
2744
+ *
2745
+ * `observed` comes from a read of EVERY pane on the socket, so a session
2746
+ * missing from it has no exit to report and its entry goes. That is what
2747
+ * keeps the map from growing without bound as tmux sessions come and go
2748
+ * outside `killSession()`. Only the caller may decide a read is authoritative:
2749
+ * a failed or empty one never reaches here.
2750
+ *
2751
+ * An entry keeps the `at` of the FIRST read that saw that exit, so the stamp
2752
+ * says when the agent was found gone rather than when the last poll ran. A
2753
+ * changed status, a changed signal, or a different pane pid all start a new
2754
+ * observation — the pid is what catches a second command in the same pane
2755
+ * that happened to exit the same way.
2756
+ */
2757
+ applyPaneExits(observed) {
2758
+ for (const muxName of [...this.paneExits.keys()]) {
2759
+ if (!observed.has(muxName))
2760
+ this.paneExits.delete(muxName);
2761
+ }
2762
+ for (const [muxName, next] of observed) {
2763
+ const prev = this.paneExits.get(muxName);
2764
+ const sameExit = prev !== undefined &&
2765
+ prev.panePid === next.panePid &&
2766
+ prev.exit.status === next.exit.status &&
2767
+ prev.exit.signal === next.exit.signal;
2768
+ this.paneExits.set(muxName, sameExit ? prev : next);
2769
+ }
2770
+ }
2771
+ /**
2772
+ * Forget a session's exit observation, e.g. once its pane has been respawned.
2773
+ * Also invalidates any read already in flight, so the answer this retracts
2774
+ * cannot be written back a moment later.
2775
+ */
2776
+ clearPaneExit(muxName) {
2777
+ this.paneExits.delete(muxName);
2778
+ this.paneExitGeneration++;
2779
+ }
2780
+ /**
2781
+ * Poll for exited agents, on the manager's own interval.
2782
+ *
2783
+ * Deliberately NOT part of `startStatsCollection()`. That collector is armed
2784
+ * when the browser opens the Monitor panel and DISARMED when it closes it
2785
+ * (`panels-ui.js`), and it is skipped at boot entirely when no session was
2786
+ * recovered — so riding it would leave a session created on a freshly booted
2787
+ * server reporting nothing at all, and would let one browser turn exit
2788
+ * detection off for every other. Started unconditionally, like the mouse-mode
2789
+ * sync and the remote-reconnect watcher below.
2790
+ *
2791
+ * The `paneExitsUpdated` event is internal to the server; nothing here adds an
2792
+ * SSE event, and the field reaches the browser on `session:updated`.
2793
+ */
2794
+ startPaneExitWatcher(intervalMs = DEFAULT_PANE_EXIT_INTERVAL_MS) {
2795
+ if (this.paneExitInterval) {
2796
+ clearInterval(this.paneExitInterval);
2797
+ }
2798
+ this.paneExitInterval = setInterval(() => {
2799
+ // No IS_TEST_MODE guard: `readPaneRows()` is the only thing that would
2800
+ // spawn a process and it refuses under test, so a test can drive this
2801
+ // whole loop with fake timers instead of being locked out of it.
2802
+ void this.refreshPaneExits()
2803
+ .then(() => this.emit('paneExitsUpdated'))
2804
+ .catch((err) => console.error('[TmuxManager] Pane exit watcher error:', err));
2805
+ }, intervalMs);
2806
+ }
2807
+ stopPaneExitWatcher() {
2808
+ if (this.paneExitInterval) {
2809
+ clearInterval(this.paneExitInterval);
2810
+ this.paneExitInterval = null;
2811
+ }
2812
+ }
2497
2813
  startStatsCollection(intervalMs = DEFAULT_STATS_INTERVAL_MS) {
2498
2814
  if (this.statsInterval) {
2499
2815
  clearInterval(this.statsInterval);
@@ -2710,6 +3026,8 @@ export class TmuxManager extends EventEmitter {
2710
3026
  }
2711
3027
  destroy() {
2712
3028
  this.stopStatsCollection();
3029
+ this.stopPaneExitWatcher();
3030
+ this.paneExits.clear();
2713
3031
  this.stopMouseModeSync();
2714
3032
  this.stopRemoteReconnectWatcher();
2715
3033
  this.reconnectState.clear();