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.
- package/dist/config/cli-registry/schema.d.ts +3 -0
- package/dist/config/cli-registry/schema.d.ts.map +1 -1
- package/dist/config/cli-registry/schema.js +32 -0
- package/dist/config/cli-registry/schema.js.map +1 -1
- package/dist/config/cli-registry/stock.d.ts.map +1 -1
- package/dist/config/cli-registry/stock.js +82 -13
- package/dist/config/cli-registry/stock.js.map +1 -1
- package/dist/config/cli-registry/types.d.ts +48 -1
- package/dist/config/cli-registry/types.d.ts.map +1 -1
- package/dist/docker-hosts.d.ts +14 -3
- package/dist/docker-hosts.d.ts.map +1 -1
- package/dist/docker-hosts.js +66 -5
- package/dist/docker-hosts.js.map +1 -1
- package/dist/git-clone.d.ts +22 -2
- package/dist/git-clone.d.ts.map +1 -1
- package/dist/git-clone.js +26 -6
- package/dist/git-clone.js.map +1 -1
- package/dist/mux-interface.d.ts +33 -1
- package/dist/mux-interface.d.ts.map +1 -1
- package/dist/session-activity.d.ts +41 -0
- package/dist/session-activity.d.ts.map +1 -1
- package/dist/session-activity.js +62 -0
- package/dist/session-activity.js.map +1 -1
- package/dist/session.d.ts +225 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +402 -22
- package/dist/session.js.map +1 -1
- package/dist/tmux-manager.d.ts +183 -4
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +333 -15
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tui/tui-approvals.d.ts +10 -2
- package/dist/tui/tui-approvals.d.ts.map +1 -1
- package/dist/tui/tui-approvals.js +14 -3
- package/dist/tui/tui-approvals.js.map +1 -1
- package/dist/tui/tui-model.d.ts +12 -0
- package/dist/tui/tui-model.d.ts.map +1 -1
- package/dist/tui/tui-model.js +19 -2
- package/dist/tui/tui-model.js.map +1 -1
- package/dist/tui/tui-render.d.ts +11 -2
- package/dist/tui/tui-render.d.ts.map +1 -1
- package/dist/tui/tui-render.js +14 -5
- package/dist/tui/tui-render.js.map +1 -1
- package/dist/types/session.d.ts +62 -0
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js.map +1 -1
- package/dist/utils/claude-transcript.d.ts +49 -0
- package/dist/utils/claude-transcript.d.ts.map +1 -0
- package/dist/utils/claude-transcript.js +76 -0
- package/dist/utils/claude-transcript.js.map +1 -0
- package/dist/web/approval-inbox.d.ts +31 -0
- package/dist/web/approval-inbox.d.ts.map +1 -1
- package/dist/web/approval-inbox.js +20 -0
- package/dist/web/approval-inbox.js.map +1 -1
- package/dist/web/public/admin-ui.js.gz +0 -0
- package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
- package/dist/web/public/app.3ea688d2.js +43 -0
- package/dist/web/public/app.3ea688d2.js.br +0 -0
- package/dist/web/public/app.3ea688d2.js.gz +0 -0
- package/dist/web/public/approvals-ui.js +26 -1
- package/dist/web/public/approvals-ui.js.br +0 -0
- package/dist/web/public/approvals-ui.js.gz +0 -0
- package/dist/web/public/{constants.b26748e9.js → constants.dee9dcc7.js} +288 -24
- package/dist/web/public/constants.dee9dcc7.js.br +0 -0
- package/dist/web/public/constants.dee9dcc7.js.gz +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/entrance-animations.js.gz +0 -0
- package/dist/web/public/home-sessions.js +9 -0
- package/dist/web/public/home-sessions.js.br +0 -0
- package/dist/web/public/home-sessions.js.gz +0 -0
- package/dist/web/public/host-wake-ui.js.gz +0 -0
- package/dist/web/public/i18n.d22be5c4.js.gz +0 -0
- package/dist/web/public/image-input.a216cf9e.js.gz +0 -0
- package/dist/web/public/index.html +20 -13
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.c1e7c7b7.js.gz +0 -0
- package/dist/web/public/{mobile-handlers.6f354a87.js → mobile-handlers.23a85db1.js} +22 -27
- package/dist/web/public/mobile-handlers.23a85db1.js.br +0 -0
- package/dist/web/public/mobile-handlers.23a85db1.js.gz +0 -0
- package/dist/web/public/mobile-overview.js +48 -0
- package/dist/web/public/mobile-overview.js.br +0 -0
- package/dist/web/public/mobile-overview.js.gz +0 -0
- package/dist/web/public/mobile.eeadfbbe.css +1 -0
- package/dist/web/public/mobile.eeadfbbe.css.br +0 -0
- package/dist/web/public/mobile.eeadfbbe.css.gz +0 -0
- package/dist/web/public/{notification-manager.36ea4624.js → notification-manager.50bb87d8.js} +4 -2
- package/dist/web/public/notification-manager.50bb87d8.js.br +0 -0
- package/dist/web/public/notification-manager.50bb87d8.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/{panels-ui.28df5053.js → panels-ui.24ea8932.js} +1 -1
- package/dist/web/public/panels-ui.24ea8932.js.br +0 -0
- package/dist/web/public/{panels-ui.28df5053.js.gz → panels-ui.24ea8932.js.gz} +0 -0
- package/dist/web/public/{ralph-panel.6de2d0f8.js → ralph-panel.b350e2ef.js} +11 -11
- package/dist/web/public/ralph-panel.b350e2ef.js.br +0 -0
- package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
- package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
- package/dist/web/public/readmymind-ui.js.gz +0 -0
- package/dist/web/public/reboot-restore-ui.js.gz +0 -0
- package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
- package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
- package/dist/web/public/session-lineage.js.gz +0 -0
- package/dist/web/public/session-ui.7df17fd3.js.gz +0 -0
- package/dist/web/public/settings-ui.336cf5da.js +78 -0
- package/dist/web/public/settings-ui.336cf5da.js.br +0 -0
- package/dist/web/public/settings-ui.336cf5da.js.gz +0 -0
- package/dist/web/public/styles.dd278987.css +1 -0
- package/dist/web/public/styles.dd278987.css.br +0 -0
- package/dist/web/public/styles.dd278987.css.gz +0 -0
- package/dist/web/public/subagent-windows.e6ca799f.js.gz +0 -0
- package/dist/web/public/sw.js +36 -17
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/tab-rail-resize.dcdc5772.js +1 -0
- package/dist/web/public/tab-rail-resize.dcdc5772.js.br +0 -0
- package/dist/web/public/tab-rail-resize.dcdc5772.js.gz +0 -0
- package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js.gz +0 -0
- package/dist/web/public/terminal-split.js +12 -1
- package/dist/web/public/terminal-split.js.br +0 -0
- package/dist/web/public/terminal-split.js.gz +0 -0
- package/dist/web/public/terminal-ui.baf7c9c6.js +2 -0
- package/dist/web/public/terminal-ui.baf7c9c6.js.br +0 -0
- package/dist/web/public/terminal-ui.baf7c9c6.js.gz +0 -0
- package/dist/web/public/ultracode-panel.js.gz +0 -0
- package/dist/web/public/ultracode-windows.js.gz +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/marked.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-predictive-echo.bd6882b8.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.6fee72f2.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/voice-input.c4b51eb6.js.gz +0 -0
- package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
- package/dist/web/public/webview-tabs.js.gz +0 -0
- package/dist/web/routes/case-routes.d.ts +10 -0
- package/dist/web/routes/case-routes.d.ts.map +1 -1
- package/dist/web/routes/case-routes.js +19 -3
- package/dist/web/routes/case-routes.js.map +1 -1
- package/dist/web/routes/hook-event-routes.d.ts.map +1 -1
- package/dist/web/routes/hook-event-routes.js +23 -6
- package/dist/web/routes/hook-event-routes.js.map +1 -1
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +16 -1
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/routes/ws-routes.d.ts +3 -0
- package/dist/web/routes/ws-routes.d.ts.map +1 -1
- package/dist/web/routes/ws-routes.js +16 -0
- package/dist/web/routes/ws-routes.js.map +1 -1
- package/dist/web/server.d.ts +17 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +73 -0
- package/dist/web/server.js.map +1 -1
- package/dist/web/session-listener-wiring.d.ts +1 -0
- package/dist/web/session-listener-wiring.d.ts.map +1 -1
- package/dist/web/session-listener-wiring.js +12 -0
- package/dist/web/session-listener-wiring.js.map +1 -1
- package/package.json +2 -1
- package/dist/web/public/app.a792bf71.js +0 -38
- package/dist/web/public/app.a792bf71.js.br +0 -0
- package/dist/web/public/app.a792bf71.js.gz +0 -0
- package/dist/web/public/constants.b26748e9.js.br +0 -0
- package/dist/web/public/constants.b26748e9.js.gz +0 -0
- package/dist/web/public/mobile-handlers.6f354a87.js.br +0 -0
- package/dist/web/public/mobile-handlers.6f354a87.js.gz +0 -0
- package/dist/web/public/mobile.6caaa28a.css +0 -1
- package/dist/web/public/mobile.6caaa28a.css.br +0 -0
- package/dist/web/public/mobile.6caaa28a.css.gz +0 -0
- package/dist/web/public/notification-manager.36ea4624.js.br +0 -0
- package/dist/web/public/notification-manager.36ea4624.js.gz +0 -0
- package/dist/web/public/panels-ui.28df5053.js.br +0 -0
- package/dist/web/public/ralph-panel.6de2d0f8.js.br +0 -0
- package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
- package/dist/web/public/settings-ui.df5ffb68.js +0 -78
- package/dist/web/public/settings-ui.df5ffb68.js.br +0 -0
- package/dist/web/public/settings-ui.df5ffb68.js.gz +0 -0
- package/dist/web/public/styles.74f312e5.css +0 -1
- package/dist/web/public/styles.74f312e5.css.br +0 -0
- package/dist/web/public/styles.74f312e5.css.gz +0 -0
- package/dist/web/public/tab-rail-resize.42c24949.js +0 -1
- package/dist/web/public/tab-rail-resize.42c24949.js.br +0 -0
- package/dist/web/public/tab-rail-resize.42c24949.js.gz +0 -0
- package/dist/web/public/terminal-ui.32fbeaf6.js +0 -2
- package/dist/web/public/terminal-ui.32fbeaf6.js.br +0 -0
- package/dist/web/public/terminal-ui.32fbeaf6.js.gz +0 -0
package/dist/tmux-manager.js
CHANGED
|
@@ -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
|
-
/**
|
|
132
|
-
|
|
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
|
|
147
|
-
* into
|
|
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
|
|
153
|
-
const
|
|
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
|
-
|
|
158
|
-
if (sep === -1)
|
|
185
|
+
if (!line.includes(PANE_LIST_SEP))
|
|
159
186
|
continue;
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
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
|
-
|
|
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();
|