@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.
Files changed (245) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +11 -0
  3. package/README.md +1979 -0
  4. package/dist/agent-list.d.ts +76 -0
  5. package/dist/agent-list.js +102 -0
  6. package/dist/agent-read.d.ts +105 -0
  7. package/dist/agent-read.js +309 -0
  8. package/dist/agent-rules-fetch.d.ts +51 -0
  9. package/dist/agent-rules-fetch.js +275 -0
  10. package/dist/agent-rules.default.d.ts +30 -0
  11. package/dist/agent-rules.default.js +292 -0
  12. package/dist/agent-session.d.ts +23 -0
  13. package/dist/agent-session.js +44 -0
  14. package/dist/agent-state.d.ts +84 -0
  15. package/dist/agent-state.js +188 -0
  16. package/dist/agent-target.d.ts +60 -0
  17. package/dist/agent-target.js +81 -0
  18. package/dist/agents.d.ts +31 -0
  19. package/dist/agents.js +75 -0
  20. package/dist/api.d.ts +28 -0
  21. package/dist/api.js +76 -0
  22. package/dist/args.d.ts +74 -0
  23. package/dist/args.js +254 -0
  24. package/dist/ask-context-attach.d.ts +73 -0
  25. package/dist/ask-context-attach.js +151 -0
  26. package/dist/ask-context-screen.d.ts +21 -0
  27. package/dist/ask-context-screen.js +49 -0
  28. package/dist/ask-context-switch.d.ts +31 -0
  29. package/dist/ask-context-switch.js +74 -0
  30. package/dist/ask-context.d.ts +84 -0
  31. package/dist/ask-context.js +172 -0
  32. package/dist/ask-pending.d.ts +47 -0
  33. package/dist/ask-pending.js +221 -0
  34. package/dist/ask-seal.d.ts +120 -0
  35. package/dist/ask-seal.js +179 -0
  36. package/dist/ask.d.ts +107 -0
  37. package/dist/ask.js +551 -0
  38. package/dist/blocked-prompt-seal.d.ts +35 -0
  39. package/dist/blocked-prompt-seal.js +89 -0
  40. package/dist/blocked-prompt.d.ts +57 -0
  41. package/dist/blocked-prompt.js +181 -0
  42. package/dist/channel.d.ts +67 -0
  43. package/dist/channel.js +136 -0
  44. package/dist/check-update.d.ts +28 -0
  45. package/dist/check-update.js +153 -0
  46. package/dist/cli.d.ts +3 -0
  47. package/dist/cli.js +813 -0
  48. package/dist/codex-config.d.ts +24 -0
  49. package/dist/codex-config.js +169 -0
  50. package/dist/codex-transcript.d.ts +59 -0
  51. package/dist/codex-transcript.js +373 -0
  52. package/dist/command-refusal.d.ts +56 -0
  53. package/dist/command-refusal.js +127 -0
  54. package/dist/command-scan.d.ts +46 -0
  55. package/dist/command-scan.js +154 -0
  56. package/dist/command-seal.d.ts +57 -0
  57. package/dist/command-seal.js +88 -0
  58. package/dist/command-seq.d.ts +30 -0
  59. package/dist/command-seq.js +150 -0
  60. package/dist/config.d.ts +156 -0
  61. package/dist/config.js +392 -0
  62. package/dist/core-sections.d.ts +18 -0
  63. package/dist/core-sections.js +28 -0
  64. package/dist/crypto.d.ts +278 -0
  65. package/dist/crypto.js +698 -0
  66. package/dist/desktop-notify.d.ts +33 -0
  67. package/dist/desktop-notify.js +49 -0
  68. package/dist/device-directory.d.ts +36 -0
  69. package/dist/device-directory.js +97 -0
  70. package/dist/device-presence.d.ts +17 -0
  71. package/dist/device-presence.js +62 -0
  72. package/dist/downloads.d.ts +29 -0
  73. package/dist/downloads.js +106 -0
  74. package/dist/errors.d.ts +12 -0
  75. package/dist/errors.js +28 -0
  76. package/dist/file-crypto.d.ts +94 -0
  77. package/dist/file-crypto.js +272 -0
  78. package/dist/file-view-budget.d.ts +15 -0
  79. package/dist/file-view-budget.js +34 -0
  80. package/dist/file-view-switch.d.ts +28 -0
  81. package/dist/file-view-switch.js +122 -0
  82. package/dist/file-view.d.ts +108 -0
  83. package/dist/file-view.js +327 -0
  84. package/dist/gate.d.ts +141 -0
  85. package/dist/gate.js +336 -0
  86. package/dist/gemini-transcript.d.ts +32 -0
  87. package/dist/gemini-transcript.js +189 -0
  88. package/dist/http-failure.d.ts +98 -0
  89. package/dist/http-failure.js +349 -0
  90. package/dist/hwip-core.generated.d.ts +7 -0
  91. package/dist/hwip-core.generated.js +14 -0
  92. package/dist/hwip-hook.d.ts +77 -0
  93. package/dist/hwip-hook.js +340 -0
  94. package/dist/index.d.ts +7 -0
  95. package/dist/index.js +14 -0
  96. package/dist/input-sequencer.d.ts +65 -0
  97. package/dist/input-sequencer.js +142 -0
  98. package/dist/insecure-url.d.ts +16 -0
  99. package/dist/insecure-url.js +76 -0
  100. package/dist/installer.d.ts +96 -0
  101. package/dist/installer.js +850 -0
  102. package/dist/inventory-offload.d.ts +49 -0
  103. package/dist/inventory-offload.js +209 -0
  104. package/dist/inventory-worker.d.ts +2 -0
  105. package/dist/inventory-worker.js +27 -0
  106. package/dist/jsonc-doc.d.ts +106 -0
  107. package/dist/jsonc-doc.js +354 -0
  108. package/dist/keep-awake.d.ts +32 -0
  109. package/dist/keep-awake.js +46 -0
  110. package/dist/key-changes.d.ts +48 -0
  111. package/dist/key-changes.js +140 -0
  112. package/dist/lan-auth.d.ts +140 -0
  113. package/dist/lan-auth.js +217 -0
  114. package/dist/lan-endpoint.d.ts +41 -0
  115. package/dist/lan-endpoint.js +115 -0
  116. package/dist/lan-receiver.d.ts +122 -0
  117. package/dist/lan-receiver.js +510 -0
  118. package/dist/lan-sender.d.ts +76 -0
  119. package/dist/lan-sender.js +184 -0
  120. package/dist/lan-transfer.d.ts +36 -0
  121. package/dist/lan-transfer.js +54 -0
  122. package/dist/listener-device-id.d.ts +20 -0
  123. package/dist/listener-device-id.js +133 -0
  124. package/dist/listener-log.d.ts +4 -0
  125. package/dist/listener-log.js +23 -0
  126. package/dist/listener-process.d.ts +200 -0
  127. package/dist/listener-process.js +470 -0
  128. package/dist/listener-service.d.ts +225 -0
  129. package/dist/listener-service.js +682 -0
  130. package/dist/listener-worktree-cleanup.d.ts +27 -0
  131. package/dist/listener-worktree-cleanup.js +62 -0
  132. package/dist/listener.d.ts +1325 -0
  133. package/dist/listener.js +5680 -0
  134. package/dist/login-address.d.ts +25 -0
  135. package/dist/login-address.js +97 -0
  136. package/dist/login-page.d.ts +29 -0
  137. package/dist/login-page.js +93 -0
  138. package/dist/login.d.ts +98 -0
  139. package/dist/login.js +388 -0
  140. package/dist/mcp-command.d.ts +45 -0
  141. package/dist/mcp-command.js +50 -0
  142. package/dist/mcp.d.ts +19 -0
  143. package/dist/mcp.js +29 -0
  144. package/dist/node-support.d.ts +19 -0
  145. package/dist/node-support.js +38 -0
  146. package/dist/notify-failure.d.ts +57 -0
  147. package/dist/notify-failure.js +150 -0
  148. package/dist/opencode-transcript.d.ts +139 -0
  149. package/dist/opencode-transcript.js +390 -0
  150. package/dist/optional-features.d.ts +6 -0
  151. package/dist/optional-features.js +19 -0
  152. package/dist/pane-fit.d.ts +40 -0
  153. package/dist/pane-fit.js +119 -0
  154. package/dist/peer-key.d.ts +33 -0
  155. package/dist/peer-key.js +34 -0
  156. package/dist/pi-transcript.d.ts +26 -0
  157. package/dist/pi-transcript.js +255 -0
  158. package/dist/presence.d.ts +14 -0
  159. package/dist/presence.js +124 -0
  160. package/dist/private-files.d.ts +51 -0
  161. package/dist/private-files.js +218 -0
  162. package/dist/project-switch.d.ts +19 -0
  163. package/dist/project-switch.js +60 -0
  164. package/dist/redact.d.ts +10 -0
  165. package/dist/redact.js +21 -0
  166. package/dist/remote-agents.d.ts +410 -0
  167. package/dist/remote-agents.js +1218 -0
  168. package/dist/remote-hook.d.ts +12 -0
  169. package/dist/remote-hook.js +196 -0
  170. package/dist/replace-file.d.ts +50 -0
  171. package/dist/replace-file.js +328 -0
  172. package/dist/schedule-command.d.ts +35 -0
  173. package/dist/schedule-command.js +260 -0
  174. package/dist/schedule-launch.d.ts +15 -0
  175. package/dist/schedule-launch.js +24 -0
  176. package/dist/schedule-ready.d.ts +44 -0
  177. package/dist/schedule-ready.js +71 -0
  178. package/dist/schedule-remote.d.ts +77 -0
  179. package/dist/schedule-remote.js +167 -0
  180. package/dist/schedule-runner.d.ts +89 -0
  181. package/dist/schedule-runner.js +452 -0
  182. package/dist/schedule.d.ts +149 -0
  183. package/dist/schedule.js +428 -0
  184. package/dist/secret-mask.d.ts +29 -0
  185. package/dist/secret-mask.js +245 -0
  186. package/dist/self-register.d.ts +45 -0
  187. package/dist/self-register.js +67 -0
  188. package/dist/send.d.ts +56 -0
  189. package/dist/send.js +104 -0
  190. package/dist/session-diff.d.ts +161 -0
  191. package/dist/session-diff.js +445 -0
  192. package/dist/session-registry.d.ts +143 -0
  193. package/dist/session-registry.js +216 -0
  194. package/dist/sessions-report.d.ts +59 -0
  195. package/dist/sessions-report.js +99 -0
  196. package/dist/shell-audit.d.ts +34 -0
  197. package/dist/shell-audit.js +47 -0
  198. package/dist/subagent-transcripts.d.ts +167 -0
  199. package/dist/subagent-transcripts.js +437 -0
  200. package/dist/subscriber-trust.d.ts +128 -0
  201. package/dist/subscriber-trust.js +397 -0
  202. package/dist/templates.d.ts +105 -0
  203. package/dist/templates.js +871 -0
  204. package/dist/tmux-install.d.ts +44 -0
  205. package/dist/tmux-install.js +65 -0
  206. package/dist/tmux-liveness.d.ts +45 -0
  207. package/dist/tmux-liveness.js +71 -0
  208. package/dist/tmux-pane.d.ts +55 -0
  209. package/dist/tmux-pane.js +126 -0
  210. package/dist/tmux-socket.d.ts +20 -0
  211. package/dist/tmux-socket.js +292 -0
  212. package/dist/todo-fold.d.ts +31 -0
  213. package/dist/todo-fold.js +102 -0
  214. package/dist/transcript-tail.d.ts +312 -0
  215. package/dist/transcript-tail.js +718 -0
  216. package/dist/trust-command.d.ts +49 -0
  217. package/dist/trust-command.js +200 -0
  218. package/dist/trust-lock.d.ts +26 -0
  219. package/dist/trust-lock.js +94 -0
  220. package/dist/trust-pins.d.ts +37 -0
  221. package/dist/trust-pins.js +82 -0
  222. package/dist/turn-recorder-wire.d.ts +70 -0
  223. package/dist/turn-recorder-wire.js +165 -0
  224. package/dist/turn-recorder.d.ts +190 -0
  225. package/dist/turn-recorder.js +695 -0
  226. package/dist/turn-ring.d.ts +93 -0
  227. package/dist/turn-ring.js +418 -0
  228. package/dist/turn-watch.d.ts +177 -0
  229. package/dist/turn-watch.js +630 -0
  230. package/dist/types.d.ts +63 -0
  231. package/dist/types.js +2 -0
  232. package/dist/uninstall.d.ts +55 -0
  233. package/dist/uninstall.js +507 -0
  234. package/dist/usage.d.ts +17 -0
  235. package/dist/usage.js +421 -0
  236. package/dist/verify.d.ts +47 -0
  237. package/dist/verify.js +396 -0
  238. package/dist/worktree.d.ts +144 -0
  239. package/dist/worktree.js +310 -0
  240. package/dist/wrapper.d.ts +254 -0
  241. package/dist/wrapper.js +704 -0
  242. package/dist/ws-url.d.ts +109 -0
  243. package/dist/ws-url.js +202 -0
  244. package/package.json +78 -10
  245. package/bin/hwip.js +0 -3
@@ -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;