@hwip/cli 0.0.1 → 0.1.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/LICENSE +190 -0
- package/NOTICE +11 -0
- package/README.md +1979 -0
- package/dist/agent-list.d.ts +76 -0
- package/dist/agent-list.js +102 -0
- package/dist/agent-read.d.ts +105 -0
- package/dist/agent-read.js +309 -0
- package/dist/agent-rules-fetch.d.ts +51 -0
- package/dist/agent-rules-fetch.js +275 -0
- package/dist/agent-rules.default.d.ts +30 -0
- package/dist/agent-rules.default.js +292 -0
- package/dist/agent-session.d.ts +23 -0
- package/dist/agent-session.js +44 -0
- package/dist/agent-state.d.ts +84 -0
- package/dist/agent-state.js +188 -0
- package/dist/agent-target.d.ts +60 -0
- package/dist/agent-target.js +81 -0
- package/dist/agents.d.ts +31 -0
- package/dist/agents.js +75 -0
- package/dist/api.d.ts +28 -0
- package/dist/api.js +76 -0
- package/dist/args.d.ts +74 -0
- package/dist/args.js +254 -0
- package/dist/ask-context-attach.d.ts +73 -0
- package/dist/ask-context-attach.js +151 -0
- package/dist/ask-context-screen.d.ts +21 -0
- package/dist/ask-context-screen.js +49 -0
- package/dist/ask-context-switch.d.ts +31 -0
- package/dist/ask-context-switch.js +74 -0
- package/dist/ask-context.d.ts +84 -0
- package/dist/ask-context.js +172 -0
- package/dist/ask-pending.d.ts +47 -0
- package/dist/ask-pending.js +221 -0
- package/dist/ask-seal.d.ts +120 -0
- package/dist/ask-seal.js +179 -0
- package/dist/ask.d.ts +107 -0
- package/dist/ask.js +551 -0
- package/dist/blocked-prompt-seal.d.ts +35 -0
- package/dist/blocked-prompt-seal.js +89 -0
- package/dist/blocked-prompt.d.ts +57 -0
- package/dist/blocked-prompt.js +181 -0
- package/dist/channel.d.ts +67 -0
- package/dist/channel.js +136 -0
- package/dist/check-update.d.ts +28 -0
- package/dist/check-update.js +153 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +813 -0
- package/dist/codex-config.d.ts +24 -0
- package/dist/codex-config.js +169 -0
- package/dist/codex-transcript.d.ts +59 -0
- package/dist/codex-transcript.js +373 -0
- package/dist/command-refusal.d.ts +56 -0
- package/dist/command-refusal.js +127 -0
- package/dist/command-scan.d.ts +46 -0
- package/dist/command-scan.js +154 -0
- package/dist/command-seal.d.ts +57 -0
- package/dist/command-seal.js +88 -0
- package/dist/command-seq.d.ts +30 -0
- package/dist/command-seq.js +150 -0
- package/dist/config.d.ts +156 -0
- package/dist/config.js +392 -0
- package/dist/core-sections.d.ts +18 -0
- package/dist/core-sections.js +28 -0
- package/dist/crypto.d.ts +278 -0
- package/dist/crypto.js +698 -0
- package/dist/desktop-notify.d.ts +33 -0
- package/dist/desktop-notify.js +49 -0
- package/dist/device-directory.d.ts +36 -0
- package/dist/device-directory.js +97 -0
- package/dist/device-presence.d.ts +17 -0
- package/dist/device-presence.js +62 -0
- package/dist/downloads.d.ts +29 -0
- package/dist/downloads.js +106 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +28 -0
- package/dist/file-crypto.d.ts +94 -0
- package/dist/file-crypto.js +272 -0
- package/dist/file-view-budget.d.ts +15 -0
- package/dist/file-view-budget.js +34 -0
- package/dist/file-view-switch.d.ts +28 -0
- package/dist/file-view-switch.js +122 -0
- package/dist/file-view.d.ts +108 -0
- package/dist/file-view.js +327 -0
- package/dist/gate.d.ts +141 -0
- package/dist/gate.js +336 -0
- package/dist/gemini-transcript.d.ts +32 -0
- package/dist/gemini-transcript.js +189 -0
- package/dist/http-failure.d.ts +98 -0
- package/dist/http-failure.js +349 -0
- package/dist/hwip-core.generated.d.ts +7 -0
- package/dist/hwip-core.generated.js +14 -0
- package/dist/hwip-hook.d.ts +77 -0
- package/dist/hwip-hook.js +340 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/input-sequencer.d.ts +65 -0
- package/dist/input-sequencer.js +142 -0
- package/dist/insecure-url.d.ts +16 -0
- package/dist/insecure-url.js +76 -0
- package/dist/installer.d.ts +96 -0
- package/dist/installer.js +850 -0
- package/dist/inventory-offload.d.ts +49 -0
- package/dist/inventory-offload.js +209 -0
- package/dist/inventory-worker.d.ts +2 -0
- package/dist/inventory-worker.js +27 -0
- package/dist/jsonc-doc.d.ts +106 -0
- package/dist/jsonc-doc.js +354 -0
- package/dist/keep-awake.d.ts +32 -0
- package/dist/keep-awake.js +46 -0
- package/dist/key-changes.d.ts +48 -0
- package/dist/key-changes.js +140 -0
- package/dist/lan-auth.d.ts +140 -0
- package/dist/lan-auth.js +217 -0
- package/dist/lan-endpoint.d.ts +41 -0
- package/dist/lan-endpoint.js +115 -0
- package/dist/lan-receiver.d.ts +122 -0
- package/dist/lan-receiver.js +510 -0
- package/dist/lan-sender.d.ts +76 -0
- package/dist/lan-sender.js +184 -0
- package/dist/lan-transfer.d.ts +36 -0
- package/dist/lan-transfer.js +54 -0
- package/dist/listener-device-id.d.ts +20 -0
- package/dist/listener-device-id.js +133 -0
- package/dist/listener-log.d.ts +4 -0
- package/dist/listener-log.js +23 -0
- package/dist/listener-process.d.ts +200 -0
- package/dist/listener-process.js +470 -0
- package/dist/listener-service.d.ts +225 -0
- package/dist/listener-service.js +682 -0
- package/dist/listener-worktree-cleanup.d.ts +27 -0
- package/dist/listener-worktree-cleanup.js +62 -0
- package/dist/listener.d.ts +1325 -0
- package/dist/listener.js +5680 -0
- package/dist/login-address.d.ts +25 -0
- package/dist/login-address.js +97 -0
- package/dist/login-page.d.ts +29 -0
- package/dist/login-page.js +93 -0
- package/dist/login.d.ts +98 -0
- package/dist/login.js +388 -0
- package/dist/mcp-command.d.ts +45 -0
- package/dist/mcp-command.js +50 -0
- package/dist/mcp.d.ts +19 -0
- package/dist/mcp.js +29 -0
- package/dist/node-support.d.ts +19 -0
- package/dist/node-support.js +38 -0
- package/dist/notify-failure.d.ts +57 -0
- package/dist/notify-failure.js +150 -0
- package/dist/opencode-transcript.d.ts +139 -0
- package/dist/opencode-transcript.js +390 -0
- package/dist/optional-features.d.ts +6 -0
- package/dist/optional-features.js +19 -0
- package/dist/pane-fit.d.ts +40 -0
- package/dist/pane-fit.js +119 -0
- package/dist/peer-key.d.ts +33 -0
- package/dist/peer-key.js +34 -0
- package/dist/pi-transcript.d.ts +26 -0
- package/dist/pi-transcript.js +255 -0
- package/dist/presence.d.ts +14 -0
- package/dist/presence.js +124 -0
- package/dist/private-files.d.ts +51 -0
- package/dist/private-files.js +218 -0
- package/dist/project-switch.d.ts +19 -0
- package/dist/project-switch.js +60 -0
- package/dist/redact.d.ts +10 -0
- package/dist/redact.js +21 -0
- package/dist/remote-agents.d.ts +410 -0
- package/dist/remote-agents.js +1218 -0
- package/dist/remote-hook.d.ts +12 -0
- package/dist/remote-hook.js +196 -0
- package/dist/replace-file.d.ts +50 -0
- package/dist/replace-file.js +328 -0
- package/dist/schedule-command.d.ts +35 -0
- package/dist/schedule-command.js +260 -0
- package/dist/schedule-launch.d.ts +15 -0
- package/dist/schedule-launch.js +24 -0
- package/dist/schedule-ready.d.ts +44 -0
- package/dist/schedule-ready.js +71 -0
- package/dist/schedule-remote.d.ts +77 -0
- package/dist/schedule-remote.js +167 -0
- package/dist/schedule-runner.d.ts +89 -0
- package/dist/schedule-runner.js +452 -0
- package/dist/schedule.d.ts +149 -0
- package/dist/schedule.js +428 -0
- package/dist/secret-mask.d.ts +29 -0
- package/dist/secret-mask.js +245 -0
- package/dist/self-register.d.ts +45 -0
- package/dist/self-register.js +67 -0
- package/dist/send.d.ts +56 -0
- package/dist/send.js +104 -0
- package/dist/session-diff.d.ts +161 -0
- package/dist/session-diff.js +445 -0
- package/dist/session-registry.d.ts +143 -0
- package/dist/session-registry.js +216 -0
- package/dist/sessions-report.d.ts +59 -0
- package/dist/sessions-report.js +99 -0
- package/dist/shell-audit.d.ts +34 -0
- package/dist/shell-audit.js +47 -0
- package/dist/subagent-transcripts.d.ts +167 -0
- package/dist/subagent-transcripts.js +437 -0
- package/dist/subscriber-trust.d.ts +128 -0
- package/dist/subscriber-trust.js +397 -0
- package/dist/templates.d.ts +105 -0
- package/dist/templates.js +871 -0
- package/dist/tmux-install.d.ts +44 -0
- package/dist/tmux-install.js +65 -0
- package/dist/tmux-liveness.d.ts +45 -0
- package/dist/tmux-liveness.js +71 -0
- package/dist/tmux-pane.d.ts +55 -0
- package/dist/tmux-pane.js +126 -0
- package/dist/tmux-socket.d.ts +20 -0
- package/dist/tmux-socket.js +292 -0
- package/dist/todo-fold.d.ts +31 -0
- package/dist/todo-fold.js +102 -0
- package/dist/transcript-tail.d.ts +312 -0
- package/dist/transcript-tail.js +718 -0
- package/dist/trust-command.d.ts +49 -0
- package/dist/trust-command.js +200 -0
- package/dist/trust-lock.d.ts +26 -0
- package/dist/trust-lock.js +94 -0
- package/dist/trust-pins.d.ts +37 -0
- package/dist/trust-pins.js +82 -0
- package/dist/turn-recorder-wire.d.ts +70 -0
- package/dist/turn-recorder-wire.js +165 -0
- package/dist/turn-recorder.d.ts +190 -0
- package/dist/turn-recorder.js +695 -0
- package/dist/turn-ring.d.ts +93 -0
- package/dist/turn-ring.js +418 -0
- package/dist/turn-watch.d.ts +177 -0
- package/dist/turn-watch.js +630 -0
- package/dist/types.d.ts +63 -0
- package/dist/types.js +2 -0
- package/dist/uninstall.d.ts +55 -0
- package/dist/uninstall.js +507 -0
- package/dist/usage.d.ts +17 -0
- package/dist/usage.js +421 -0
- package/dist/verify.d.ts +47 -0
- package/dist/verify.js +396 -0
- package/dist/worktree.d.ts +144 -0
- package/dist/worktree.js +310 -0
- package/dist/wrapper.d.ts +254 -0
- package/dist/wrapper.js +704 -0
- package/dist/ws-url.d.ts +109 -0
- package/dist/ws-url.js +202 -0
- package/package.json +78 -10
- package/bin/hwip.js +0 -3
package/dist/wrapper.js
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.handleShellSession = exports.shellTargets = exports.readShellSessionState = exports.planShellSession = exports.handleAgentSession = exports.tmuxMissingMessage = exports.inPaneNote = exports.handOffExec = exports.ensureListenerRunning = exports.takeListenerLaunchMarker = exports.listenerVersionDrifted = exports.targetForAgent = exports.splitAgentOptions = exports.sanitizeLabel = exports.findAvailableSession = exports.firstFreeSession = exports.tmuxSessionName = exports.herdrSessionArgs = exports.sessionSidecarArgs = exports.detectProjectName = exports.detectProjectDir = exports.safeBasename = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* `hwip cc` / `hwip codex` / `hwip gemini` — spawn an agent inside a named
|
|
6
|
+
* tmux session so the resident listener (`hwip listener`) can address it
|
|
7
|
+
* by session name to inject messages later.
|
|
8
|
+
*
|
|
9
|
+
* The tmux session name follows `hwip-<project>` where <project> resolves
|
|
10
|
+
* from CLAUDE/CURSOR/WINDSURF_PROJECT_DIR → git repo root → cwd basename.
|
|
11
|
+
* When the wrapper is invoked from inside an existing tmux session
|
|
12
|
+
* ($TMUX set) it skips the outer tmux to avoid nesting and execs the
|
|
13
|
+
* agent directly — letting power users keep their own multiplexer setup.
|
|
14
|
+
*/
|
|
15
|
+
const child_process_1 = require("child_process");
|
|
16
|
+
const path_1 = require("path");
|
|
17
|
+
const agents_js_1 = require("./agents.js");
|
|
18
|
+
const tmux_install_js_1 = require("./tmux-install.js");
|
|
19
|
+
const check_update_js_1 = require("./check-update.js");
|
|
20
|
+
const config_js_1 = require("./config.js");
|
|
21
|
+
const listener_process_js_1 = require("./listener-process.js");
|
|
22
|
+
const listener_service_js_1 = require("./listener-service.js");
|
|
23
|
+
const session_registry_js_1 = require("./session-registry.js");
|
|
24
|
+
const worktree_js_1 = require("./worktree.js");
|
|
25
|
+
const FALLBACK_NAME = 'project';
|
|
26
|
+
/** basename(), with a stable fallback for edge paths like `/`. */
|
|
27
|
+
const safeBasename = (path) => (0, path_1.basename)(path) || FALLBACK_NAME;
|
|
28
|
+
exports.safeBasename = safeBasename;
|
|
29
|
+
/** The directory the session is named for: env > git root > cwd. Null when git is the answer and there is no repo. */
|
|
30
|
+
const detectProjectDir = () => {
|
|
31
|
+
for (const key of config_js_1.PROJECT_DIR_ENV_VARS) {
|
|
32
|
+
const v = (0, config_js_1.resolvedEnv)(key);
|
|
33
|
+
if (v)
|
|
34
|
+
return v.replace(/\/+$/, '') || '/';
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const root = (0, child_process_1.execFileSync)('git', ['rev-parse', '--show-toplevel'], {
|
|
38
|
+
encoding: 'utf-8',
|
|
39
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
40
|
+
}).trim();
|
|
41
|
+
if (root)
|
|
42
|
+
return root;
|
|
43
|
+
}
|
|
44
|
+
catch { /* not a git repo — fall through */ }
|
|
45
|
+
return process.cwd();
|
|
46
|
+
};
|
|
47
|
+
exports.detectProjectDir = detectProjectDir;
|
|
48
|
+
/** Resolve a project name for the tmux session: env > git root > cwd basename. */
|
|
49
|
+
const detectProjectName = () => (0, exports.safeBasename)((0, exports.detectProjectDir)());
|
|
50
|
+
exports.detectProjectName = detectProjectName;
|
|
51
|
+
/**
|
|
52
|
+
* tmux commands, chained after `new`, that tell the listener which project
|
|
53
|
+
* the session belongs to when its name does not say so: a `--label` tail and a
|
|
54
|
+
* worktree's directory name both read as part of the project otherwise, and
|
|
55
|
+
* each opened a card of its own. Nothing when the name already says it all, so
|
|
56
|
+
* a plain family session is reported exactly as before. The rule is config
|
|
57
|
+
* `groupOf`, which the listener also applies to sessions started without these.
|
|
58
|
+
*
|
|
59
|
+
* Each option names its session (`-t =<session>:`). An untargeted `set-option`
|
|
60
|
+
* applies to the client's current session, and a launch from inside tmux (a
|
|
61
|
+
* `--detach` from an agent's pane) would relabel the caller instead of the new
|
|
62
|
+
* session (measured on tmux 3.5a).
|
|
63
|
+
*/
|
|
64
|
+
const sessionSidecarArgs = (session, projectDir, label) => {
|
|
65
|
+
const checkout = (0, config_js_1.checkoutOf)(projectDir);
|
|
66
|
+
const group = label
|
|
67
|
+
? { project: checkout?.key ?? (0, exports.safeBasename)(projectDir), label }
|
|
68
|
+
: (0, config_js_1.groupOf)(session.slice('hwip-'.length), checkout);
|
|
69
|
+
if (!group)
|
|
70
|
+
return [];
|
|
71
|
+
return [
|
|
72
|
+
';', 'set-option', '-t', sessionOptionTarget(session), config_js_1.SESSION_PROJECT_OPTION, group.project,
|
|
73
|
+
';', 'set-option', '-t', sessionOptionTarget(session), config_js_1.SESSION_LABEL_OPTION, group.label,
|
|
74
|
+
];
|
|
75
|
+
};
|
|
76
|
+
exports.sessionSidecarArgs = sessionSidecarArgs;
|
|
77
|
+
/**
|
|
78
|
+
* tmux commands, chained after `new -A`, that point config
|
|
79
|
+
* `HERDR_SESSION_OPTIONS` at the herdr pane this attach runs in, or clear them
|
|
80
|
+
* outside herdr.
|
|
81
|
+
*/
|
|
82
|
+
const herdrSessionArgs = (session, env = process.env) => {
|
|
83
|
+
const inHerdr = env.HERDR_ENV === '1' && !!env.HERDR_PANE_ID && !!env.HERDR_SOCKET_PATH;
|
|
84
|
+
return Object.entries(config_js_1.HERDR_SESSION_OPTIONS).flatMap(([name, option]) => {
|
|
85
|
+
const value = inHerdr ? env[name] : undefined;
|
|
86
|
+
return [';', 'set-option', ...(value ? [] : ['-u']), '-t', sessionOptionTarget(session), option, ...(value ? [value] : [])];
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
exports.herdrSessionArgs = herdrSessionArgs;
|
|
90
|
+
/**
|
|
91
|
+
* The option target for one session by exact name. `=` stops a prefix match;
|
|
92
|
+
* the trailing `:` makes `show-options`/`set-option`, which read `-t` as a
|
|
93
|
+
* pane, take it as a session (tmux 3.5a: `-t =name` alone answers nothing).
|
|
94
|
+
*/
|
|
95
|
+
const sessionOptionTarget = (session) => `=${session}:`;
|
|
96
|
+
/**
|
|
97
|
+
* `hwip-<project>` — the canonical tmux session base name. tmux itself turns
|
|
98
|
+
* `.` and `:` into `_` when it creates a session (3.5a: `hwip-foo.bar` becomes
|
|
99
|
+
* `hwip-foo_bar`), so the name is written that way up front: every later
|
|
100
|
+
* `-t =<name>` must match what tmux actually holds. `;` goes too — an argv
|
|
101
|
+
* element ending in one is a tmux command separator.
|
|
102
|
+
*/
|
|
103
|
+
const tmuxSessionName = (project) => `hwip-${(0, config_js_1.tmuxName)(project)}`;
|
|
104
|
+
exports.tmuxSessionName = tmuxSessionName;
|
|
105
|
+
/**
|
|
106
|
+
* Every live tmux session as `name → attached`, in one spawn.
|
|
107
|
+
*
|
|
108
|
+
* `tmux list-sessions` exits non-zero when no server is running; that is the
|
|
109
|
+
* ordinary "first `hwip cc` after a reboot" case, not an error, and it means
|
|
110
|
+
* an empty family.
|
|
111
|
+
*
|
|
112
|
+
* The format puts the 0/1 flag first so the name — which carries whatever the
|
|
113
|
+
* project directory was called, spaces included — is everything after the
|
|
114
|
+
* first space and needs no quoting.
|
|
115
|
+
*/
|
|
116
|
+
const liveSessions = () => {
|
|
117
|
+
const r = (0, child_process_1.spawnSync)('tmux', ['list-sessions', '-F', '#{session_attached} #{session_name}'], {
|
|
118
|
+
encoding: 'utf-8',
|
|
119
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
120
|
+
});
|
|
121
|
+
const out = new Map();
|
|
122
|
+
if (r.status !== 0)
|
|
123
|
+
return out;
|
|
124
|
+
for (const line of (r.stdout ?? '').split('\n')) {
|
|
125
|
+
const sep = line.indexOf(' ');
|
|
126
|
+
if (sep < 0)
|
|
127
|
+
continue;
|
|
128
|
+
out.set(line.slice(sep + 1), line.slice(0, sep) === '1');
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
};
|
|
132
|
+
const MAX_SUFFIX_ATTEMPTS = 20;
|
|
133
|
+
/** The first name in the family no live session holds — for a launch that can only create. */
|
|
134
|
+
const firstFreeSession = (base) => {
|
|
135
|
+
const live = liveSessions();
|
|
136
|
+
return familyNames(base).find((name) => !live.has(name)) ?? `${base}-${MAX_SUFFIX_ATTEMPTS + 1}`;
|
|
137
|
+
};
|
|
138
|
+
exports.firstFreeSession = firstFreeSession;
|
|
139
|
+
/** `<base>`, `<base>-2`, `<base>-3`, … — the session family for one project. */
|
|
140
|
+
const familyNames = (base) => Array.from({ length: MAX_SUFFIX_ATTEMPTS }, (_, i) => (i === 0 ? base : `${base}-${i + 1}`));
|
|
141
|
+
/**
|
|
142
|
+
* Pick a tmux session name that won't steal focus from another live
|
|
143
|
+
* `hwip cc`. Strategy:
|
|
144
|
+
* - Reuse the **highest-numbered detached** session in the family.
|
|
145
|
+
* - Otherwise take the lowest name that doesn't exist yet (create new),
|
|
146
|
+
* so an all-attached family gets an independent `<base>-N`.
|
|
147
|
+
*
|
|
148
|
+
* Highest-detached rather than lowest is what keeps the family reachable.
|
|
149
|
+
* The old scan walked the family in order and returned the first session that
|
|
150
|
+
* was missing *or* detached, which meant a detached `<base>` ended the scan
|
|
151
|
+
* every time: `<base>-2` could not be reached by any later `hwip cc`, not even
|
|
152
|
+
* after `<base>` was used and exited (a missing `<base>` is taken fresh). A
|
|
153
|
+
* session nobody can reach still holds a live agent — a Claude Code CLI plus
|
|
154
|
+
* its MCP fleet, 250-550MB measured — so stranding one leaks memory the user
|
|
155
|
+
* can neither see nor reclaim. Draining from the tail makes every suffix
|
|
156
|
+
* reachable again: the tail goes first, and `<base>` is picked on the run
|
|
157
|
+
* after that. Reuse also wins over a gap in the numbering, for the same
|
|
158
|
+
* reason — a free `<base>-2` must not hide a detached `<base>-3`.
|
|
159
|
+
*
|
|
160
|
+
* Reclaiming is deliberately all this does. Killing a detached session would
|
|
161
|
+
* take a live agent with it, and nothing here can tell "the user is done with
|
|
162
|
+
* this" from "the user closed the terminal and will come back".
|
|
163
|
+
*/
|
|
164
|
+
const findAvailableSession = (base,
|
|
165
|
+
/** The agent about to be started. A detached session that is running a
|
|
166
|
+
* different one is not a slot for it — see `sameAgent` below. Omitted by
|
|
167
|
+
* callers that have no kind to match, which keeps the old behaviour. */
|
|
168
|
+
agentKind,
|
|
169
|
+
/** Test seam: what this machine recorded about a session name. */
|
|
170
|
+
recall = session_registry_js_1.recallSession) => {
|
|
171
|
+
const live = liveSessions();
|
|
172
|
+
const family = familyNames(base);
|
|
173
|
+
/**
|
|
174
|
+
* Whether a detached session can host this agent.
|
|
175
|
+
*
|
|
176
|
+
* `tmux new -A` attaches to an existing session and DROPS the command it
|
|
177
|
+
* was given, so reusing a detached session that is running claude for a
|
|
178
|
+
* `hwip pi` puts the user in front of claude — silently, with pi never
|
|
179
|
+
* started. The registry is the only thing on this machine that knows which
|
|
180
|
+
* agent a name is running, so it decides.
|
|
181
|
+
*
|
|
182
|
+
* A name the registry has no record of is reusable: that is a session this
|
|
183
|
+
* machine never swept (no listener, or one that started after it), and
|
|
184
|
+
* refusing those would break the ordinary reattach for anyone not running
|
|
185
|
+
* the daemon.
|
|
186
|
+
*
|
|
187
|
+
* A STALE record is the other way to be wrong: the daemon recorded claude
|
|
188
|
+
* in this slot, the user has since started pi there by hand, and the sweep
|
|
189
|
+
* has not run. Then a `hwip pi` skips the user's own detached session and
|
|
190
|
+
* opens `-2`. That costs a reattach, where trusting the record the other way
|
|
191
|
+
* costs an agent that never starts — so the record wins.
|
|
192
|
+
*/
|
|
193
|
+
const sameAgent = (name) => {
|
|
194
|
+
if (!agentKind)
|
|
195
|
+
return true;
|
|
196
|
+
const known = recall(name);
|
|
197
|
+
return !known || known.agentKind === agentKind;
|
|
198
|
+
};
|
|
199
|
+
for (let i = family.length - 1; i >= 0; i--) {
|
|
200
|
+
if (live.get(family[i]) === false && sameAgent(family[i]))
|
|
201
|
+
return family[i];
|
|
202
|
+
}
|
|
203
|
+
const free = family.find((name) => !live.has(name));
|
|
204
|
+
if (free)
|
|
205
|
+
return free;
|
|
206
|
+
// Every name in the family is taken. `base` is the historical answer, and
|
|
207
|
+
// `tmux new -A` on a taken name attaches instead of starting — fine when
|
|
208
|
+
// whatever is there runs this agent, and the silent wrong-agent attach this
|
|
209
|
+
// function exists to prevent when it does not. One name past the family is
|
|
210
|
+
// the only answer left that actually starts the agent that was asked for.
|
|
211
|
+
return sameAgent(base) ? base : `${base}-${MAX_SUFFIX_ATTEMPTS + 1}`;
|
|
212
|
+
};
|
|
213
|
+
exports.findAvailableSession = findAvailableSession;
|
|
214
|
+
/**
|
|
215
|
+
* tmux forbids `.` and `:` in session names; whitespace would split the target;
|
|
216
|
+
* and an argv element ending in `;` is a tmux command separator.
|
|
217
|
+
*/
|
|
218
|
+
const sanitizeLabel = (label) => label.trim().replace(/[.:;\s]+/g, '-');
|
|
219
|
+
exports.sanitizeLabel = sanitizeLabel;
|
|
220
|
+
/** The wrapper's flags that take a value, and the option each one sets. */
|
|
221
|
+
const VALUE_FLAGS = { '--label': 'label', '--wt': 'worktree' };
|
|
222
|
+
/**
|
|
223
|
+
* A flag value the user typed, or the reason it cannot be one. Empty (`--label ''`)
|
|
224
|
+
* would name the session `hwip-<project>-`, and a flag-shaped value
|
|
225
|
+
* (`--label --detach`) is almost always a missing argument. A label is made
|
|
226
|
+
* tmux-safe here; a branch name is passed on as typed, for git to judge.
|
|
227
|
+
*/
|
|
228
|
+
const takeValue = (flag, raw) => {
|
|
229
|
+
if (raw === undefined || raw.startsWith('-')) {
|
|
230
|
+
return { error: `${flag} needs a value (e.g. ${flag} ${flag === '--label' ? 'impl' : 'feat/x'})` };
|
|
231
|
+
}
|
|
232
|
+
const value = flag === '--label' ? (0, exports.sanitizeLabel)(raw) : raw.trim();
|
|
233
|
+
return value ? { value } : { error: `${flag} must not be empty` };
|
|
234
|
+
};
|
|
235
|
+
const splitAgentOptions = (extra) => {
|
|
236
|
+
const opts = {};
|
|
237
|
+
let i = 0;
|
|
238
|
+
while (i < extra.length) {
|
|
239
|
+
const arg = extra[i];
|
|
240
|
+
if (arg === '--detach') {
|
|
241
|
+
opts.detach = true;
|
|
242
|
+
i += 1;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const flags = Object.keys(VALUE_FLAGS); // keys of a const object
|
|
246
|
+
const spaced = flags.find((f) => arg === f);
|
|
247
|
+
const joined = flags.find((f) => arg.startsWith(`${f}=`));
|
|
248
|
+
const flag = spaced ?? joined;
|
|
249
|
+
if (!flag)
|
|
250
|
+
break;
|
|
251
|
+
const taken = takeValue(flag, spaced ? extra[i + 1] : arg.slice(flag.length + 1));
|
|
252
|
+
i += spaced ? 2 : 1;
|
|
253
|
+
if (taken.error)
|
|
254
|
+
return { opts, rest: extra.slice(i), error: taken.error };
|
|
255
|
+
if (taken.value)
|
|
256
|
+
opts[VALUE_FLAGS[flag]] = taken.value;
|
|
257
|
+
}
|
|
258
|
+
return { opts, rest: extra.slice(i) };
|
|
259
|
+
};
|
|
260
|
+
exports.splitAgentOptions = splitAgentOptions;
|
|
261
|
+
/** POSIX shell-quote so passthrough args survive being joined into a tmux shell-command string. */
|
|
262
|
+
const SHELL_SAFE = /^[\w\-./=:@%+,]+$/;
|
|
263
|
+
const shellQuote = (s) => s.length > 0 && SHELL_SAFE.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
|
|
264
|
+
const targetForAgent = (agent, extra, agentKind, opts = {}) => {
|
|
265
|
+
// Already inside tmux → no nested session, just run the agent in the
|
|
266
|
+
// current pane. Nested tmux prefix collisions are confusing and the
|
|
267
|
+
// listener can't reach a session it didn't name anyway. A detached
|
|
268
|
+
// launch is the exception: it never attaches, so nesting cannot happen,
|
|
269
|
+
// and a Claude Code session driving `hwip pi --detach` from its own pane
|
|
270
|
+
// is exactly where it runs from.
|
|
271
|
+
// A label asks for a tmux session by name; running in the current pane
|
|
272
|
+
// would drop that silently, so a label is the other exception.
|
|
273
|
+
if (process.env.TMUX && !opts.detach && !opts.label) {
|
|
274
|
+
return { kind: 'direct', cmd: agent, args: extra };
|
|
275
|
+
}
|
|
276
|
+
const projectDir = opts.projectDir ?? (0, exports.detectProjectDir)();
|
|
277
|
+
const base = (0, exports.tmuxSessionName)((0, exports.safeBasename)(projectDir));
|
|
278
|
+
// A label pins the name — the caller wants THIS session, not a slot in
|
|
279
|
+
// the family (a plan-named implementer next to the user's own `hwip pi`).
|
|
280
|
+
// A detached launch without one takes the first name the family does not
|
|
281
|
+
// have: `findAvailableSession` prefers a detached session to REUSE, which
|
|
282
|
+
// `new -d` cannot do (it only creates) — it would just fail on the name.
|
|
283
|
+
// Otherwise reattach a detached session of this project when there is
|
|
284
|
+
// one, else auto-suffix — lets the user keep `hwip cc` workflow simple
|
|
285
|
+
// and still get independent sessions when opening multiple terminals in
|
|
286
|
+
// the same project.
|
|
287
|
+
const session = opts.label
|
|
288
|
+
? `${base}-${opts.label}`
|
|
289
|
+
: opts.detach
|
|
290
|
+
? (0, exports.firstFreeSession)(base)
|
|
291
|
+
: (0, exports.findAvailableSession)(base, agentKind);
|
|
292
|
+
// tmux joins trailing argv into a single shell-command, so flags like
|
|
293
|
+
// `--resume` would be eaten by tmux's own parser. Build one quoted
|
|
294
|
+
// shell string instead, which tmux passes through verbatim.
|
|
295
|
+
const shellCmd = [agent, ...extra].map(shellQuote).join(' ');
|
|
296
|
+
// `-d`: create without attaching, so no TTY is needed and `new` fails
|
|
297
|
+
// loudly on a taken name instead of `-A` silently attaching to whatever
|
|
298
|
+
// runs there. `-c`: the agent's cwd must be the directory the session is
|
|
299
|
+
// NAMED for — a launcher sitting in a subdirectory or another worktree
|
|
300
|
+
// would otherwise get a session named for one tree running in another.
|
|
301
|
+
const sidecar = (0, exports.sessionSidecarArgs)(session, projectDir, opts.label);
|
|
302
|
+
if (opts.detach) {
|
|
303
|
+
return { kind: 'tmux-detached', cmd: 'tmux', args: ['new', '-d', '-s', session, '-c', projectDir, shellCmd, ...sidecar], session };
|
|
304
|
+
}
|
|
305
|
+
// `tmux new -A`: attach if the named session exists, else create it.
|
|
306
|
+
return { kind: 'tmux-new', cmd: 'tmux', args: ['new', '-A', '-s', session, shellCmd, ...sidecar, ...(0, exports.herdrSessionArgs)(session)], session };
|
|
307
|
+
};
|
|
308
|
+
exports.targetForAgent = targetForAgent;
|
|
309
|
+
// ── Background listener auto-start ────────────────────────────────────
|
|
310
|
+
/**
|
|
311
|
+
* Whether the daemon on record is running a different build than the one
|
|
312
|
+
* we're launching from. `npm i -g @hwip/cli` swaps dist/ but leaves the
|
|
313
|
+
* live process untouched, so a daemon can stay days behind the installed
|
|
314
|
+
* package — answering pushes (chat looks fine) while silently ignoring every
|
|
315
|
+
* message subtype added since it booted. That is exactly how a pre-1.24
|
|
316
|
+
* listener left the phone's live terminal spinning until a reboot.
|
|
317
|
+
*
|
|
318
|
+
* A missing stamp means the daemon predates version stamping, which puts it
|
|
319
|
+
* behind by definition — treat unknown as drifted.
|
|
320
|
+
*
|
|
321
|
+
* Strictly "installed is newer", never a plain inequality: one account can
|
|
322
|
+
* have several installs at different versions (nvm node versions, a repo
|
|
323
|
+
* build alongside the global one), and `!==` would make each `hwip cc` kill
|
|
324
|
+
* and respawn the other's daemon forever. An older `hwip cc` leaves a newer
|
|
325
|
+
* daemon alone — downgrading it would reintroduce the very gap this closes.
|
|
326
|
+
*/
|
|
327
|
+
const listenerVersionDrifted = (running, installed) => running === null || (0, check_update_js_1.isNewer)(installed, running);
|
|
328
|
+
exports.listenerVersionDrifted = listenerVersionDrifted;
|
|
329
|
+
/**
|
|
330
|
+
* Whether the listener started this process for a schedule (`LISTENER_LAUNCH_ENV`), and the marker used up: left in the
|
|
331
|
+
* environment it would reach the git hooks of a `--wt` worktree and the tmux server this launch may start, and so every pane after it.
|
|
332
|
+
*/
|
|
333
|
+
const takeListenerLaunchMarker = () => {
|
|
334
|
+
const set = process.env[listener_process_js_1.LISTENER_LAUNCH_ENV] === '1';
|
|
335
|
+
delete process.env[listener_process_js_1.LISTENER_LAUNCH_ENV];
|
|
336
|
+
return set;
|
|
337
|
+
};
|
|
338
|
+
exports.takeListenerLaunchMarker = takeListenerLaunchMarker;
|
|
339
|
+
/**
|
|
340
|
+
* Make sure the phone-bridge daemon is running AND is the build we just
|
|
341
|
+
* launched from. `hwip cc` is the right moment to replace a drifted one: the
|
|
342
|
+
* user is starting fresh work rather than mid-task, so the ~1s restart window
|
|
343
|
+
* (in which a push would be dropped — WS fan-out has no queue) costs nothing.
|
|
344
|
+
*/
|
|
345
|
+
const ensureListenerRunning = async (launchedByListener = (0, exports.takeListenerLaunchMarker)()) => {
|
|
346
|
+
if (launchedByListener)
|
|
347
|
+
return;
|
|
348
|
+
const pid = (0, listener_process_js_1.runningListenerPid)();
|
|
349
|
+
const running = pid === null ? null : (0, listener_process_js_1.runningListenerVersion)();
|
|
350
|
+
if (pid !== null && !(0, exports.listenerVersionDrifted)(running, config_js_1.VERSION))
|
|
351
|
+
return;
|
|
352
|
+
// When the login-time service is installed, the service manager (launchd,
|
|
353
|
+
// or under systemd the user manager) owns this process. Stopping its child
|
|
354
|
+
// makes the manager start a replacement while we spawn our own, and the
|
|
355
|
+
// two race through the singleton guard in handleListener — whichever
|
|
356
|
+
// loses exits 0, and if that is the manager's child, KeepAlive either
|
|
357
|
+
// flaps it every ThrottleInterval or reads the clean exit as deliberate
|
|
358
|
+
// and gives up for the rest of the login session. Ask the manager to do
|
|
359
|
+
// it instead; `kickstart -k` replaces a drifted daemon in one step.
|
|
360
|
+
if ((0, listener_service_js_1.serviceInstalled)()) {
|
|
361
|
+
const owner = process.platform === 'linux' ? 'systemd' : 'launchd';
|
|
362
|
+
const logOffset = (0, listener_process_js_1.listenerLogSize)();
|
|
363
|
+
const result = (0, listener_service_js_1.restartService)();
|
|
364
|
+
if (!result.ok) {
|
|
365
|
+
console.error(`hwip: could not start the listener service — ${result.reason}`);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (reportFailedStart(await (0, listener_process_js_1.awaitServiceListener)(logOffset, pid)))
|
|
369
|
+
return;
|
|
370
|
+
if (pid === null)
|
|
371
|
+
console.log(`hwip: listener started by ${owner} (log: ${listener_process_js_1.LISTENER_LOG_FILE})`);
|
|
372
|
+
else
|
|
373
|
+
console.log(`hwip: listener ${running ?? '(pre-1.26)'} is stale — ${owner} restarting on ${config_js_1.VERSION}`);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (pid !== null) {
|
|
377
|
+
console.log(`hwip: listener ${running ?? '(pre-1.26)'} is stale — restarting on ${config_js_1.VERSION}`);
|
|
378
|
+
const stopped = await (0, listener_process_js_1.stopListener)(pid);
|
|
379
|
+
// A pid that could not be checked was left running, and a new daemon
|
|
380
|
+
// would only defer to it through the singleton guard.
|
|
381
|
+
if (stopped.kind === 'unchecked') {
|
|
382
|
+
console.error(`hwip: could not check pid ${pid} (${stopped.reason}) — left the old listener running`);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
// Its stamps stay, so a new daemon would only defer to it.
|
|
386
|
+
if (stopped.kind === 'other-listener') {
|
|
387
|
+
console.error(`hwip: ${(0, listener_process_js_1.otherListenerNote)(pid, stopped.script)}`);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const started = await (0, listener_process_js_1.startListenerDetached)();
|
|
392
|
+
if (started.kind === 'not-spawned') {
|
|
393
|
+
console.error('hwip: listener autostart failed — run `hwip listener` manually.');
|
|
394
|
+
}
|
|
395
|
+
else if (!reportFailedStart(started) && pid === null && started.kind === 'running') {
|
|
396
|
+
console.log(`hwip: listener autostarted in background (log: ${listener_process_js_1.LISTENER_LOG_FILE})`);
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
exports.ensureListenerRunning = ensureListenerRunning;
|
|
400
|
+
/** Say why a just-started listener is not running; true when it said anything. */
|
|
401
|
+
const reportFailedStart = (started) => {
|
|
402
|
+
const why = (0, listener_process_js_1.startFailure)(started);
|
|
403
|
+
if (why === null)
|
|
404
|
+
return false;
|
|
405
|
+
console.error(`hwip: the listener stopped right after starting — ${why}`);
|
|
406
|
+
console.error(` the phone cannot reach this session until it runs (log: ${listener_process_js_1.LISTENER_LOG_FILE})`);
|
|
407
|
+
return true;
|
|
408
|
+
};
|
|
409
|
+
/**
|
|
410
|
+
* The exec that replaces this process with the child, or null when the wrapper
|
|
411
|
+
* has to stay and wait instead. Returning the function rather than a boolean
|
|
412
|
+
* keeps one check where two would otherwise drift: the platform gate and the
|
|
413
|
+
* policy are the same answer.
|
|
414
|
+
*
|
|
415
|
+
* `process.execve` is declared optional by @types/node, and is absent on node
|
|
416
|
+
* <22.15 (added there) and on Windows (its doc: "not available on Windows or
|
|
417
|
+
* IBM i"), so its presence covers both without a version check. `engines.node`
|
|
418
|
+
* still allows 20.x, 22.13–22.14 and 23.5–23.10 (added in 22.15 and 23.11, per
|
|
419
|
+
* the Node docs), so the spawn path below is not a transitional fallback — it
|
|
420
|
+
* stays.
|
|
421
|
+
*
|
|
422
|
+
* `isTTY` is `undefined` rather than `false` on a pipe, hence every `=== true`.
|
|
423
|
+
*
|
|
424
|
+
* The environment is bound here rather than left to execve's default: node
|
|
425
|
+
* 22.15.1 execs with an empty environment when the argument is omitted
|
|
426
|
+
* (measured — 22.23.2 and 24.x inherit process.env), and a tmux started without
|
|
427
|
+
* TERM dies with "open terminal failed: terminal does not support clear".
|
|
428
|
+
*/
|
|
429
|
+
const handOffExec = (deps) => {
|
|
430
|
+
// A detached launch returns after tmux answers — nothing to hand the terminal to.
|
|
431
|
+
if (deps.kind === 'tmux-detached')
|
|
432
|
+
return null;
|
|
433
|
+
if (!deps.execve)
|
|
434
|
+
return null;
|
|
435
|
+
// execve runs no cleanup handler, and node only writes stdout synchronously
|
|
436
|
+
// to a TTY on POSIX — anything buffered to a pipe would vanish with us.
|
|
437
|
+
if (deps.stdoutTTY !== true)
|
|
438
|
+
return null;
|
|
439
|
+
// `tmux new` additionally needs a terminal of its own to attach to. That is
|
|
440
|
+
// the failure the spawn path below exists to explain, so send it there.
|
|
441
|
+
if (deps.kind === 'tmux-new' && deps.stdinTTY !== true)
|
|
442
|
+
return null;
|
|
443
|
+
const { execve, env } = deps;
|
|
444
|
+
return (file, argv) => execve(file, argv, env);
|
|
445
|
+
};
|
|
446
|
+
exports.handOffExec = handOffExec;
|
|
447
|
+
/**
|
|
448
|
+
* Inside tmux the agent runs in the current pane, and the listener reports
|
|
449
|
+
* only `hwip-*` sessions — so unless this pane already sits in one, the phone
|
|
450
|
+
* never sees the agent. Say so, and name the two ways to a session it does
|
|
451
|
+
* see. `session` is this pane's tmux session, null when tmux would not say.
|
|
452
|
+
*/
|
|
453
|
+
const inPaneNote = (subcommand, session) => session?.startsWith('hwip-')
|
|
454
|
+
? undefined
|
|
455
|
+
: `hwip: running in this tmux pane, which the phone can't see — for a session it can, use \`hwip ${subcommand} --detach\` or run outside tmux`;
|
|
456
|
+
exports.inPaneNote = inPaneNote;
|
|
457
|
+
const currentTmuxSession = () => {
|
|
458
|
+
try {
|
|
459
|
+
// `-t` this pane: without it tmux answers for its current client,
|
|
460
|
+
// which with several attached can be another session.
|
|
461
|
+
const target = process.env.TMUX_PANE ? ['-t', process.env.TMUX_PANE] : [];
|
|
462
|
+
return (0, child_process_1.execFileSync)('tmux', ['display-message', '-p', ...target, '#S'], { encoding: 'utf-8' }).trim() || null;
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
/**
|
|
469
|
+
* What to say when tmux is missing — the install hint `hwip install` gives,
|
|
470
|
+
* not just "not found". Undefined when tmux is there. Exported for tests.
|
|
471
|
+
*/
|
|
472
|
+
const tmuxMissingMessage = (command, plan) => {
|
|
473
|
+
if (plan.kind === 'present')
|
|
474
|
+
return undefined;
|
|
475
|
+
const fix = plan.kind === 'command' ? `Run: ${plan.label}` : plan.hint;
|
|
476
|
+
return `hwip: tmux not found — \`hwip ${command}\` needs it. ${fix}`;
|
|
477
|
+
};
|
|
478
|
+
exports.tmuxMissingMessage = tmuxMissingMessage;
|
|
479
|
+
/** Checked before the listener is started: without tmux it has nothing to serve. */
|
|
480
|
+
const tmuxMissing = (command) => (0, exports.tmuxMissingMessage)(command, (0, tmux_install_js_1.planTmuxInstall)({ resolve: agents_js_1.resolveCommand, platform: process.platform }));
|
|
481
|
+
const handleAgentSession = async (agent, extra = [], opts = {}, deps = {}) => {
|
|
482
|
+
// First of all, before `ensureWorktree` runs git (its hooks would inherit the marker).
|
|
483
|
+
const launchedByListener = (0, exports.takeListenerLaunchMarker)();
|
|
484
|
+
// Best-effort: make sure the phone-bridge daemon is running, and running
|
|
485
|
+
// the build we were launched from. The user shouldn't need to remember a
|
|
486
|
+
// second command for the picker on their phone to work.
|
|
487
|
+
const missing = tmuxMissing(agent.subcommands[0]);
|
|
488
|
+
if (missing) {
|
|
489
|
+
console.error(missing);
|
|
490
|
+
return 127;
|
|
491
|
+
}
|
|
492
|
+
// Before the listener starts: a refused `--wt` should cost nothing.
|
|
493
|
+
if (opts.worktree) {
|
|
494
|
+
const made = await (0, worktree_js_1.ensureWorktree)({ dir: (0, exports.detectProjectDir)(), name: opts.worktree });
|
|
495
|
+
if (!made.ok) {
|
|
496
|
+
console.error(`hwip: ${made.error}`);
|
|
497
|
+
return made.usage ? 2 : 1;
|
|
498
|
+
}
|
|
499
|
+
console.error(`hwip: worktree ${made.path} (branch ${made.branch}) ${made.created ? 'created' : 'reused'}`);
|
|
500
|
+
// `projectDir` is what the session is named for: CLAUDE_PROJECT_DIR, when set, would otherwise name it for the repo
|
|
501
|
+
// the command was typed in.
|
|
502
|
+
opts = { ...opts, projectDir: made.path };
|
|
503
|
+
}
|
|
504
|
+
// The daily update check rides along with the listener start, not after it.
|
|
505
|
+
const [update] = await Promise.all([(0, check_update_js_1.cachedUpdateNotice)(), (deps.startListener ?? (() => (0, exports.ensureListenerRunning)(launchedByListener)))()]);
|
|
506
|
+
if (update)
|
|
507
|
+
console.error(update);
|
|
508
|
+
// The cwd is what a new tmux session (or the in-pane agent) starts in. After the listener start: a daemon
|
|
509
|
+
// spawned from here would inherit the worktree as its cwd, and it is the one that later removes the directory.
|
|
510
|
+
if (opts.worktree && opts.projectDir)
|
|
511
|
+
process.chdir(opts.projectDir);
|
|
512
|
+
const { kind, cmd, args, session } = (0, exports.targetForAgent)(agent.binary, extra, agent.kind, opts);
|
|
513
|
+
const note = kind === 'direct' ? (0, exports.inPaneNote)(agent.subcommands[0], currentTmuxSession()) : undefined;
|
|
514
|
+
if (note)
|
|
515
|
+
console.error(note);
|
|
516
|
+
// A pinned name skips `findAvailableSession`, and with it the registry
|
|
517
|
+
// check that keeps `new -A` from attaching to a session running another
|
|
518
|
+
// agent and dropping our command. Run that check here for the attach path.
|
|
519
|
+
if (kind === 'tmux-new' && opts.label && session) {
|
|
520
|
+
const known = (0, session_registry_js_1.recallSession)(session);
|
|
521
|
+
if (known && known.agentKind !== agent.kind) {
|
|
522
|
+
console.error(`hwip: ${session} is running ${known.agentKind}, not ${agent.binary} — pick another --label`);
|
|
523
|
+
return 1;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return (deps.launch ?? launchTarget)({ kind, cmd, args, session }, agent.binary);
|
|
527
|
+
};
|
|
528
|
+
exports.handleAgentSession = handleAgentSession;
|
|
529
|
+
/**
|
|
530
|
+
* Run a spawn target: a detached session is created and named, anything else
|
|
531
|
+
* hands the terminal over (execve where it can, a waited child where not) and
|
|
532
|
+
* forwards the exit code. `what` names the program in the detached message.
|
|
533
|
+
*/
|
|
534
|
+
const launchTarget = async ({ kind, cmd, args, session }, what) => {
|
|
535
|
+
if (kind === 'tmux-detached' && session) {
|
|
536
|
+
const r = (0, child_process_1.spawnSync)(cmd, args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
537
|
+
if (r.error || r.status !== 0) {
|
|
538
|
+
const why = r.error ? r.error.message : (r.stderr ?? '').trim() || `exit ${r.status}`;
|
|
539
|
+
console.error(`hwip: could not start ${session} — ${why}`);
|
|
540
|
+
return r.status ?? 1;
|
|
541
|
+
}
|
|
542
|
+
// The name is the contract: the caller attaches, kills, or injects by it.
|
|
543
|
+
console.log(`hwip: ${what} started in tmux session ${session} (detached) — attach: tmux attach -t ${session}`);
|
|
544
|
+
return 0;
|
|
545
|
+
}
|
|
546
|
+
// Hand the terminal over and stop existing. Waiting on the child is all
|
|
547
|
+
// this process does for the rest of the session, and it holds a whole node
|
|
548
|
+
// heap to do it — one resident process per `hwip cc`, purely to forward an
|
|
549
|
+
// exit code that execve gives us for free by being the same process.
|
|
550
|
+
const handOff = (0, exports.handOffExec)({
|
|
551
|
+
execve: process.execve,
|
|
552
|
+
env: process.env,
|
|
553
|
+
kind,
|
|
554
|
+
stdinTTY: process.stdin.isTTY,
|
|
555
|
+
stdoutTTY: process.stdout.isTTY,
|
|
556
|
+
});
|
|
557
|
+
if (handOff) {
|
|
558
|
+
const file = (0, agents_js_1.resolveCommand)(cmd);
|
|
559
|
+
// execve does no PATH lookup, so resolving is mandatory — and the
|
|
560
|
+
// resolve failing is the same fact the spawn path learns from ENOENT.
|
|
561
|
+
if (!file) {
|
|
562
|
+
console.error(`hwip: '${cmd}' not found on PATH`);
|
|
563
|
+
return 127;
|
|
564
|
+
}
|
|
565
|
+
// Nothing buffered survives: execve runs no cleanup handler and fires
|
|
566
|
+
// no exit event. Safe here only because stdout is a TTY, which node
|
|
567
|
+
// writes synchronously on POSIX — every `hwip:` line above is already out.
|
|
568
|
+
handOff(file, [cmd, ...args]);
|
|
569
|
+
}
|
|
570
|
+
return new Promise((resolve) => {
|
|
571
|
+
const start = Date.now();
|
|
572
|
+
const child = (0, child_process_1.spawn)(cmd, args, { stdio: 'inherit' });
|
|
573
|
+
child.on('exit', (code) => {
|
|
574
|
+
const dur = Date.now() - start;
|
|
575
|
+
// Short-lived non-zero exits are the symptom of "ran from a
|
|
576
|
+
// pane that isn't a real TTY" (some IDE terminals). The user
|
|
577
|
+
// otherwise just sees their shell return with `[exited]` and no
|
|
578
|
+
// clue what went wrong. Only `tmux new` fails that way — telling
|
|
579
|
+
// someone whose agent died inside a perfectly good pane to "run
|
|
580
|
+
// from a plain shell pane" would send them after the wrong thing.
|
|
581
|
+
if (kind === 'tmux-new' && code && code !== 0 && dur < 2000) {
|
|
582
|
+
console.error(`hwip: ${cmd} ${args.join(' ')} exited ${code} after ${dur}ms.\n` +
|
|
583
|
+
` If this terminal is itself inside tmux (or an iTerm/Warp\n` +
|
|
584
|
+
` tmux-integration pane), run \`hwip cc\` from a plain shell\n` +
|
|
585
|
+
` pane instead — \`tmux new\` needs a real TTY to attach.`);
|
|
586
|
+
}
|
|
587
|
+
resolve(code ?? 0);
|
|
588
|
+
});
|
|
589
|
+
child.on('error', (err) => {
|
|
590
|
+
if (err.code === 'ENOENT') {
|
|
591
|
+
console.error(`hwip: '${cmd}' not found on PATH`);
|
|
592
|
+
resolve(127);
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
console.error(`hwip: failed to spawn ${cmd}: ${err.message}`);
|
|
596
|
+
resolve(1);
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
};
|
|
601
|
+
const runTmux = (args) => {
|
|
602
|
+
const r = (0, child_process_1.spawnSync)('tmux', args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
603
|
+
return { status: r.error ? null : r.status, stdout: r.stdout ?? '', stderr: r.error?.message ?? r.stderr ?? '' };
|
|
604
|
+
};
|
|
605
|
+
/**
|
|
606
|
+
* How `hwip sh` proceeds, given what tmux holds under the name. `create` is
|
|
607
|
+
* the detached target, `attach` the attaching one (null for `--detach`).
|
|
608
|
+
*
|
|
609
|
+
* A missing session is created with `new -d` and `@hwip_shell` set to its own
|
|
610
|
+
* name in the same tmux call, so no sweep ever sees it unmarked, and `new -d`
|
|
611
|
+
* fails on a taken name: a session that appeared since the probe is never
|
|
612
|
+
* attached to and marked. The listener accepts the marker only when it names
|
|
613
|
+
* the session, so a stray `set -g @hwip_shell …` cannot mark anything. A
|
|
614
|
+
* session this command opened before is reattached (or, detached, left as it
|
|
615
|
+
* is — running `!hwip sh --detach` twice from the phone must not fail). Any
|
|
616
|
+
* other session under that name is refused: the marker would turn it into a
|
|
617
|
+
* typable shell.
|
|
618
|
+
*/
|
|
619
|
+
const planShellSession = (create, attach, state) => {
|
|
620
|
+
const session = create.session ?? '';
|
|
621
|
+
if (state === 'unmarked') {
|
|
622
|
+
return { kind: 'refuse', reason: `${session} exists and is not a hwip sh session — pick another --label` };
|
|
623
|
+
}
|
|
624
|
+
if (state === 'marked')
|
|
625
|
+
return attach ? { kind: 'attach', target: attach } : { kind: 'running' };
|
|
626
|
+
return {
|
|
627
|
+
kind: 'create',
|
|
628
|
+
args: [...create.args, ';', 'set-option', '-t', sessionOptionTarget(session), config_js_1.SESSION_SHELL_OPTION, session],
|
|
629
|
+
attach,
|
|
630
|
+
};
|
|
631
|
+
};
|
|
632
|
+
exports.planShellSession = planShellSession;
|
|
633
|
+
/**
|
|
634
|
+
* Read the marker off the session itself. `show-options` without `-A` shows
|
|
635
|
+
* only what is set on that session, never an inherited global value. A failed
|
|
636
|
+
* `has-session` reads as "none", which is safe: creation is `new -d`, and it
|
|
637
|
+
* fails on a name that does exist.
|
|
638
|
+
*/
|
|
639
|
+
const readShellSessionState = (session, run = runTmux) => {
|
|
640
|
+
if (run(['has-session', '-t', `=${session}`]).status !== 0)
|
|
641
|
+
return 'none';
|
|
642
|
+
const r = run(['show-options', '-qv', '-t', sessionOptionTarget(session), config_js_1.SESSION_SHELL_OPTION]);
|
|
643
|
+
return r.stdout.trim() === session ? 'marked' : 'unmarked';
|
|
644
|
+
};
|
|
645
|
+
exports.readShellSessionState = readShellSessionState;
|
|
646
|
+
/**
|
|
647
|
+
* The two targets `hwip sh` works with. Inside tmux there is no attaching —
|
|
648
|
+
* `new -A` would nest and exit 1 after the session already exists — so the
|
|
649
|
+
* run is a detached one, as `--detach` is.
|
|
650
|
+
*/
|
|
651
|
+
const shellTargets = (shell, opts, inTmux = !!process.env.TMUX) => {
|
|
652
|
+
const create = (0, exports.targetForAgent)(shell, ['-l'], 'shell', { label: opts.label ?? 'sh', detach: true });
|
|
653
|
+
const session = create.session ?? '';
|
|
654
|
+
// `attach-session`, never `new -A`: a session that died since the probe
|
|
655
|
+
// must not come back as a fresh, unmarked one.
|
|
656
|
+
const attach = { kind: 'tmux-new', cmd: 'tmux', args: ['attach-session', '-t', `=${session}`], session };
|
|
657
|
+
return { create, attach: opts.detach || inTmux ? null : attach };
|
|
658
|
+
};
|
|
659
|
+
exports.shellTargets = shellTargets;
|
|
660
|
+
/**
|
|
661
|
+
* `hwip sh [--label x] [--detach]` — open (or reattach) `hwip-<project>-sh`
|
|
662
|
+
* running the login shell, marked so the phone may type into it. The label
|
|
663
|
+
* always pins a tmux session, so this never runs in the current pane.
|
|
664
|
+
*/
|
|
665
|
+
const handleShellSession = async (opts) => {
|
|
666
|
+
const missing = tmuxMissing('sh');
|
|
667
|
+
if (missing) {
|
|
668
|
+
console.error(missing);
|
|
669
|
+
return 127;
|
|
670
|
+
}
|
|
671
|
+
await (0, exports.ensureListenerRunning)();
|
|
672
|
+
const shell = process.env.SHELL || '/bin/sh';
|
|
673
|
+
const { create, attach } = (0, exports.shellTargets)(shell, opts);
|
|
674
|
+
const session = create.session ?? '';
|
|
675
|
+
const switchHint = () => {
|
|
676
|
+
if (process.env.TMUX)
|
|
677
|
+
console.log(`hwip: from this tmux: tmux switch-client -t ${session}`);
|
|
678
|
+
};
|
|
679
|
+
const plan = (0, exports.planShellSession)(create, attach, (0, exports.readShellSessionState)(session));
|
|
680
|
+
if (plan.kind === 'refuse') {
|
|
681
|
+
console.error(`hwip: ${plan.reason}`);
|
|
682
|
+
return 1;
|
|
683
|
+
}
|
|
684
|
+
if (plan.kind === 'running') {
|
|
685
|
+
console.log(`hwip: shell session ${session} is already running — attach: tmux attach -t ${session}`);
|
|
686
|
+
switchHint();
|
|
687
|
+
return 0;
|
|
688
|
+
}
|
|
689
|
+
if (plan.kind === 'attach')
|
|
690
|
+
return launchTarget(plan.target, (0, path_1.basename)(shell));
|
|
691
|
+
if (!plan.attach) {
|
|
692
|
+
const code = await launchTarget({ ...create, args: plan.args }, (0, path_1.basename)(shell));
|
|
693
|
+
if (code === 0)
|
|
694
|
+
switchHint();
|
|
695
|
+
return code;
|
|
696
|
+
}
|
|
697
|
+
const made = runTmux(plan.args);
|
|
698
|
+
if (made.status !== 0) {
|
|
699
|
+
console.error(`hwip: could not start ${session} — ${(made.stderr ?? '').trim() || `exit ${made.status}`}`);
|
|
700
|
+
return made.status ?? 1;
|
|
701
|
+
}
|
|
702
|
+
return launchTarget(plan.attach, (0, path_1.basename)(shell));
|
|
703
|
+
};
|
|
704
|
+
exports.handleShellSession = handleShellSession;
|