aicodeman 1.48.0 → 1.49.0
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/README.md +12 -3
- package/dist/config/cli-registry/schema.d.ts +1 -0
- package/dist/config/cli-registry/schema.d.ts.map +1 -1
- package/dist/config/cli-registry/schema.js +5 -1
- 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 +71 -5
- package/dist/config/cli-registry/stock.js.map +1 -1
- package/dist/config/cli-registry/types.d.ts +4 -4
- package/dist/config/cli-registry/types.d.ts.map +1 -1
- package/dist/config/foreign-tmux.d.ts +48 -0
- package/dist/config/foreign-tmux.d.ts.map +1 -0
- package/dist/config/foreign-tmux.js +48 -0
- package/dist/config/foreign-tmux.js.map +1 -0
- package/dist/custom-model-injection-apply.d.ts +4 -2
- package/dist/custom-model-injection-apply.d.ts.map +1 -1
- package/dist/custom-model-injection-apply.js +35 -21
- package/dist/custom-model-injection-apply.js.map +1 -1
- package/dist/docker-hosts.d.ts +8 -1
- package/dist/docker-hosts.d.ts.map +1 -1
- package/dist/docker-hosts.js +5 -5
- package/dist/docker-hosts.js.map +1 -1
- package/dist/foreign-tmux-discovery.d.ts +140 -0
- package/dist/foreign-tmux-discovery.d.ts.map +1 -0
- package/dist/foreign-tmux-discovery.js +366 -0
- package/dist/foreign-tmux-discovery.js.map +1 -0
- package/dist/foreign-tmux.d.ts +381 -0
- package/dist/foreign-tmux.d.ts.map +1 -0
- package/dist/foreign-tmux.js +775 -0
- package/dist/foreign-tmux.js.map +1 -0
- package/dist/git-fetch-scheduler.d.ts +2 -0
- package/dist/git-fetch-scheduler.d.ts.map +1 -1
- package/dist/git-fetch-scheduler.js +1 -1
- package/dist/git-fetch-scheduler.js.map +1 -1
- package/dist/git-fetch.d.ts +1 -1
- package/dist/git-fetch.d.ts.map +1 -1
- package/dist/git-workspace-status.d.ts +2 -2
- package/dist/git-workspace-status.d.ts.map +1 -1
- package/dist/hooks-config.d.ts +2 -1
- package/dist/hooks-config.d.ts.map +1 -1
- package/dist/hooks-config.js +2 -1
- package/dist/hooks-config.js.map +1 -1
- package/dist/live-worktree.d.ts +53 -0
- package/dist/live-worktree.d.ts.map +1 -0
- package/dist/live-worktree.js +97 -0
- package/dist/live-worktree.js.map +1 -0
- package/dist/mux-interface.d.ts +12 -1
- package/dist/mux-interface.d.ts.map +1 -1
- package/dist/reboot-restore.d.ts +1 -1
- package/dist/reboot-restore.d.ts.map +1 -1
- package/dist/reboot-restore.js +9 -0
- package/dist/reboot-restore.js.map +1 -1
- package/dist/remote-files.d.ts +150 -5
- package/dist/remote-files.d.ts.map +1 -1
- package/dist/remote-files.js +505 -7
- package/dist/remote-files.js.map +1 -1
- package/dist/remote-hosts.d.ts +15 -9
- package/dist/remote-hosts.d.ts.map +1 -1
- package/dist/remote-hosts.js +42 -20
- package/dist/remote-hosts.js.map +1 -1
- package/dist/session-cli-registry-bridge.d.ts.map +1 -1
- package/dist/session-cli-registry-bridge.js +19 -8
- package/dist/session-cli-registry-bridge.js.map +1 -1
- package/dist/session-env-clamp.d.ts +1 -1
- package/dist/session-env-clamp.d.ts.map +1 -1
- package/dist/session-env-clamp.js +4 -3
- package/dist/session-env-clamp.js.map +1 -1
- package/dist/session.d.ts +91 -21
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +190 -33
- package/dist/session.js.map +1 -1
- package/dist/tmux-manager.d.ts +74 -12
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +256 -41
- package/dist/tmux-manager.js.map +1 -1
- package/dist/types/foreign-tmux.d.ts +101 -0
- package/dist/types/foreign-tmux.d.ts.map +1 -0
- package/dist/types/foreign-tmux.js +27 -0
- package/dist/types/foreign-tmux.js.map +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/session.d.ts +106 -2
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js.map +1 -1
- package/dist/types/update.d.ts +4 -1
- package/dist/types/update.d.ts.map +1 -1
- package/dist/web/edit-history-sync.d.ts +27 -7
- package/dist/web/edit-history-sync.d.ts.map +1 -1
- package/dist/web/edit-history-sync.js +35 -19
- package/dist/web/edit-history-sync.js.map +1 -1
- package/dist/web/launch-defaults.d.ts +1 -1
- package/dist/web/launch-defaults.js +2 -2
- package/dist/web/launch-defaults.js.map +1 -1
- package/dist/web/linked-cases-writer.d.ts +43 -0
- package/dist/web/linked-cases-writer.d.ts.map +1 -0
- package/dist/web/linked-cases-writer.js +107 -0
- package/dist/web/linked-cases-writer.js.map +1 -0
- package/dist/web/paste-image-gc.d.ts +4 -1
- package/dist/web/paste-image-gc.d.ts.map +1 -1
- package/dist/web/paste-image-gc.js +4 -2
- package/dist/web/paste-image-gc.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.963b5ba8.js → app.9ea37070.js} +6 -6
- package/dist/web/public/app.9ea37070.js.br +0 -0
- package/dist/web/public/app.9ea37070.js.gz +0 -0
- package/dist/web/public/approvals-ui.js.gz +0 -0
- package/dist/web/public/constants.782170e1.js.gz +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/edit-history-ui.js +27 -14
- package/dist/web/public/edit-history-ui.js.br +0 -0
- package/dist/web/public/edit-history-ui.js.gz +0 -0
- package/dist/web/public/entrance-animations.js.gz +0 -0
- package/dist/web/public/foreign-sessions.js +476 -0
- package/dist/web/public/foreign-sessions.js.br +0 -0
- package/dist/web/public/foreign-sessions.js.gz +0 -0
- package/dist/web/public/git-status-ui.js +5 -3
- package/dist/web/public/git-status-ui.js.br +0 -0
- package/dist/web/public/git-status-ui.js.gz +0 -0
- package/dist/web/public/header-divider.eb2d2463.js.gz +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.a5e82c95.js +2 -0
- package/dist/web/public/i18n.a5e82c95.js.br +0 -0
- package/dist/web/public/i18n.a5e82c95.js.gz +0 -0
- package/dist/web/public/image-input.a5d0f8f3.js.gz +0 -0
- package/dist/web/public/index.html +36 -17
- 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.d4ec5957.js → keyboard-accessory.b941676c.js} +3 -1
- package/dist/web/public/keyboard-accessory.b941676c.js.br +0 -0
- package/dist/web/public/{keyboard-accessory.d4ec5957.js.gz → keyboard-accessory.b941676c.js.gz} +0 -0
- package/dist/web/public/mobile-handlers.f7c3f692.js.gz +0 -0
- package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
- package/dist/web/public/mobile-overview.js +10 -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.5ef2ce85.css → mobile.67c8eb5b.css} +1 -1
- package/dist/web/public/mobile.67c8eb5b.css.br +0 -0
- package/dist/web/public/{mobile.5ef2ce85.css.gz → mobile.67c8eb5b.css.gz} +0 -0
- package/dist/web/public/notification-manager.feb82c04.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/{panels-ui.b100727d.js → panels-ui.8bcd4473.js} +4 -4
- package/dist/web/public/panels-ui.8bcd4473.js.br +0 -0
- package/dist/web/public/panels-ui.8bcd4473.js.gz +0 -0
- package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
- package/dist/web/public/ralph-wizard.b6c0dd19.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.304ba0d4.js.gz +0 -0
- package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
- package/dist/web/public/session-lineage.js.gz +0 -0
- package/dist/web/public/{session-ui.5ac82f2c.js → session-ui.a7c8c095.js} +11 -11
- package/dist/web/public/session-ui.a7c8c095.js.br +0 -0
- package/dist/web/public/session-ui.a7c8c095.js.gz +0 -0
- package/dist/web/public/{settings-ui.d1d4fb99.js → settings-ui.18f92052.js} +3 -3
- package/dist/web/public/settings-ui.18f92052.js.br +0 -0
- package/dist/web/public/settings-ui.18f92052.js.gz +0 -0
- package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
- package/dist/web/public/spreadsheet-preview.75c751de.js.gz +0 -0
- package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
- package/dist/web/public/{styles.77dc538f.css → styles.6a718331.css} +1 -1
- package/dist/web/public/styles.6a718331.css.br +0 -0
- package/dist/web/public/{styles.77dc538f.css.gz → styles.6a718331.css.gz} +0 -0
- package/dist/web/public/subagent-windows.07277a58.js.gz +0 -0
- package/dist/web/public/sw.js +2 -2
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/tab-layout-browser.2d6189f7.js.gz +0 -0
- package/dist/web/public/tab-rail-resize.b5cdceaf.js.gz +0 -0
- package/dist/web/public/terminal-keycode229-recovery.98843069.js.gz +0 -0
- package/dist/web/public/terminal-split.js.gz +0 -0
- package/dist/web/public/terminal-tile.js +2 -2
- package/dist/web/public/terminal-tile.js.br +0 -0
- package/dist/web/public/terminal-tile.js.gz +0 -0
- package/dist/web/public/{terminal-ui.d7c75ce8.js → terminal-ui.a7883640.js} +2 -2
- package/dist/web/public/terminal-ui.a7883640.js.br +0 -0
- package/dist/web/public/terminal-ui.a7883640.js.gz +0 -0
- package/dist/web/public/tile-grid.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/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
- package/dist/web/public/vendor/fflate.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.276b11d9.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.1be42307.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.2342c37b.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/public/whats-new.json +9 -9
- package/dist/web/routes/case-routes.d.ts.map +1 -1
- package/dist/web/routes/case-routes.js +72 -53
- package/dist/web/routes/case-routes.js.map +1 -1
- package/dist/web/routes/discord-routes.js +1 -1
- package/dist/web/routes/discord-routes.js.map +1 -1
- package/dist/web/routes/edit-history-routes.d.ts +2 -2
- package/dist/web/routes/edit-history-routes.d.ts.map +1 -1
- package/dist/web/routes/edit-history-routes.js +4 -3
- package/dist/web/routes/edit-history-routes.js.map +1 -1
- package/dist/web/routes/file-routes.d.ts.map +1 -1
- package/dist/web/routes/file-routes.js +213 -102
- package/dist/web/routes/file-routes.js.map +1 -1
- package/dist/web/routes/git-status-routes.d.ts.map +1 -1
- package/dist/web/routes/git-status-routes.js +7 -2
- package/dist/web/routes/git-status-routes.js.map +1 -1
- package/dist/web/routes/mux-routes.d.ts +10 -3
- package/dist/web/routes/mux-routes.d.ts.map +1 -1
- package/dist/web/routes/mux-routes.js +59 -1
- package/dist/web/routes/mux-routes.js.map +1 -1
- package/dist/web/routes/ralph-routes.d.ts.map +1 -1
- package/dist/web/routes/ralph-routes.js +22 -0
- package/dist/web/routes/ralph-routes.js.map +1 -1
- package/dist/web/routes/readmymind-routes.d.ts.map +1 -1
- package/dist/web/routes/readmymind-routes.js +3 -2
- package/dist/web/routes/readmymind-routes.js.map +1 -1
- package/dist/web/routes/respawn-routes.d.ts.map +1 -1
- package/dist/web/routes/respawn-routes.js +24 -0
- package/dist/web/routes/respawn-routes.js.map +1 -1
- package/dist/web/routes/session-routes.d.ts +11 -8
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +262 -29
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/routes/system-routes.d.ts.map +1 -1
- package/dist/web/routes/system-routes.js +3 -2
- package/dist/web/routes/system-routes.js.map +1 -1
- package/dist/web/schemas.d.ts +24 -1
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +34 -9
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/self-update.d.ts +9 -0
- package/dist/web/self-update.d.ts.map +1 -1
- package/dist/web/self-update.js +19 -3
- package/dist/web/self-update.js.map +1 -1
- package/dist/web/server.d.ts +16 -3
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +84 -24
- package/dist/web/server.js.map +1 -1
- package/dist/web/session-wait-registry.d.ts +13 -0
- package/dist/web/session-wait-registry.d.ts.map +1 -1
- package/dist/web/session-wait-registry.js +16 -8
- package/dist/web/session-wait-registry.js.map +1 -1
- package/package.json +2 -2
- package/skills/codeman/SKILL.md +20 -4
- package/dist/web/public/app.963b5ba8.js.br +0 -0
- package/dist/web/public/app.963b5ba8.js.gz +0 -0
- package/dist/web/public/i18n.33f930d7.js +0 -2
- package/dist/web/public/i18n.33f930d7.js.br +0 -0
- package/dist/web/public/i18n.33f930d7.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.d4ec5957.js.br +0 -0
- package/dist/web/public/mobile.5ef2ce85.css.br +0 -0
- package/dist/web/public/panels-ui.b100727d.js.br +0 -0
- package/dist/web/public/panels-ui.b100727d.js.gz +0 -0
- package/dist/web/public/session-ui.5ac82f2c.js.br +0 -0
- package/dist/web/public/session-ui.5ac82f2c.js.gz +0 -0
- package/dist/web/public/settings-ui.d1d4fb99.js.br +0 -0
- package/dist/web/public/settings-ui.d1d4fb99.js.gz +0 -0
- package/dist/web/public/styles.77dc538f.css.br +0 -0
- package/dist/web/public/terminal-ui.d7c75ce8.js.br +0 -0
- package/dist/web/public/terminal-ui.d7c75ce8.js.gz +0 -0
package/dist/tmux-manager.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { EventEmitter } from 'node:events';
|
|
24
24
|
import { collectDescendants } from './proc-tree.js';
|
|
25
|
+
import { foreignViewSessionName, buildForeignDockerViewKillCommand, buildForeignAttachCommand, buildForeignDockerAttachCommand, buildForeignRemoteAttachCommand, } from './foreign-tmux.js';
|
|
25
26
|
import { execSync, exec, execFile } from 'node:child_process';
|
|
26
27
|
import { promisify } from 'node:util';
|
|
27
28
|
const execAsync = promisify(exec);
|
|
@@ -315,9 +316,10 @@ export function derivePaneCurrentPaths(rows, previous = new Map()) {
|
|
|
315
316
|
*
|
|
316
317
|
* Mirrors `Session.paneExitApplies`, which is where the rule is enforced. A
|
|
317
318
|
* remote session's local pane holds the ssh client, a docker case's holds a
|
|
318
|
-
* `docker exec` into the container's own tmux,
|
|
319
|
-
*
|
|
320
|
-
*
|
|
319
|
+
* `docker exec` into the container's own tmux, an ADOPTED session's holds a
|
|
320
|
+
* `tmux attach` to a session a human started rather than the agent itself, and
|
|
321
|
+
* a record rebuilt from the socket carries no provenance at all, so the session
|
|
322
|
+
* end forces all four to UNKNOWN whatever tmux reports. A tick that sees only those has nothing to
|
|
321
323
|
* learn, and `refreshPaneExits()` skips its tmux read rather than paying for
|
|
322
324
|
* the answer.
|
|
323
325
|
*
|
|
@@ -332,12 +334,27 @@ export function hasObservablePaneSession(sessions) {
|
|
|
332
334
|
continue;
|
|
333
335
|
if (session.docker)
|
|
334
336
|
continue;
|
|
337
|
+
if (session.adopt)
|
|
338
|
+
continue;
|
|
335
339
|
if (session.discovered === true)
|
|
336
340
|
continue;
|
|
337
341
|
return true;
|
|
338
342
|
}
|
|
339
343
|
return false;
|
|
340
344
|
}
|
|
345
|
+
/**
|
|
346
|
+
* Which adopted wrappers have a dead pane with a tmux client still attached, from rows of
|
|
347
|
+
* `#{pane_dead} #{session_attached} #{session_name}`. Exported for unit testing.
|
|
348
|
+
*/
|
|
349
|
+
export function endedAdoptedAttaches(rows, adopted) {
|
|
350
|
+
const ended = [];
|
|
351
|
+
for (const row of rows.split('\n')) {
|
|
352
|
+
const [dead, attached, muxName] = row.trim().split(' ');
|
|
353
|
+
if (dead === '1' && Number(attached) > 0 && adopted.has(muxName))
|
|
354
|
+
ended.push(muxName);
|
|
355
|
+
}
|
|
356
|
+
return ended;
|
|
357
|
+
}
|
|
341
358
|
/**
|
|
342
359
|
* Resolve a target pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'`.
|
|
343
360
|
* Prefers the active pane and falls back to the first valid pane.
|
|
@@ -1026,12 +1043,15 @@ export function remoteTmuxLocation(remote, sessionId) {
|
|
|
1026
1043
|
*
|
|
1027
1044
|
* Emits:
|
|
1028
1045
|
* ssh -o BatchMode=yes -t [<COD-107 connection opts>] user@host \
|
|
1029
|
-
* 'tmux -L codeman-remote new-session -A -s codeman-ssh-<id> -c <path>
|
|
1046
|
+
* 'tmux -L codeman-remote new-session -A -s codeman-ssh-<id> -c <path> /bin/sh -c <pane-command> \
|
|
1030
1047
|
* \; set -t codeman-ssh-<id> status off \; set -t codeman-ssh-<id> mouse off \
|
|
1031
1048
|
* \; set -t codeman-ssh-<id> prefix C-q \; set -s escape-time 0 \; \
|
|
1032
1049
|
* bind-key -T prefix C-q send-prefix \; unbind-key -a -T prefix \; \
|
|
1033
1050
|
* bind-key -T prefix C-q send-prefix \; set -t codeman-ssh-<id> remain-on-exit failed'
|
|
1034
1051
|
*
|
|
1052
|
+
* `<pane-command>` is `cd <path> && exec <cli>`, quoted as one token. A per-host `commands.*`
|
|
1053
|
+
* override is that one token alone, with no `/bin/sh -c` in front of it.
|
|
1054
|
+
*
|
|
1035
1055
|
* COD-107 — the connection options (`-p`, `-i`, `-J`, SOCKS `-o ProxyCommand`,
|
|
1036
1056
|
* arbitrary `-o`) come from the shared `buildSshConnectionArgs(remote)`, so the
|
|
1037
1057
|
* prereq tmux probe and this launch connect with identical options.
|
|
@@ -1045,10 +1065,12 @@ export function remoteTmuxLocation(remote, sessionId) {
|
|
|
1045
1065
|
* - The `set` options are scoped per-session (`set -t <name>` / server-level
|
|
1046
1066
|
* `set -s`), never `-g`, so they never mutate other sessions' prefix/mouse.
|
|
1047
1067
|
* - The whole tmux invocation is a SINGLE ssh argument (the remote login shell
|
|
1048
|
-
* runs it), so it is shell-quoted as one unit
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1051
|
-
*
|
|
1068
|
+
* runs it), so it is shell-quoted as one unit. The `cd && exec` command is the
|
|
1069
|
+
* pane command: Codeman's own text goes to `/bin/sh -c` as separate tmux
|
|
1070
|
+
* arguments, and a per-host `commands.*` override stays one argument, which
|
|
1071
|
+
* tmux hands to the remote default-shell (see the comment at `paneCommand`).
|
|
1072
|
+
* Each is shell-quoted, so escaping stays correct through every layer even
|
|
1073
|
+
* when the remote path contains spaces.
|
|
1052
1074
|
*/
|
|
1053
1075
|
export function buildRemoteLaunchCommand(options) {
|
|
1054
1076
|
const { mode, remote, sessionId, claudeMode, allowedTools, ompConfig, resumeSessionId } = options;
|
|
@@ -1056,9 +1078,9 @@ export function buildRemoteLaunchCommand(options) {
|
|
|
1056
1078
|
// hardcoding --dangerously-skip-permissions, so a non-granted multi-user user's
|
|
1057
1079
|
// downgraded 'auto' actually reaches the remote agent (the default command otherwise
|
|
1058
1080
|
// ignored claudeMode). A per-host `commands.claude` override stays authoritative
|
|
1059
|
-
// (admin's explicit choice). Wrapped in
|
|
1060
|
-
// `defaultRemoteCommandForMode`: `claude` lives under a per-user PATH
|
|
1061
|
-
// only an interactive login shell resolves (see that function's comment).
|
|
1081
|
+
// (admin's explicit choice). Wrapped in the login shell (`remoteLoginShellCommand`) for
|
|
1082
|
+
// the same reason as `defaultRemoteCommandForMode`: `claude` lives under a per-user PATH
|
|
1083
|
+
// entry that only an interactive login shell resolves (see that function's comment).
|
|
1062
1084
|
const override = remote.commands?.[mode];
|
|
1063
1085
|
let modeCommand;
|
|
1064
1086
|
if (override) {
|
|
@@ -1123,16 +1145,24 @@ export function buildRemoteLaunchCommand(options) {
|
|
|
1123
1145
|
modeCommand = defaultRemoteCommandForMode(mode);
|
|
1124
1146
|
}
|
|
1125
1147
|
const remoteName = remoteTmuxSessionName(sessionId);
|
|
1126
|
-
// Innermost: the command tmux runs in the new pane.
|
|
1127
|
-
//
|
|
1128
|
-
//
|
|
1148
|
+
// Innermost: the command tmux runs in the new pane. `exec` replaces the shell
|
|
1149
|
+
// with the CLI so the pane PID is the agent itself.
|
|
1150
|
+
//
|
|
1151
|
+
// tmux runs a ONE-argument command through the remote default-shell, which is
|
|
1152
|
+
// the user's login shell, and bypasses it for two or more arguments (both
|
|
1153
|
+
// measured on tmux 3.4). Codeman's own text is POSIX (`"${SHELL:-/bin/sh}"` is
|
|
1154
|
+
// a parse error in fish, status 127, a dead pane on every new session), so it
|
|
1155
|
+
// goes to `/bin/sh -c` as separate arguments and no login shell has to parse it.
|
|
1156
|
+
// An override is the user's own text for THEIR shell: it stays one argument,
|
|
1157
|
+
// because `source` and `[[` work in bash and zsh and die in a dash `/bin/sh`.
|
|
1129
1158
|
const paneCommand = `cd ${shellescape(remote.remotePath)} && ${modeCommand}`;
|
|
1159
|
+
const paneArgv = `${override ? '' : '/bin/sh -c '}${shellescape(paneCommand)}`;
|
|
1130
1160
|
// The tmux command line, with `\;` separating commands so the config `set`s
|
|
1131
1161
|
// apply on the SAME connection (and are idempotent on reattach). Options are
|
|
1132
1162
|
// scoped per-session (`set -t <name>` / server `set -s`), NEVER `-g`, so a
|
|
1133
1163
|
// shared remote tmux server's other sessions keep their own prefix/mouse.
|
|
1134
1164
|
const tmuxInvocation = [
|
|
1135
|
-
`tmux -L ${REMOTE_TMUX_SOCKET} new-session -A -s ${remoteName} -c ${shellescape(remote.remotePath)} ${
|
|
1165
|
+
`tmux -L ${REMOTE_TMUX_SOCKET} new-session -A -s ${remoteName} -c ${shellescape(remote.remotePath)} ${paneArgv}`,
|
|
1136
1166
|
`set -t ${remoteName} status off`,
|
|
1137
1167
|
`set -t ${remoteName} mouse off`,
|
|
1138
1168
|
`set -t ${remoteName} prefix ${NESTED_TMUX_PREFIX}`,
|
|
@@ -1281,7 +1311,7 @@ function claudeDockerPaneCommand(modeCommand, sessionId, resumeId) {
|
|
|
1281
1311
|
* Build the ONE `bash -c` launch string for a docker session: image-check ->
|
|
1282
1312
|
* ensure (inspect-or-create) -> start -> `exec docker exec -it` into the durable
|
|
1283
1313
|
* in-container tmux (resume-aware). PURE and unit-testable. The escaping survives
|
|
1284
|
-
* four layers: outer `bash -c
|
|
1314
|
+
* four layers: outer `bash -c '…'` (`bashCArg()` at respawn-pane) -> the joined
|
|
1285
1315
|
* command -> `docker exec … sh -lc '<tmux>'` -> tmux `'<paneCommand>'`.
|
|
1286
1316
|
*/
|
|
1287
1317
|
export function buildDockerLaunchCommand(opts) {
|
|
@@ -1308,8 +1338,8 @@ export function buildDockerLaunchCommand(opts) {
|
|
|
1308
1338
|
else if (resumeSessionId) {
|
|
1309
1339
|
modeCommand = appendResumeFlag(modeCommand, mode, resumeSessionId);
|
|
1310
1340
|
}
|
|
1311
|
-
// Run by tmux
|
|
1312
|
-
// pane PID the agent itself.
|
|
1341
|
+
// Run by tmux through the container's default-shell, so the path is shell-quoted
|
|
1342
|
+
// here. `exec` makes the pane PID the agent itself.
|
|
1313
1343
|
const paneCommand = `cd ${workdir} && ${modeCommand}`;
|
|
1314
1344
|
// `setenv -g` primes the session id so reattaches / newly-created panes inherit
|
|
1315
1345
|
// it. `new-session -A` = attach-or-create (idempotent + resume-aware). Options
|
|
@@ -1363,11 +1393,10 @@ export function buildDockerLaunchCommand(opts) {
|
|
|
1363
1393
|
const ensure = adopted
|
|
1364
1394
|
? `${base} inspect ${name} >/dev/null 2>&1 || { echo ${notFoundMsg}; exit 1; }`
|
|
1365
1395
|
: `${base} inspect ${name} >/dev/null 2>&1 || ${createCommand}`;
|
|
1366
|
-
//
|
|
1367
|
-
//
|
|
1368
|
-
//
|
|
1369
|
-
//
|
|
1370
|
-
// every other line in this builder already uses.
|
|
1396
|
+
// A `grep -qx` pipeline, in the single-quoted form every other line in this builder
|
|
1397
|
+
// uses. This chain reaches `bash -c` verbatim now (`bashCArg()`), but it went through
|
|
1398
|
+
// a double-quoted layer for a long time, where a `"` closed that string early and tmux
|
|
1399
|
+
// failed with a bare `execvp(3) failed`, so keep it free of double quotes and `$(…)`.
|
|
1371
1400
|
const start = adopted
|
|
1372
1401
|
? `${base} inspect -f ${shellescape('{{.State.Running}}')} ${name} 2>/dev/null | grep -qx true || { echo ${notRunningMsg}; exit 1; }`
|
|
1373
1402
|
: `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`;
|
|
@@ -1562,6 +1591,44 @@ function buildRemoteSessionCommand(options) {
|
|
|
1562
1591
|
}
|
|
1563
1592
|
return buildRemoteLaunchCommand(options);
|
|
1564
1593
|
}
|
|
1594
|
+
/**
|
|
1595
|
+
* ADOPTION: the pane command for a wrapper session around a tmux session a
|
|
1596
|
+
* human started. Sibling of `buildRemoteSessionCommand` and
|
|
1597
|
+
* `buildDockerLaunchCommand`, and the same shape of decision: the wrapper is an
|
|
1598
|
+
* ordinary `codeman-<8hex>` session on OUR socket, and only what runs INSIDE its
|
|
1599
|
+
* pane differs per location.
|
|
1600
|
+
*
|
|
1601
|
+
* That indirection is what makes adoption safe by construction rather than by
|
|
1602
|
+
* discipline: `killSession` only ever kills the wrapper on our own socket, so
|
|
1603
|
+
* there is no shape of caller bug that reaches the foreign server with a
|
|
1604
|
+
* `kill-session`. It is the same reasoning that keeps a non-owned REMOTE session
|
|
1605
|
+
* safe (see the detach-not-kill early return in `killSession`).
|
|
1606
|
+
*/
|
|
1607
|
+
export function buildAdoptSessionCommand(adopt, sessionId) {
|
|
1608
|
+
const target = {
|
|
1609
|
+
socketPath: adopt.socketPath,
|
|
1610
|
+
targetSession: adopt.targetSession,
|
|
1611
|
+
// Derived rather than required: the view name is a function of the Codeman
|
|
1612
|
+
// session id, so a record that predates it (or one whose creator filled it in
|
|
1613
|
+
// after construction) still produces the right name instead of an empty `-s`.
|
|
1614
|
+
viewSession: adopt.viewSession || foreignViewSessionName(sessionId),
|
|
1615
|
+
};
|
|
1616
|
+
if (adopt.location === 'docker' && adopt.docker) {
|
|
1617
|
+
return buildForeignDockerAttachCommand({
|
|
1618
|
+
...target,
|
|
1619
|
+
dockerBase: buildDockerBaseArgs(adopt.docker).join(' '),
|
|
1620
|
+
containerName: adopt.docker.containerName,
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
if (adopt.location === 'remote' && adopt.remote) {
|
|
1624
|
+
return buildForeignRemoteAttachCommand({
|
|
1625
|
+
...target,
|
|
1626
|
+
sshArgs: buildSshConnectionArgs(adopt.remote),
|
|
1627
|
+
sshTarget: remoteSshTarget(adopt.remote),
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1630
|
+
return buildForeignAttachCommand(target);
|
|
1631
|
+
}
|
|
1565
1632
|
/**
|
|
1566
1633
|
* Push one environment variable into a tmux session with `setenv`.
|
|
1567
1634
|
*
|
|
@@ -2113,7 +2180,7 @@ export class TmuxManager extends EventEmitter {
|
|
|
2113
2180
|
* In test mode: creates an in-memory session only (no real tmux session).
|
|
2114
2181
|
*/
|
|
2115
2182
|
async createSession(options) {
|
|
2116
|
-
const { sessionId, workingDir, mode, name, cliName, niceConfig, model, claudeMode, allowedTools, openCodeConfig, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, copilotConfig, deepSeekConfig, ompConfig, resumeSessionId, envOverrides, effort, advisorModel, keepWarm, spawnedByAgent, historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, remote, docker, owner, } = options;
|
|
2183
|
+
const { sessionId, workingDir, mode, name, cliName, niceConfig, model, claudeMode, allowedTools, openCodeConfig, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, copilotConfig, deepSeekConfig, ompConfig, resumeSessionId, envOverrides, effort, advisorModel, keepWarm, spawnedByAgent, historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, remote, docker, adopt, owner, } = options;
|
|
2117
2184
|
const muxName = `codeman-${sessionId.slice(0, 8)}`;
|
|
2118
2185
|
if (!isValidMuxName(muxName)) {
|
|
2119
2186
|
throw new Error('Invalid session name: contains unsafe characters');
|
|
@@ -2131,6 +2198,7 @@ export class TmuxManager extends EventEmitter {
|
|
|
2131
2198
|
workingDir,
|
|
2132
2199
|
remote,
|
|
2133
2200
|
docker,
|
|
2201
|
+
adopt,
|
|
2134
2202
|
owner,
|
|
2135
2203
|
mode,
|
|
2136
2204
|
attached: false,
|
|
@@ -2156,14 +2224,22 @@ export class TmuxManager extends EventEmitter {
|
|
|
2156
2224
|
// surfacing as a bare `execvp(3) failed: No such file or directory` with nothing
|
|
2157
2225
|
// pointing at the real cause. The container's own CLIs are verified by the adoption
|
|
2158
2226
|
// preflight / image gate before launch instead.
|
|
2159
|
-
|
|
2227
|
+
//
|
|
2228
|
+
// ⚠️ Skipped for an ADOPTED session for the mirror-image reason: its CLI was started by
|
|
2229
|
+
// a human in a process we did not spawn, and our pane only runs `tmux attach`. Requiring
|
|
2230
|
+
// the binary here would reject adopting a claude that lives in a container or on an ssh
|
|
2231
|
+
// host, and (worse) reject a LOCAL adoption whenever the agent is outside the server
|
|
2232
|
+
// process's PATH, which is the usual systemd/launchd situation.
|
|
2233
|
+
const cliRunsInContainer = !!docker || !!adopt;
|
|
2160
2234
|
if (!cliRunsInContainer && !cliDir) {
|
|
2161
2235
|
const message = missingCliMessage(mode);
|
|
2162
2236
|
if (message)
|
|
2163
2237
|
throw new Error(message);
|
|
2164
2238
|
}
|
|
2165
2239
|
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
|
2166
|
-
const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine(mode, workingDir,
|
|
2240
|
+
const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine(mode, workingDir,
|
|
2241
|
+
// An adopted wrapper launches no agent, so nothing can carry the exporter.
|
|
2242
|
+
!!(remote || docker || adopt));
|
|
2167
2243
|
const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent);
|
|
2168
2244
|
// The user's own REAL statusLine, if any (walked via Claude Code's own
|
|
2169
2245
|
// settings precedence) — exported below so the shared exporter script
|
|
@@ -2196,11 +2272,21 @@ export class TmuxManager extends EventEmitter {
|
|
|
2196
2272
|
try {
|
|
2197
2273
|
// Build the full command to run inside tmux
|
|
2198
2274
|
const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
|
|
2199
|
-
const fullCmd =
|
|
2200
|
-
?
|
|
2201
|
-
:
|
|
2202
|
-
?
|
|
2203
|
-
:
|
|
2275
|
+
const fullCmd = adopt
|
|
2276
|
+
? buildAdoptSessionCommand(adopt, sessionId)
|
|
2277
|
+
: docker
|
|
2278
|
+
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
|
|
2279
|
+
: remote
|
|
2280
|
+
? buildRemoteSessionCommand({
|
|
2281
|
+
mode,
|
|
2282
|
+
remote,
|
|
2283
|
+
sessionId,
|
|
2284
|
+
claudeMode,
|
|
2285
|
+
allowedTools,
|
|
2286
|
+
ompConfig,
|
|
2287
|
+
resumeSessionId,
|
|
2288
|
+
})
|
|
2289
|
+
: localFullCmd;
|
|
2204
2290
|
// Create tmux session in three steps to handle cold-start (no server running)
|
|
2205
2291
|
// and avoid the race where the command exits before remain-on-exit is set:
|
|
2206
2292
|
// 2. Set remain-on-exit (server now exists, session won't vanish on exit)
|
|
@@ -2250,8 +2336,10 @@ export class TmuxManager extends EventEmitter {
|
|
|
2250
2336
|
this.applyEnvOverrides(muxName, envOverrides);
|
|
2251
2337
|
// Replace the shell with the actual command (no echo in terminal). Keep
|
|
2252
2338
|
// pane launch in /tmp, then cd inside bash against the current mount table.
|
|
2253
|
-
|
|
2254
|
-
|
|
2339
|
+
// ⚠️ An adopted pane is never `cd`'d: it only attaches, and its `workingDir`
|
|
2340
|
+
// is the neutral wrapper folder, not where the foreign pane is.
|
|
2341
|
+
const launchCmd = remote || docker || adopt ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
|
2342
|
+
execSync(`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${bashCArg(launchCmd, !!(remote || docker || adopt))}`, {
|
|
2255
2343
|
timeout: EXEC_TIMEOUT_MS,
|
|
2256
2344
|
stdio: 'ignore',
|
|
2257
2345
|
});
|
|
@@ -2305,6 +2393,7 @@ export class TmuxManager extends EventEmitter {
|
|
|
2305
2393
|
workingDir,
|
|
2306
2394
|
remote,
|
|
2307
2395
|
docker,
|
|
2396
|
+
adopt,
|
|
2308
2397
|
owner,
|
|
2309
2398
|
mode,
|
|
2310
2399
|
attached: false,
|
|
@@ -2402,13 +2491,20 @@ export class TmuxManager extends EventEmitter {
|
|
|
2402
2491
|
const session = this.sessions.get(sessionId);
|
|
2403
2492
|
if (!session)
|
|
2404
2493
|
return null;
|
|
2494
|
+
// Adoption metadata comes from the tracked session, not the caller: a respawn
|
|
2495
|
+
// of an adopted wrapper RE-ATTACHES to the same foreign target. It must never
|
|
2496
|
+
// be able to become "launch a fresh agent here" because a caller forgot to
|
|
2497
|
+
// pass the field through.
|
|
2498
|
+
const adopt = options.adopt ?? session.adopt;
|
|
2405
2499
|
const muxName = session.muxName;
|
|
2406
2500
|
if (!isValidMuxName(muxName) || !isValidPath(workingDir))
|
|
2407
2501
|
return null;
|
|
2408
2502
|
// Resolve CLI binary directory based on mode
|
|
2409
2503
|
const { pathExport } = this.buildPathExport(mode);
|
|
2410
2504
|
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
|
2411
|
-
const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine(mode, workingDir,
|
|
2505
|
+
const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine(mode, workingDir,
|
|
2506
|
+
// An adopted wrapper launches no agent, so nothing can carry the exporter.
|
|
2507
|
+
!!(remote || docker || adopt));
|
|
2412
2508
|
const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent);
|
|
2413
2509
|
const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined;
|
|
2414
2510
|
const baseCmd = buildSpawnCommand({
|
|
@@ -2436,11 +2532,13 @@ export class TmuxManager extends EventEmitter {
|
|
|
2436
2532
|
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
|
2437
2533
|
const cmd = wrapWithNice(baseCmd, config);
|
|
2438
2534
|
const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
|
|
2439
|
-
const fullCmd =
|
|
2440
|
-
?
|
|
2441
|
-
:
|
|
2442
|
-
?
|
|
2443
|
-
:
|
|
2535
|
+
const fullCmd = adopt
|
|
2536
|
+
? buildAdoptSessionCommand(adopt, sessionId)
|
|
2537
|
+
: docker
|
|
2538
|
+
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
|
|
2539
|
+
: remote
|
|
2540
|
+
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools, ompConfig, resumeSessionId })
|
|
2541
|
+
: localFullCmd;
|
|
2444
2542
|
try {
|
|
2445
2543
|
// Same per-CLI env setup as createSession, re-applied so the respawned pane inherits it.
|
|
2446
2544
|
this._configureCliEnv(muxName, sessionId, mode, legacyConfigForMode(mode, options));
|
|
@@ -2449,8 +2547,10 @@ export class TmuxManager extends EventEmitter {
|
|
|
2449
2547
|
// dropping the ones the caller retired first (see applyEnvOverrides).
|
|
2450
2548
|
this.applyEnvOverrides(muxName, envOverrides, unsetEnvKeys);
|
|
2451
2549
|
// -c /tmp + cd bounce — see createSession() for rationale (stale FUSE state).
|
|
2452
|
-
|
|
2453
|
-
|
|
2550
|
+
// ⚠️ An adopted pane is never `cd`'d: it only attaches, and its `workingDir`
|
|
2551
|
+
// is the neutral wrapper folder, not where the foreign pane is.
|
|
2552
|
+
const launchCmd = remote || docker || adopt ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
|
2553
|
+
await execAsync(`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${bashCArg(launchCmd, !!(remote || docker || adopt))}`, {
|
|
2454
2554
|
timeout: EXEC_TIMEOUT_MS,
|
|
2455
2555
|
});
|
|
2456
2556
|
// Wait for the respawned process to start
|
|
@@ -2650,6 +2750,56 @@ export class TmuxManager extends EventEmitter {
|
|
|
2650
2750
|
// for a non-owned session. The only `kill-session` we run is on OUR LOCAL
|
|
2651
2751
|
// socket (`this.tmux()` = `tmux -L codeman` on THIS host), which kills the
|
|
2652
2752
|
// local pane — it does NOT reach the REMOTE socket.
|
|
2753
|
+
// ADOPTION: DETACH-NOT-KILL, by construction rather than by discipline.
|
|
2754
|
+
//
|
|
2755
|
+
// An adopted session's wrapper is an ordinary session on OUR socket whose
|
|
2756
|
+
// pane runs `tmux attach` (or `docker exec …`/`ssh …` then attach) against a
|
|
2757
|
+
// FOREIGN server. Killing the wrapper kills our attach client, which is a
|
|
2758
|
+
// DETACH on the foreign side; the human's session keeps running, and our
|
|
2759
|
+
// grouped view session self-destroys via `destroy-unattached on`.
|
|
2760
|
+
//
|
|
2761
|
+
// The guard below is a formality more than a fence: `this.tmux()` only ever
|
|
2762
|
+
// addresses our own socket, so no code path here CAN reach the foreign
|
|
2763
|
+
// server. It is written out anyway so the intent survives future edits, the
|
|
2764
|
+
// same way the non-owned-remote early return does.
|
|
2765
|
+
if (session.adopt) {
|
|
2766
|
+
console.log(`[TmuxManager] DETACH (adopted): tearing down wrapper only for ${session.muxName}`);
|
|
2767
|
+
// A `docker exec` survives its client, so the in-container view would stay
|
|
2768
|
+
// attached forever and leak. Best-effort, and it can only reach OUR view.
|
|
2769
|
+
// See buildForeignDockerViewKillCommand for why local/remote need nothing.
|
|
2770
|
+
// Fire and forget like the owned docker branch below: a daemon that stopped
|
|
2771
|
+
// answering must not hold the event loop for the whole timeout.
|
|
2772
|
+
if (session.adopt.location === 'docker' && session.adopt.docker) {
|
|
2773
|
+
try {
|
|
2774
|
+
exec(buildForeignDockerViewKillCommand({
|
|
2775
|
+
dockerBase: buildDockerBaseArgs(session.adopt.docker).join(' '),
|
|
2776
|
+
containerName: session.adopt.docker.containerName,
|
|
2777
|
+
socketPath: session.adopt.socketPath,
|
|
2778
|
+
viewSession: session.adopt.viewSession || foreignViewSessionName(sessionId),
|
|
2779
|
+
}), { timeout: EXEC_TIMEOUT_MS }, () => { });
|
|
2780
|
+
}
|
|
2781
|
+
catch {
|
|
2782
|
+
// Best-effort: the container may be gone, or the view already collected.
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
if (isValidMuxName(session.muxName)) {
|
|
2786
|
+
try {
|
|
2787
|
+
await execAsync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
|
|
2788
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
2789
|
+
});
|
|
2790
|
+
}
|
|
2791
|
+
catch {
|
|
2792
|
+
// Wrapper may already be gone.
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
this.lastPaneCount.delete(session.muxName);
|
|
2796
|
+
this.clearPaneExit(session.muxName);
|
|
2797
|
+
this.sessions.delete(sessionId);
|
|
2798
|
+
this.clearRemoteReconnectState(sessionId);
|
|
2799
|
+
this.saveSessions();
|
|
2800
|
+
this.emit('sessionKilled', { sessionId });
|
|
2801
|
+
return true;
|
|
2802
|
+
}
|
|
2653
2803
|
if (session.remote && session.remote.owned === false) {
|
|
2654
2804
|
console.log(`[TmuxManager] DETACH (non-owned remote): tearing down local pane only for ${session.muxName}`);
|
|
2655
2805
|
if (isValidMuxName(session.muxName)) {
|
|
@@ -3328,8 +3478,51 @@ export class TmuxManager extends EventEmitter {
|
|
|
3328
3478
|
catch (err) {
|
|
3329
3479
|
console.error('[TmuxManager] Remote reconnect watcher error:', err);
|
|
3330
3480
|
}
|
|
3481
|
+
void this.detachEndedAdoptedAttaches().catch((err) => {
|
|
3482
|
+
console.error('[TmuxManager] Adopted attach watcher error:', err);
|
|
3483
|
+
});
|
|
3331
3484
|
}, intervalMs);
|
|
3332
3485
|
}
|
|
3486
|
+
/**
|
|
3487
|
+
* Let go of a wrapper whose pane died (#510). That pane holds the attach to someone
|
|
3488
|
+
* else's tmux session, so it dies when that session ends, the transport drops or the
|
|
3489
|
+
* view gets detached, and `paneExit` rightly reports none of them. But `remain-on-exit`
|
|
3490
|
+
* keeps Codeman's own tmux client on the dead pane, so the tab kept its pid and `busy`,
|
|
3491
|
+
* input went nowhere and both attach routes refused it.
|
|
3492
|
+
*
|
|
3493
|
+
* Detaching that client is an ordinary PTY exit with status 0: the pid goes null, the tab
|
|
3494
|
+
* stays open, and selecting it starts the wrapper again, which joins the target if it is
|
|
3495
|
+
* back and otherwise prints that it is gone. Only Codeman's own socket is touched.
|
|
3496
|
+
*/
|
|
3497
|
+
async detachEndedAdoptedAttaches() {
|
|
3498
|
+
const adopted = new Set();
|
|
3499
|
+
for (const session of this.sessions.values()) {
|
|
3500
|
+
if (session.adopt)
|
|
3501
|
+
adopted.add(session.muxName);
|
|
3502
|
+
}
|
|
3503
|
+
if (adopted.size === 0)
|
|
3504
|
+
return;
|
|
3505
|
+
for (const muxName of endedAdoptedAttaches(await this.readAttachRows(), adopted)) {
|
|
3506
|
+
await this.detachClients(muxName);
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
/** Rows for {@link endedAdoptedAttaches}. Empty when tmux did not answer. */
|
|
3510
|
+
async readAttachRows() {
|
|
3511
|
+
if (IS_TEST_MODE)
|
|
3512
|
+
return '';
|
|
3513
|
+
const format = '#{pane_dead} #{session_attached} #{session_name}';
|
|
3514
|
+
const { stdout } = await execAsync(`${this.tmux()} list-panes -a -F '${format}' 2>/dev/null || true`, {
|
|
3515
|
+
encoding: 'utf-8',
|
|
3516
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
3517
|
+
});
|
|
3518
|
+
return stdout;
|
|
3519
|
+
}
|
|
3520
|
+
/** Detach every tmux client from one of Codeman's own sessions. */
|
|
3521
|
+
async detachClients(muxName) {
|
|
3522
|
+
if (IS_TEST_MODE || !isValidMuxName(muxName))
|
|
3523
|
+
return;
|
|
3524
|
+
await execAsync(`${this.tmux()} detach-client -s "${muxName}"`, { timeout: EXEC_TIMEOUT_MS });
|
|
3525
|
+
}
|
|
3333
3526
|
stopRemoteReconnectWatcher() {
|
|
3334
3527
|
if (this.remoteReconnectInterval) {
|
|
3335
3528
|
clearInterval(this.remoteReconnectInterval);
|
|
@@ -4099,6 +4292,28 @@ export class TmuxManager extends EventEmitter {
|
|
|
4099
4292
|
}
|
|
4100
4293
|
}
|
|
4101
4294
|
}
|
|
4295
|
+
/**
|
|
4296
|
+
* The `bash -c` argument of a pane launch (`respawn-pane … bash -c <arg>`, run through the
|
|
4297
|
+
* local sh). A remote, docker or adopted command is already quoted for the shell at the far
|
|
4298
|
+
* end and must reach bash verbatim, so it goes in single quotes. Double quotes
|
|
4299
|
+
* (JSON.stringify) let the local sh expand every `$` first: the PATH bootstrap of
|
|
4300
|
+
* `withRemoteTmuxPath` (`$HOME/.local/bin:…:$PATH`) arrived on the remote host as this
|
|
4301
|
+
* host's home and PATH, so a remote user with another home found no tmux and every new
|
|
4302
|
+
* remote pane exited 127. `${SHELL:-/bin/sh}` in the remote launch was the local shell for
|
|
4303
|
+
* the same reason, and an adopted ssh attach carries the same bootstrap, so a tmux in the
|
|
4304
|
+
* remote `~/.local/bin` was found by discovery and then missed by the attach. Single quotes
|
|
4305
|
+
* also keep a real newline, which JSON escaping left as the two characters `\n`.
|
|
4306
|
+
* An adopted attach goes verbatim at all three locations, the local one included: every
|
|
4307
|
+
* value in it is already quoted by `foreign-tmux.ts`, and nothing in it is for this shell.
|
|
4308
|
+
* An agent launched on this host keeps its double-quoted form.
|
|
4309
|
+
*
|
|
4310
|
+
* ⚠️ So nothing in a verbatim launch is expanded on this host any more, the docker client's
|
|
4311
|
+
* own arguments included: a bare `$VAR` in `extraCreateArgs`/`extraExecArgs` reaches docker
|
|
4312
|
+
* as those literal characters.
|
|
4313
|
+
*/
|
|
4314
|
+
export function bashCArg(launchCmd, verbatim) {
|
|
4315
|
+
return verbatim ? shellescape(launchCmd) : JSON.stringify(launchCmd);
|
|
4316
|
+
}
|
|
4102
4317
|
/**
|
|
4103
4318
|
* Shell-escape a string for use as a single argument.
|
|
4104
4319
|
* Wraps in single quotes, escaping any embedded single quotes.
|