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/session.js
CHANGED
|
@@ -33,13 +33,15 @@ import { v4 as uuidv4 } from 'uuid';
|
|
|
33
33
|
import * as pty from 'node-pty';
|
|
34
34
|
import { DEFAULT_NICE_CONFIG, getErrorMessage, isEffortLevel, } from './types.js';
|
|
35
35
|
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
|
|
36
|
+
import { claudeTranscriptExists } from './utils/claude-transcript.js';
|
|
37
|
+
import { matchesPattern } from './config/cli-registry/patterns.js';
|
|
36
38
|
import { probeDockerCliVersion } from './docker-hosts.js';
|
|
37
39
|
import { probeRemoteCliVersion } from './remote-hosts.js';
|
|
38
40
|
import { TaskTracker } from './task-tracker.js';
|
|
39
41
|
import { RalphTracker } from './ralph-tracker.js';
|
|
40
42
|
import { BashToolParser } from './bash-tool-parser.js';
|
|
41
43
|
import { isTrustDialogScreen, trustDialogNextKey, TRUST_KEY_CONFIRM, TRUST_DIALOG_WINDOW_MS, TRUST_DIALOG_RETRY_MS, TRUST_DIALOG_MAX_ATTEMPTS, TRUST_DIALOG_SCAN_BYTES, } from './session-trust-dialog.js';
|
|
42
|
-
import { trackActivityStreak, isSustainedActivity, isPaneQuiet, IDLE_RECHECK_MS, PANE_PROBE_MIN_INTERVAL_MS, PANE_PROBE_RECHECK_MS, } from './session-activity.js';
|
|
44
|
+
import { trackActivityStreak, isSustainedActivity, isPaneQuiet, watchingLabel, WATCHING_TAIL_LINES, IDLE_RECHECK_MS, PANE_PROBE_MIN_INTERVAL_MS, PANE_PROBE_RECHECK_MS, } from './session-activity.js';
|
|
43
45
|
import { BufferAccumulator, ANSI_ESCAPE_PATTERN_FULL, TOKEN_PATTERN, SPINNER_PATTERN, CLAUDE_WORKING_LINE_PATTERN, MAX_SESSION_TOKENS, execPattern, getClaudeCliVersion, getClaudeBinaryPath, spawnPtyWithHelperRepair, resolveLocalShell, } from './utils/index.js';
|
|
44
46
|
import { MAX_TERMINAL_BUFFER_SIZE, TRIM_TERMINAL_TO as TERMINAL_BUFFER_TRIM_SIZE, MAX_TEXT_OUTPUT_SIZE, TRIM_TEXT_TO as TEXT_OUTPUT_TRIM_SIZE, MAX_MESSAGES, MAX_LINE_BUFFER_SIZE, } from './config/buffer-limits.js';
|
|
45
47
|
import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js';
|
|
@@ -358,8 +360,35 @@ export class Session extends EventEmitter {
|
|
|
358
360
|
_activityStreak = null; // Unbroken run of PTY repaints (working detection)
|
|
359
361
|
_lastPaneProbeAt = 0; // Throttle for the tmux screen probe
|
|
360
362
|
_lastPaneProbeWorking = null; // Its last verdict (null = could not read)
|
|
363
|
+
/**
|
|
364
|
+
* Background work the pane's own footer reports, e.g. `1 monitor`; null for none.
|
|
365
|
+
*
|
|
366
|
+
* Cached BESIDE `_lastPaneProbeWorking` and refreshed only by a capture that really
|
|
367
|
+
* happened, so it goes stale exactly as that verdict does. The probe returns its
|
|
368
|
+
* cached boolean without re-capturing inside `PANE_PROBE_MIN_INTERVAL_MS`, and a
|
|
369
|
+
* label derived from a capture nobody took would be a guess wearing a fact's clothes.
|
|
370
|
+
*
|
|
371
|
+
* ⚠️ It then FREEZES once `_confirmIdle()` concludes: `activityTimeout` is null from
|
|
372
|
+
* there, and nothing looks at the pane again until it produces output. That is
|
|
373
|
+
* correct rather than merely tolerable, because work ending repaints the pane either
|
|
374
|
+
* way — a monitor firing wakes the agent, and codex drops its background-terminal row
|
|
375
|
+
* on its own. Do not add a timer to keep this fresh; it would spend a `capture-pane`
|
|
376
|
+
* per idle session per tick to learn nothing.
|
|
377
|
+
*
|
|
378
|
+
* A server restart is not a hole in that either, though it looks like one: this field
|
|
379
|
+
* is live state and starts empty. Reconciliation re-attaches the pane, the attach
|
|
380
|
+
* repaint carries the composer glyph, and the idle confirmation that arms on it probes
|
|
381
|
+
* and re-reads the label with no input from anyone — measured 2026-09-23 on a restarted
|
|
382
|
+
* instance, back within ~20 s for a session whose background terminal was still
|
|
383
|
+
* running. A session that comes back with no label has no chip on its screen.
|
|
384
|
+
*/
|
|
385
|
+
_watching = null;
|
|
361
386
|
/** Lazily compiled `capabilities.workDetect.workingLine`. See _workingLinePattern(). */
|
|
362
387
|
_workingLineRe = undefined;
|
|
388
|
+
/** Lazily compiled `capabilities.workDetect.watchingLine`. See _watchingLinePattern(). */
|
|
389
|
+
_watchingLineRe = undefined;
|
|
390
|
+
/** Resolved with the pattern above: how many rows at the foot of the screen to search. */
|
|
391
|
+
_watchingWindow = WATCHING_TAIL_LINES;
|
|
363
392
|
_trustDialogAccepted = false; // Stops the trust-dialog scan (answered, or given up)
|
|
364
393
|
_trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
|
|
365
394
|
_lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
|
|
@@ -395,6 +424,20 @@ export class Session extends EventEmitter {
|
|
|
395
424
|
_mux = null;
|
|
396
425
|
_muxSession = null;
|
|
397
426
|
_useMux = false;
|
|
427
|
+
/**
|
|
428
|
+
* The agent in this session's local tmux pane has exited (Ark0N/Codeman#446).
|
|
429
|
+
* `null` is the UNKNOWN arm of the tri-state and is what {@link setPaneExit}
|
|
430
|
+
* stores for every session shape the field does not apply to. See
|
|
431
|
+
* {@link PaneExit} for the shapes and for why an unknown answer must never be
|
|
432
|
+
* rendered as "alive".
|
|
433
|
+
*/
|
|
434
|
+
_paneExit = null;
|
|
435
|
+
/**
|
|
436
|
+
* This session was rebuilt from the tmux socket rather than from Codeman's
|
|
437
|
+
* own records, so its `remote`/`docker` metadata is missing rather than known
|
|
438
|
+
* to be absent. See {@link MuxSession.discovered}.
|
|
439
|
+
*/
|
|
440
|
+
_discoveredMuxSession = false;
|
|
398
441
|
// Flag to prevent new timers after session is stopped
|
|
399
442
|
_isStopped = false;
|
|
400
443
|
// Ralph tracking (Ralph Wiggum loops and todo lists inside Claude Code)
|
|
@@ -606,6 +649,16 @@ export class Session extends EventEmitter {
|
|
|
606
649
|
this._remote = config.remote;
|
|
607
650
|
this._docker = config.docker;
|
|
608
651
|
this._owner = config.owner;
|
|
652
|
+
this._discoveredMuxSession = config.discoveredMuxSession === true;
|
|
653
|
+
// Restored so a record that says the agent exited survives a server restart
|
|
654
|
+
// rather than being blanked by the first persist after boot. It runs here
|
|
655
|
+
// because the scoping reads `_remote`, `_docker` and the mux fields, all of
|
|
656
|
+
// which are set by now. It is a claim about a pane this process has not
|
|
657
|
+
// looked at yet, so every path that starts or re-attaches a pane drops it
|
|
658
|
+
// (see `_setupOrAttachMuxSession`) and the pane-exit watcher's own tick
|
|
659
|
+
// replaces it with a first-hand reading. NOT the stats collector, which a
|
|
660
|
+
// browser panel arms and disarms — see `startPaneExitWatcher`.
|
|
661
|
+
this.setPaneExit(config.paneExit);
|
|
609
662
|
// Never self-parent: a session pointing at itself would draw a zero-length
|
|
610
663
|
// lineage arc under its own tab. Only reachable via the recovery path, where
|
|
611
664
|
// both the id and the saved parent come from disk.
|
|
@@ -810,6 +863,72 @@ export class Session extends EventEmitter {
|
|
|
810
863
|
get muxName() {
|
|
811
864
|
return this._muxSession?.muxName ?? null;
|
|
812
865
|
}
|
|
866
|
+
/**
|
|
867
|
+
* True when a tmux pane's death would mean THIS session's agent has exited.
|
|
868
|
+
*
|
|
869
|
+
* Four shapes fail the test, and each would otherwise publish a death that is
|
|
870
|
+
* not the agent's. A direct-PTY session owns no pane at all. A remote SSH
|
|
871
|
+
* session's local pane holds the ssh client, whose death means a transport
|
|
872
|
+
* drop OR an exit, which is the ambiguity PR #355 was about. A docker case's
|
|
873
|
+
* local pane holds a `docker exec` into the container's own tmux.
|
|
874
|
+
*
|
|
875
|
+
* The fourth is a session rebuilt from the socket. Absent `remote`/`docker`
|
|
876
|
+
* normally means "this is local", but on a discovered record it only means
|
|
877
|
+
* "Codeman never found the metadata": the synthetic `restored-<fragment>` id
|
|
878
|
+
* matches no `state.json` entry, so a remote session rediscovered after
|
|
879
|
+
* `mux-sessions.json` was lost arrives looking local, and its next transport
|
|
880
|
+
* drop would be published as an agent exit. Unproven locality fails closed.
|
|
881
|
+
*/
|
|
882
|
+
get paneExitApplies() {
|
|
883
|
+
if (this._discoveredMuxSession)
|
|
884
|
+
return false;
|
|
885
|
+
return this._useMux && this._muxSession !== null && !this._remote && !this._docker;
|
|
886
|
+
}
|
|
887
|
+
/** What Codeman last observed of this pane's agent, or undefined for UNKNOWN. */
|
|
888
|
+
get paneExit() {
|
|
889
|
+
return this._paneExit ?? undefined;
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Forget this pane's exit, on both this record and the mux layer's cache.
|
|
893
|
+
* Every path that starts or relaunches a command in the pane calls it, and
|
|
894
|
+
* the mux half also invalidates a pane read already in flight.
|
|
895
|
+
*
|
|
896
|
+
* It does not persist or broadcast by itself; the caller owns both. ⚠ That
|
|
897
|
+
* caller MUST persist, and the pane-exit watcher is not a fallback for it:
|
|
898
|
+
* the watcher's next tick reads UNKNOWN, finds this field already cleared,
|
|
899
|
+
* reports no change and therefore writes nothing, so a caller that only
|
|
900
|
+
* broadcasts leaves `state.json` saying the agent exited for as long as the
|
|
901
|
+
* session stays quiet. `/interactive` and `/shell` did exactly that until
|
|
902
|
+
* Ark0N/Codeman#446 review; both now persist on their success path.
|
|
903
|
+
*/
|
|
904
|
+
clearPaneExitForNewPane() {
|
|
905
|
+
this.setPaneExit(undefined);
|
|
906
|
+
if (this._muxSession)
|
|
907
|
+
this._mux?.clearPaneExit?.(this._muxSession.muxName);
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* Record what the mux layer observed of this pane's agent, and say whether
|
|
911
|
+
* that changed the answer. The caller persists and broadcasts on a true.
|
|
912
|
+
*
|
|
913
|
+
* A session the field does not apply to is forced to UNKNOWN here rather than
|
|
914
|
+
* at the reporting end, so the rule lives in one place and the mux layer stays
|
|
915
|
+
* free to report the raw pane reading its own remote-reconnect watcher needs.
|
|
916
|
+
*/
|
|
917
|
+
setPaneExit(next) {
|
|
918
|
+
const resolved = this.paneExitApplies ? (next ?? null) : null;
|
|
919
|
+
const prev = this._paneExit;
|
|
920
|
+
if (prev === resolved)
|
|
921
|
+
return false;
|
|
922
|
+
if (prev !== null &&
|
|
923
|
+
resolved !== null &&
|
|
924
|
+
prev.status === resolved.status &&
|
|
925
|
+
prev.signal === resolved.signal &&
|
|
926
|
+
prev.at === resolved.at) {
|
|
927
|
+
return false;
|
|
928
|
+
}
|
|
929
|
+
this._paneExit = resolved;
|
|
930
|
+
return true;
|
|
931
|
+
}
|
|
813
932
|
/**
|
|
814
933
|
* True when this session's PTY is a tmux client rather than the program itself.
|
|
815
934
|
* Read by the replay-side alt-screen strip, which must apply the same
|
|
@@ -827,6 +946,15 @@ export class Session extends EventEmitter {
|
|
|
827
946
|
get isWorking() {
|
|
828
947
|
return this._isWorking;
|
|
829
948
|
}
|
|
949
|
+
/**
|
|
950
|
+
* What the pane says is still running in the background, e.g. `1 monitor`, or null when
|
|
951
|
+
* nothing is. A session with a label here has ended its turn without wanting anything
|
|
952
|
+
* from the user, so a surface that would otherwise file it under "needs you" can say
|
|
953
|
+
* what it is waiting for instead.
|
|
954
|
+
*/
|
|
955
|
+
get watching() {
|
|
956
|
+
return this._watching;
|
|
957
|
+
}
|
|
830
958
|
/**
|
|
831
959
|
* Check if the session's process tree has active child processes beyond Claude itself.
|
|
832
960
|
* Detects running bash tools, test suites, builds, servers, etc. that Claude spawned.
|
|
@@ -1265,6 +1393,10 @@ export class Session extends EventEmitter {
|
|
|
1265
1393
|
// by the constructor: a Codeman restart starts with a fresh breaker so boot
|
|
1266
1394
|
// recovery can re-attach.
|
|
1267
1395
|
respawnBlocked: this._respawnBlocked || undefined,
|
|
1396
|
+
// Ark0N/Codeman#446 — the agent in this pane has exited, published here so
|
|
1397
|
+
// it rides the existing `session:updated` broadcast and lands in state.json
|
|
1398
|
+
// through the same persist. `status` and `pid` above stay untouched by it.
|
|
1399
|
+
paneExit: this._paneExit ?? undefined,
|
|
1268
1400
|
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
|
|
1269
1401
|
lastSubmitAt: this._lastSubmitAt || undefined,
|
|
1270
1402
|
// Only a chain the CLI's own hooks vouched for is persisted, and only when
|
|
@@ -1319,6 +1451,7 @@ export class Session extends EventEmitter {
|
|
|
1319
1451
|
totalCost: this._totalCost,
|
|
1320
1452
|
messageCount: this._messages.length,
|
|
1321
1453
|
isWorking: this._isWorking,
|
|
1454
|
+
watching: this._watching,
|
|
1322
1455
|
lastPromptTime: this._lastPromptTime,
|
|
1323
1456
|
// Buffer statistics for monitoring long-running sessions
|
|
1324
1457
|
bufferStats: {
|
|
@@ -1372,38 +1505,78 @@ export class Session extends EventEmitter {
|
|
|
1372
1505
|
*/
|
|
1373
1506
|
async _setupOrAttachMuxSession(options) {
|
|
1374
1507
|
const mux = this._mux;
|
|
1375
|
-
// Verify stale mux session — tmux may have been destroyed (e.g., killed externally)
|
|
1508
|
+
// Verify stale mux session — tmux may have been destroyed (e.g., killed externally).
|
|
1509
|
+
// A session that HAD a mux session relaunches its CLI below just like a failed
|
|
1510
|
+
// respawn does (tmux kill-server, a tmux crash, an external kill-session), so
|
|
1511
|
+
// its transcript collides with the bare `--session-id` the same way. A
|
|
1512
|
+
// genuinely new session starts with `_muxSession` null and never sets this.
|
|
1513
|
+
let muxSessionVanished = false;
|
|
1376
1514
|
if (this._muxSession && !mux.muxSessionExists(this._muxSession.muxName)) {
|
|
1377
1515
|
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
|
|
1378
1516
|
this._muxSession = null;
|
|
1517
|
+
muxSessionVanished = true;
|
|
1379
1518
|
}
|
|
1380
1519
|
// Check if session exists but pane is dead (remain-on-exit keeps it alive)
|
|
1381
1520
|
// Respawn the pane instead of creating a whole new session — preserves tmux scrollback
|
|
1382
1521
|
let needsNewSession = false;
|
|
1522
|
+
let respawnedDeadPane = false;
|
|
1523
|
+
let respawnedResumeId;
|
|
1383
1524
|
if (this._muxSession && mux.isPaneDead(this._muxSession.muxName)) {
|
|
1384
1525
|
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
|
1385
1526
|
// Confirmed dead — safe to resolve/pin now (see `_pinOmpRespawnId()`).
|
|
1386
1527
|
// `options.respawnPaneOptions` was built eagerly before this dead-pane
|
|
1387
1528
|
// check ran, so it still carries the pre-pin ompConfig; rebuild it.
|
|
1388
1529
|
this._pinOmpRespawnId();
|
|
1389
|
-
const
|
|
1530
|
+
const respawnOptions = await this._buildRespawnPaneOptionsWithResumePin();
|
|
1531
|
+
const newPid = await mux.respawnPane(respawnOptions);
|
|
1390
1532
|
if (!newPid) {
|
|
1391
1533
|
console.error('[Session] Failed to respawn pane, will create new session');
|
|
1392
1534
|
needsNewSession = true;
|
|
1393
1535
|
}
|
|
1394
1536
|
else {
|
|
1537
|
+
respawnedDeadPane = true;
|
|
1538
|
+
respawnedResumeId = respawnOptions.resumeSessionId;
|
|
1395
1539
|
this._pendingEnvUnsets.clear();
|
|
1396
1540
|
// Wait a moment for the respawned process to fully start
|
|
1397
1541
|
await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS));
|
|
1398
1542
|
}
|
|
1399
1543
|
}
|
|
1544
|
+
// Whatever the last reading said about the OLD command in this pane is now
|
|
1545
|
+
// history: the branch above either respawned the pane or found it alive, and
|
|
1546
|
+
// the branch below creates a new one. The paths that reach here are boot
|
|
1547
|
+
// recovery and an explicit start, NOT a click on an exited tab — the browser
|
|
1548
|
+
// re-attaches only on a null pid, and the premise of Ark0N/Codeman#446 is
|
|
1549
|
+
// that an exited pane keeps its pid. `restartCli()` clears separately.
|
|
1550
|
+
this.clearPaneExitForNewPane();
|
|
1400
1551
|
// Check if we already have a mux session (restored session)
|
|
1401
1552
|
const isRestored = this._muxSession !== null && !needsNewSession;
|
|
1402
1553
|
if (isRestored) {
|
|
1403
1554
|
console.log('[Session] Attaching to existing mux session:', this._muxSession.muxName);
|
|
1404
1555
|
}
|
|
1405
1556
|
else {
|
|
1406
|
-
// Create a new mux session
|
|
1557
|
+
// Create a new mux session. When this is the FALLBACK after a failed
|
|
1558
|
+
// respawn, the eagerly-built create options still carry the unpinned
|
|
1559
|
+
// launch seed, so a session whose transcript exists would meet the same
|
|
1560
|
+
// `--session-id ... already in use` refusal the respawn just lost to —
|
|
1561
|
+
// the recovery of last resort failing for the very reason it was needed.
|
|
1562
|
+
// A genuinely new session has no transcript under any of its candidate
|
|
1563
|
+
// ids, so nothing is pinned and its command shape is unchanged.
|
|
1564
|
+
//
|
|
1565
|
+
// `_resumeSessionId` is written alongside, not just the create options:
|
|
1566
|
+
// this branch leaves `isRestored` false, so the block that sets
|
|
1567
|
+
// `_claudeSessionId` below reads that field and would otherwise settle on
|
|
1568
|
+
// `this.id` while the CLI resumes the chain tail. The response viewer,
|
|
1569
|
+
// Read My Mind and the unified-list alias map all read `_claudeSessionId`
|
|
1570
|
+
// until the next first-hand hook, so the two have to name the same
|
|
1571
|
+
// conversation. The vanished-tmux-session branch above relaunches for the
|
|
1572
|
+
// same reason and takes the same pin.
|
|
1573
|
+
if (needsNewSession || muxSessionVanished) {
|
|
1574
|
+
const pinned = (await this._buildRespawnPaneOptionsWithResumePin()).resumeSessionId;
|
|
1575
|
+
if (pinned) {
|
|
1576
|
+
options.createSessionOptions.resumeSessionId = pinned;
|
|
1577
|
+
this._resumeSessionId = pinned;
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1407
1580
|
this._muxSession = await mux.createSession(options.createSessionOptions);
|
|
1408
1581
|
console.log('[Session] Created mux session:', this._muxSession.muxName);
|
|
1409
1582
|
// No extra sleep — createSession() already waits for tmux readiness
|
|
@@ -1435,13 +1608,14 @@ export class Session extends EventEmitter {
|
|
|
1435
1608
|
// hand-maintained lists of mode names that had to be edited in lockstep.
|
|
1436
1609
|
env: buildMuxAttachEnv(cliExportsTruecolor(this.mode)),
|
|
1437
1610
|
}));
|
|
1611
|
+
this._notePtySpawnGeometry(ptyCols, ptyRows);
|
|
1438
1612
|
}
|
|
1439
1613
|
catch (spawnErr) {
|
|
1440
1614
|
console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr);
|
|
1441
1615
|
this.emit('error', `Failed to attach to mux session: ${spawnErr}`);
|
|
1442
1616
|
throw spawnErr;
|
|
1443
1617
|
}
|
|
1444
|
-
return { isRestored };
|
|
1618
|
+
return { isRestored, respawnedResumeId, respawnedDeadPane };
|
|
1445
1619
|
}
|
|
1446
1620
|
/**
|
|
1447
1621
|
* COD-108 — re-establish a dropped REMOTE session. Triggered by the
|
|
@@ -1478,6 +1652,9 @@ export class Session extends EventEmitter {
|
|
|
1478
1652
|
console.error('[Session] reattachRemote: respawnPane failed for', this._muxSession.muxName);
|
|
1479
1653
|
return false;
|
|
1480
1654
|
}
|
|
1655
|
+
// No-op for the record (a remote session's field is always UNKNOWN), but the
|
|
1656
|
+
// mux layer's cache is keyed by muxName and this pane now runs a new client.
|
|
1657
|
+
this.clearPaneExitForNewPane();
|
|
1481
1658
|
console.log('[Session] reattachRemote: reattached remote session', this._muxSession.muxName, 'pid', newPid);
|
|
1482
1659
|
return true;
|
|
1483
1660
|
}
|
|
@@ -1510,26 +1687,16 @@ export class Session extends EventEmitter {
|
|
|
1510
1687
|
return false;
|
|
1511
1688
|
}
|
|
1512
1689
|
this._pinOmpRespawnId();
|
|
1513
|
-
const
|
|
1514
|
-
// Unlike the dead-pane respawn, this one kills a WORKING pane whose conversation
|
|
1515
|
-
// already has a transcript, and a CLI that launches with `--session-id <id>` refuses
|
|
1516
|
-
// an id that is already in use (claude: `Error: Session ID ... is already in use.`),
|
|
1517
|
-
// which turned an endpoint switch into a dead pane and a lost session. A launch that
|
|
1518
|
-
// declares a `fallback` chain renders `resume || new` once a resume id is set, the
|
|
1519
|
-
// same `--resume <id> || --session-id <id>` shape the docker and remote pane commands
|
|
1520
|
-
// already use, so pin the live conversation id for THIS respawn only. The registry
|
|
1521
|
-
// shape is the gate, not the CLI's name: an entry whose resume id is minted by the
|
|
1522
|
-
// CLI itself (codex/pi/omp/grok) never declares that chain, and its resume field is
|
|
1523
|
-
// read from its own `<Mode>Config` rather than this top-level one anyway.
|
|
1524
|
-
if (!options.resumeSessionId && getCli(this.mode)?.launch.chain === 'fallback') {
|
|
1525
|
-
options.resumeSessionId = this._claudeSessionId ?? this.id;
|
|
1526
|
-
}
|
|
1527
|
-
const newPid = await mux.respawnPane(options);
|
|
1690
|
+
const newPid = await mux.respawnPane(await this._buildRespawnPaneOptionsWithResumePin());
|
|
1528
1691
|
if (!newPid) {
|
|
1529
1692
|
console.error('[Session] restartCli: respawnPane failed for', this._muxSession.muxName);
|
|
1530
1693
|
return false;
|
|
1531
1694
|
}
|
|
1532
1695
|
this._pendingEnvUnsets.clear();
|
|
1696
|
+
// A relaunch in the same pane, so any exit observed of the previous command
|
|
1697
|
+
// is history. Without this the caller's persist-and-broadcast writes the old
|
|
1698
|
+
// exit straight back onto a session that is running again.
|
|
1699
|
+
this.clearPaneExitForNewPane();
|
|
1533
1700
|
console.log('[Session] restartCli: restarted CLI for', this._muxSession.muxName, 'pid', newPid);
|
|
1534
1701
|
return true;
|
|
1535
1702
|
}
|
|
@@ -1577,6 +1744,112 @@ export class Session extends EventEmitter {
|
|
|
1577
1744
|
};
|
|
1578
1745
|
return this._withCustomModelLaunchModel(options);
|
|
1579
1746
|
}
|
|
1747
|
+
/**
|
|
1748
|
+
* Respawn options for a pane whose command is being REPLACED, with the
|
|
1749
|
+
* conversation pinned so the relaunch resumes rather than collides.
|
|
1750
|
+
*
|
|
1751
|
+
* A CLI that launches with `--session-id <id>` refuses an id that is already
|
|
1752
|
+
* in use (claude: `Error: Session ID ... is already in use.`), and every
|
|
1753
|
+
* session whose agent has been prompted owns a transcript under that id. So
|
|
1754
|
+
* relaunching such a pane with the bare launch line fails, the pane dies
|
|
1755
|
+
* again immediately, and the user's conversation is stranded. A launch that
|
|
1756
|
+
* declares a `fallback` chain renders `resume || new` once a resume id is
|
|
1757
|
+
* set, which is the shape that survives both cases.
|
|
1758
|
+
*
|
|
1759
|
+
* Three candidates are tried in priority order — the conversation chain's
|
|
1760
|
+
* tail, the launch seed, then the session's own id — and the first one a
|
|
1761
|
+
* transcript backs is pinned. Four conditions gate that walk, each protecting
|
|
1762
|
+
* against a way of resuming the WRONG conversation or of making a working
|
|
1763
|
+
* relaunch fail.
|
|
1764
|
+
*
|
|
1765
|
+
* ⚠️ **A remote or docker session is never pinned.** Unlike `restartCli()`,
|
|
1766
|
+
* whose route refuses both, the dead-pane respawn is reached by every session
|
|
1767
|
+
* shape. Their pane commands (`buildRemoteLaunchCommand`,
|
|
1768
|
+
* `claudeDockerPaneCommand`) already render a SELF-HEALING
|
|
1769
|
+
* `--session-id <sid> || --resume <sid>`, and both flip to resume-first the
|
|
1770
|
+
* moment the resume id differs from the session id. The conversation lives on
|
|
1771
|
+
* the far side, so a local id pinned onto it resolves to nothing there, the
|
|
1772
|
+
* resume fails, and the `--session-id` fallback then collides with the
|
|
1773
|
+
* transcript the far side really does hold — both branches fail and the pane
|
|
1774
|
+
* dies. `_pinOmpRespawnId()` refuses remote for the same reason.
|
|
1775
|
+
*
|
|
1776
|
+
* ⚠️ **The candidates come from the conversation CHAIN, never from
|
|
1777
|
+
* `_claudeSessionId`.** That field holds either a first-hand id from the
|
|
1778
|
+
* CLI's own hook payload or a history correlation, which is a guess keyed on
|
|
1779
|
+
* the working directory. `_recordClaudeSessionInChain()` refuses a guess
|
|
1780
|
+
* precisely so it cannot "write a foreign conversation into this pane's
|
|
1781
|
+
* permanent record", and launching from one would do worse than the display
|
|
1782
|
+
* bug that rule exists to prevent: the relaunched CLI would open and WRITE to
|
|
1783
|
+
* a conversation that was never this pane's. The chain's tail is the live
|
|
1784
|
+
* conversation and is hook-vouched, so it leads the walk, ahead of the launch
|
|
1785
|
+
* seed, which is written once at construction and never moves off a `/clear`.
|
|
1786
|
+
*
|
|
1787
|
+
* ⚠️ **Every candidate must be backed by a transcript, the session's own id
|
|
1788
|
+
* included, and a candidate that has none is passed over rather than ending
|
|
1789
|
+
* the walk.** A pin that differs from the session id leaves
|
|
1790
|
+
* `--session-id <this.id>` in the fallback branch, so a resume that finds
|
|
1791
|
+
* nothing collides there and the pane dies exactly as it did before this
|
|
1792
|
+
* pinning existed. Pinning `this.id` renders the self-healing
|
|
1793
|
+
* `--resume <id> || --session-id <id>`, which is correct whether or not a
|
|
1794
|
+
* transcript exists, but a pane that has none pays for the shape twice:
|
|
1795
|
+
* claude prints "No conversation found" into the scrollback of a session that
|
|
1796
|
+
* is brand new, and `wrapWithNice()` prefixes only the FIRST branch of the
|
|
1797
|
+
* rendered `a || b`, so the branch that actually runs loses its priority for
|
|
1798
|
+
* the life of the session. Falling off the end of the walk therefore adds
|
|
1799
|
+
* no pin (the options keep any launch seed they already carried), which is
|
|
1800
|
+
* the right answer: with no transcript anywhere there is nothing for the
|
|
1801
|
+
* bare `--session-id <this.id>` to collide with.
|
|
1802
|
+
*
|
|
1803
|
+
* The create route pre-validates a resume id for the same reason, though it
|
|
1804
|
+
* additionally requires the transcript be substantial — here mere existence
|
|
1805
|
+
* is the question, because a one-line transcript still makes `--session-id`
|
|
1806
|
+
* collide.
|
|
1807
|
+
*
|
|
1808
|
+
* The registry shape is the last gate, not the CLI's name: an entry whose
|
|
1809
|
+
* resume id is minted by the CLI itself (codex/pi/omp/grok) declares no
|
|
1810
|
+
* `fallback` chain and reads its resume field from its own `<Mode>Config`.
|
|
1811
|
+
*
|
|
1812
|
+
* `reattachRemote()` deliberately does NOT call this. It re-runs the remote
|
|
1813
|
+
* session command, which attaches to the durable remote tmux with the agent
|
|
1814
|
+
* still inside it and renders no local `--session-id` to collide.
|
|
1815
|
+
*/
|
|
1816
|
+
async _buildRespawnPaneOptionsWithResumePin() {
|
|
1817
|
+
const options = this._buildRespawnPaneOptions();
|
|
1818
|
+
if (this._remote || this._docker)
|
|
1819
|
+
return options;
|
|
1820
|
+
const entry = getCli(this.mode);
|
|
1821
|
+
if (entry?.launch.chain !== 'fallback')
|
|
1822
|
+
return options;
|
|
1823
|
+
const resumeIdPattern = entry.launch.params?.resumeId;
|
|
1824
|
+
const configDir = this._claudeConfigDir();
|
|
1825
|
+
const chainTail = this._claudeSessionChain[this._claudeSessionChain.length - 1];
|
|
1826
|
+
const candidates = [chainTail, options.resumeSessionId, this.id].filter((v) => !!v);
|
|
1827
|
+
for (const candidate of candidates) {
|
|
1828
|
+
// A session Codeman DISCOVERED on the socket rather than created carries a
|
|
1829
|
+
// synthetic `restored-<fragment>` id, which fails claude's `uuid` token
|
|
1830
|
+
// pattern. The renderer would silently drop the resume flag and emit the
|
|
1831
|
+
// unpinned command, so say so here rather than letting the caller believe
|
|
1832
|
+
// the pane was pinned.
|
|
1833
|
+
if (resumeIdPattern?.type === 'token' && !matchesPattern(resumeIdPattern.pattern, candidate)) {
|
|
1834
|
+
console.log(`[Session] Not pinning resume id ${candidate} for relaunch: the CLI cannot accept that id shape`);
|
|
1835
|
+
continue;
|
|
1836
|
+
}
|
|
1837
|
+
if (!(await claudeTranscriptExists(candidate, configDir)))
|
|
1838
|
+
continue;
|
|
1839
|
+
options.resumeSessionId = candidate;
|
|
1840
|
+
return options;
|
|
1841
|
+
}
|
|
1842
|
+
// Nothing on disk to collide with, so the bare `--session-id <this.id>` the
|
|
1843
|
+
// unpinned options already carry is the correct command.
|
|
1844
|
+
return options;
|
|
1845
|
+
}
|
|
1846
|
+
/** The session's Claude config dir when it has been relocated (#255), else undefined. */
|
|
1847
|
+
_claudeConfigDir() {
|
|
1848
|
+
// Trimmed like `claudeProjectsDir()` trims the process-wide override: the
|
|
1849
|
+
// envOverrides schema validates keys only, and a whitespace-only value would
|
|
1850
|
+
// otherwise resolve to a relative path and read "no transcript" for everything.
|
|
1851
|
+
return this._envOverrides?.CLAUDE_CONFIG_DIR?.trim() || undefined;
|
|
1852
|
+
}
|
|
1580
1853
|
/**
|
|
1581
1854
|
* Force the custom-model selection's `launchModel` (pi/omp `custom/<id>`, grok's
|
|
1582
1855
|
* `[model.<name>]` block name) onto the CLI's `model` launch param. Where that param
|
|
@@ -1892,7 +2165,7 @@ export class Session extends EventEmitter {
|
|
|
1892
2165
|
// If mux wrapping is enabled, create or attach to a mux session
|
|
1893
2166
|
if (this._useMux && this._mux) {
|
|
1894
2167
|
try {
|
|
1895
|
-
const { isRestored } = await this._setupOrAttachMuxSession({
|
|
2168
|
+
const { isRestored, respawnedResumeId, respawnedDeadPane } = await this._setupOrAttachMuxSession({
|
|
1896
2169
|
// Single source of truth shared with reattachRemote() (COD-108).
|
|
1897
2170
|
respawnPaneOptions: this._buildRespawnPaneOptions(),
|
|
1898
2171
|
createSessionOptions: {
|
|
@@ -1938,7 +2211,18 @@ export class Session extends EventEmitter {
|
|
|
1938
2211
|
// persisted chain's tail is that conversation, reported first-hand by
|
|
1939
2212
|
// the CLI's own hook, so it outranks every fallback here. A NEW pane has
|
|
1940
2213
|
// an empty chain and falls through to the resume/alias fallbacks.
|
|
1941
|
-
|
|
2214
|
+
//
|
|
2215
|
+
// A dead-pane respawn is NOT that case for a CLI whose relaunch the resume
|
|
2216
|
+
// pin walk governs (`launch.chain === 'fallback'`): the CLI did stop, and
|
|
2217
|
+
// the walk may have passed over a chain tail with no transcript behind it,
|
|
2218
|
+
// so the conversation is whatever the respawn actually resumed. Undefined
|
|
2219
|
+
// there means the pane launched unpinned, which the fallbacks below name.
|
|
2220
|
+
const pinGovernsRespawn = respawnedDeadPane && getCli(this.mode)?.launch.chain === 'fallback';
|
|
2221
|
+
restoredConversation = pinGovernsRespawn
|
|
2222
|
+
? respawnedResumeId
|
|
2223
|
+
: isRestored
|
|
2224
|
+
? this._claudeSessionChain[this._claudeSessionChain.length - 1]
|
|
2225
|
+
: undefined;
|
|
1942
2226
|
this._claudeSessionId =
|
|
1943
2227
|
restoredConversation ||
|
|
1944
2228
|
this._resumeSessionId ||
|
|
@@ -2030,6 +2314,7 @@ export class Session extends EventEmitter {
|
|
|
2030
2314
|
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
|
|
2031
2315
|
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
|
2032
2316
|
}));
|
|
2317
|
+
this._notePtySpawnGeometry(120, 40);
|
|
2033
2318
|
}
|
|
2034
2319
|
catch (spawnErr) {
|
|
2035
2320
|
console.error('[Session] Failed to spawn Claude PTY:', spawnErr);
|
|
@@ -2311,8 +2596,62 @@ export class Session extends EventEmitter {
|
|
|
2311
2596
|
this._lastPaneProbeAt = now;
|
|
2312
2597
|
const text = this._mux.capturePaneText?.(this._muxSession.muxName) ?? null;
|
|
2313
2598
|
this._lastPaneProbeWorking = text === null ? null : this._workingLinePattern().test(text);
|
|
2599
|
+
this._readWatching(text);
|
|
2314
2600
|
return this._lastPaneProbeWorking;
|
|
2315
2601
|
}
|
|
2602
|
+
/**
|
|
2603
|
+
* Read the background-work chip off the same capture the working probe just took.
|
|
2604
|
+
*
|
|
2605
|
+
* The two questions are different. A turn that is running is work the user is waiting
|
|
2606
|
+
* for; a monitor, a backgrounded shell or a cloud session the agent started is work
|
|
2607
|
+
* the AGENT is waiting for, and it is the reason a pane can sit at its composer with
|
|
2608
|
+
* nothing to say and still not want anything from the user. `_confirmIdle` takes this
|
|
2609
|
+
* capture at exactly the moment the turn ends, which is the moment the answer starts
|
|
2610
|
+
* mattering.
|
|
2611
|
+
*
|
|
2612
|
+
* A capture that could not be read CLEARS the label rather than keeping the last one.
|
|
2613
|
+
* The two wrong answers are not symmetric: a stale label opens the next idle prompt
|
|
2614
|
+
* already acknowledged, so a failed capture would silence a real alert, while a dropped
|
|
2615
|
+
* label only costs a card and an alert that the next readable capture takes back.
|
|
2616
|
+
* Degrading toward the alert is the rule the whole signal is built on.
|
|
2617
|
+
*/
|
|
2618
|
+
_readWatching(paneText) {
|
|
2619
|
+
const pattern = this._watchingLinePattern();
|
|
2620
|
+
if (!pattern)
|
|
2621
|
+
return;
|
|
2622
|
+
// `null` is "the screen could not be read". That is no evidence either way, so the
|
|
2623
|
+
// label falls to null (and the change is announced below like any other).
|
|
2624
|
+
const label = paneText === null ? null : watchingLabel(paneText, pattern, this._watchingWindow);
|
|
2625
|
+
if (label === this._watching)
|
|
2626
|
+
return;
|
|
2627
|
+
this._watching = label;
|
|
2628
|
+
// ⚠️ This CHANGES while the session's status does not, so it needs an event of its
|
|
2629
|
+
// own. The label is usually set on the idle transition, which broadcasts anyway, but
|
|
2630
|
+
// it CLEARS when the work ends — and for a CLI whose background work ends without
|
|
2631
|
+
// taking a turn (measured on codex: a background terminal finishing repaints the row
|
|
2632
|
+
// away and nothing else happens) the session is idle before and after. Without this,
|
|
2633
|
+
// the server knew the badge was gone and every open page went on drawing it until
|
|
2634
|
+
// some unrelated event arrived.
|
|
2635
|
+
this.emit('watchingChanged');
|
|
2636
|
+
}
|
|
2637
|
+
/**
|
|
2638
|
+
* The regex matching this CLI's background-work chip, or null for a CLI whose registry
|
|
2639
|
+
* entry declares none. Compiled once per session, like the working-line pattern, and
|
|
2640
|
+
* null rather than a fallback: no other CLI has been measured drawing such a chip, and
|
|
2641
|
+
* guessing one would badge sessions on the strength of an unread screen.
|
|
2642
|
+
*/
|
|
2643
|
+
_watchingLinePattern() {
|
|
2644
|
+
if (this._watchingLineRe === undefined) {
|
|
2645
|
+
// The pattern and the window it runs over are one decision, so they are resolved
|
|
2646
|
+
// together: how far up the screen a CLI's row can sit is as much a property of its
|
|
2647
|
+
// layout as the row itself. Claude writes on the last row and keeps the default,
|
|
2648
|
+
// Codex pins one above its composer and declares more.
|
|
2649
|
+
const detect = getCli(this.mode)?.capabilities.workDetect;
|
|
2650
|
+
this._watchingLineRe = detect?.watchingLine ? compileVersionRegex(detect.watchingLine) : null;
|
|
2651
|
+
this._watchingWindow = detect?.watchingLines ?? WATCHING_TAIL_LINES;
|
|
2652
|
+
}
|
|
2653
|
+
return this._watchingLineRe;
|
|
2654
|
+
}
|
|
2316
2655
|
/**
|
|
2317
2656
|
* The regex matching this CLI's "a turn is running" status line.
|
|
2318
2657
|
*
|
|
@@ -2567,6 +2906,7 @@ export class Session extends EventEmitter {
|
|
|
2567
2906
|
cwd: this.workingDir,
|
|
2568
2907
|
env: buildShellEnv(this.id),
|
|
2569
2908
|
}));
|
|
2909
|
+
this._notePtySpawnGeometry(120, 40);
|
|
2570
2910
|
}
|
|
2571
2911
|
catch (spawnErr) {
|
|
2572
2912
|
console.error('[Session] Failed to spawn shell PTY:', spawnErr);
|
|
@@ -2658,6 +2998,7 @@ export class Session extends EventEmitter {
|
|
|
2658
2998
|
// Merge envOverrides after buildClaudeEnv so user settings shadow defaults.
|
|
2659
2999
|
env: { ...buildClaudeEnv(this.id), ...(this._envOverrides ?? {}) },
|
|
2660
3000
|
}));
|
|
3001
|
+
this._notePtySpawnGeometry(120, 40);
|
|
2661
3002
|
}
|
|
2662
3003
|
catch (spawnErr) {
|
|
2663
3004
|
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
|
|
@@ -3276,6 +3617,38 @@ export class Session extends EventEmitter {
|
|
|
3276
3617
|
/** Current PTY dimensions — used to skip no-op resizes that trigger Ink redraws */
|
|
3277
3618
|
_ptyCols = 120;
|
|
3278
3619
|
_ptyRows = 40;
|
|
3620
|
+
/**
|
|
3621
|
+
* Record the geometry a PTY was just spawned at. A reattached pane keeps the
|
|
3622
|
+
* tmux window's size, not the constructor's 120x40, and without this
|
|
3623
|
+
* `ptyGeometry` reported the old numbers for a live pane and the dedupe in
|
|
3624
|
+
* `resize()` skipped a real resize that happened to match them.
|
|
3625
|
+
*/
|
|
3626
|
+
_notePtySpawnGeometry(cols, rows) {
|
|
3627
|
+
this._ptyCols = cols;
|
|
3628
|
+
this._ptyRows = rows;
|
|
3629
|
+
}
|
|
3630
|
+
/**
|
|
3631
|
+
* The geometry the CLI is actually drawing for, or null when nothing is
|
|
3632
|
+
* drawing.
|
|
3633
|
+
*
|
|
3634
|
+
* Exposed because `resize()` can decline a request outright (arbitration
|
|
3635
|
+
* below) and the asking client has no other way to find out: a browser
|
|
3636
|
+
* terminal that keeps a shape the PTY refused renders garbled output rather
|
|
3637
|
+
* than wrong-sized output, because Claude Code's repaints are computed from
|
|
3638
|
+
* the width it was told (issue #464). Both transports report this back.
|
|
3639
|
+
*
|
|
3640
|
+
* ⚠️ NULL WITHOUT A PANE, never the field values. The fields are seeded at
|
|
3641
|
+
* spawn (`_notePtySpawnGeometry`) and moved by `resize()`, but a session with
|
|
3642
|
+
* a dead pane (or one created through the API and never started) still
|
|
3643
|
+
* holds the constructor defaults of 120x40, or the size of a pane that is
|
|
3644
|
+
* gone. Reporting those made a client adopt a size no process had ever
|
|
3645
|
+
* been told, and on anything narrower than 120 columns it claimed another
|
|
3646
|
+
* device owned the pane when none existed. `reconcilePtyGeometry` treats a
|
|
3647
|
+
* report with no finite numbers as no evidence, which is the truth here.
|
|
3648
|
+
*/
|
|
3649
|
+
get ptyGeometry() {
|
|
3650
|
+
return this.ptyProcess ? { cols: this._ptyCols, rows: this._ptyRows } : null;
|
|
3651
|
+
}
|
|
3279
3652
|
/**
|
|
3280
3653
|
* Live WebSocket connections that have announced a desktop viewport for this
|
|
3281
3654
|
* session. While at least one is registered, small-viewport (mobile/tablet)
|
|
@@ -3353,6 +3726,10 @@ export class Session extends EventEmitter {
|
|
|
3353
3726
|
}
|
|
3354
3727
|
if (isSmallViewport && this._desktopSizeClaims.size > 0) {
|
|
3355
3728
|
if (Date.now() - this._lastDesktopActivityAt < Session.DESKTOP_CLAIM_IDLE_MS) {
|
|
3729
|
+
// Declined. The caller is told nothing here on purpose — the decision
|
|
3730
|
+
// belongs to the session, not the socket — but the caller MUST report
|
|
3731
|
+
// `ptyGeometry` back afterwards so the asking client can adopt
|
|
3732
|
+
// the shape it did not get. Both transports do; see issue #464.
|
|
3356
3733
|
return;
|
|
3357
3734
|
}
|
|
3358
3735
|
this._mobileSizeOverride = true;
|
|
@@ -3446,6 +3823,9 @@ export class Session extends EventEmitter {
|
|
|
3446
3823
|
async stop(killMux = true) {
|
|
3447
3824
|
// Set stopped flag first to prevent new timers from being created
|
|
3448
3825
|
this._isStopped = true;
|
|
3826
|
+
// A pane that is gone is watching nothing. Nothing probes a stopped session, so
|
|
3827
|
+
// without this the last chip it drew would ride along on its row forever.
|
|
3828
|
+
this._watching = null;
|
|
3449
3829
|
this._clearAllTimers();
|
|
3450
3830
|
// Drop desktop sizing claims defensively. Sockets normally release their
|
|
3451
3831
|
// own claim on close, but a hung client's close event can lag the session
|