@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,310 @@
1
+ "use strict";
2
+ /**
3
+ * `hwip <agent> --wt <name>`: a git worktree made for a session, and taken back
4
+ * when the session is over and nothing in it would be lost.
5
+ *
6
+ * hwip only ever removes a worktree it made itself — the rows in
7
+ * `<stateDir>/worktrees.json` are the whole list. A worktree the user made by
8
+ * hand, or one `--wt` merely found at the path, has no row and no code path
9
+ * here that removes it. Every doubt about a worktree (a git call that fails or
10
+ * times out, a count that does not parse) reads as "keep it": the cost of a
11
+ * leftover directory is a line in `hwip worktree`, the cost of a wrong removal
12
+ * is somebody's work. `--force`, `rm -rf` and `branch -D` are not used anywhere.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.readPaneCwds = exports.worktreeStatuses = exports.reconcileWorktrees = exports.inspectWorktree = exports.ensureWorktree = exports.isValidBranchName = exports.worktreeDirName = exports.updateWorktreeRows = exports.readWorktreeRows = exports.tmuxRun = exports.gitRun = exports.commandRunner = void 0;
16
+ const child_process_1 = require("child_process");
17
+ const fs_1 = require("fs");
18
+ const path_1 = require("path");
19
+ const config_js_1 = require("./config.js");
20
+ const gate_js_1 = require("./gate.js");
21
+ const private_files_js_1 = require("./private-files.js");
22
+ const replace_file_js_1 = require("./replace-file.js");
23
+ const GIT_TIMEOUT_MS = 10_000;
24
+ const firstLine = (text) => text.trim().split('\n')[0] ?? '';
25
+ /** `execFile` of one command with argv (no shell: branch names and paths are user input), as a `GitRun`. */
26
+ const commandRunner = (cmd) => (args, timeoutMs = GIT_TIMEOUT_MS) => new Promise((resolve) => {
27
+ (0, child_process_1.execFile)(cmd, args, { encoding: 'utf-8', timeout: timeoutMs }, (err, stdout, stderr) => {
28
+ if (!err)
29
+ return resolve({ ok: true, stdout });
30
+ if ('code' in err && err.code === 'ENOENT')
31
+ return resolve({ ok: false, error: `${cmd} not found` });
32
+ const timedOut = 'killed' in err && err.killed;
33
+ resolve({ ok: false, error: timedOut ? `${cmd} timed out` : firstLine(stderr) || err.message });
34
+ });
35
+ });
36
+ exports.commandRunner = commandRunner;
37
+ exports.gitRun = (0, exports.commandRunner)('git');
38
+ exports.tmuxRun = (0, exports.commandRunner)('tmux');
39
+ const registryPath = () => (0, path_1.join)((0, gate_js_1.stateDir)(), 'worktrees.json');
40
+ const isRow = (e) => {
41
+ if (!e || typeof e !== 'object')
42
+ return false;
43
+ const row = e; // shape-checked field by field below
44
+ return ['path', 'root', 'branch', 'baseSha', 'createdAt'].every((k) => typeof row[k] === 'string')
45
+ && typeof row.createdBranch === 'boolean'
46
+ && (row.reasons === undefined || (Array.isArray(row.reasons) && row.reasons.every((r) => typeof r === 'string')));
47
+ };
48
+ /** Rows that still make sense: a half-written or hand-edited file must not stop a launch. */
49
+ const parseRows = (text) => {
50
+ if (!text)
51
+ return [];
52
+ try {
53
+ const parsed = JSON.parse(text);
54
+ return Array.isArray(parsed) ? parsed.filter(isRow) : [];
55
+ }
56
+ catch {
57
+ return [];
58
+ }
59
+ };
60
+ const readWorktreeRows = () => {
61
+ try {
62
+ return parseRows((0, fs_1.readFileSync)(registryPath(), 'utf-8'));
63
+ }
64
+ catch {
65
+ return [];
66
+ }
67
+ };
68
+ exports.readWorktreeRows = readWorktreeRows;
69
+ /**
70
+ * Read, change, write. A row that cannot be written is a worktree that is not
71
+ * cleaned up automatically — the launch goes on. `expected` turns a write by
72
+ * the other process (wrapper vs listener) since the read into an error, which
73
+ * lands here too: a lost row only ever means "kept".
74
+ */
75
+ const updateWorktreeRows = (change) => {
76
+ const path = registryPath();
77
+ try {
78
+ const text = (0, replace_file_js_1.readTextIfExists)(path);
79
+ (0, replace_file_js_1.replaceFile)(path, JSON.stringify(change(parseRows(text)), null, 2), text, 0o600);
80
+ (0, private_files_js_1.handBackToSudoUser)(path);
81
+ }
82
+ catch {
83
+ /* the registry is a convenience, not a gate */
84
+ }
85
+ };
86
+ exports.updateWorktreeRows = updateWorktreeRows;
87
+ const refuse = (error, usage = false) => ({ ok: false, error, usage });
88
+ /** The worktrees `git worktree list --porcelain` knows: realpath → branch ref (null when detached). */
89
+ const listWorktrees = async (root, run) => {
90
+ const out = await run(['-C', root, 'worktree', 'list', '--porcelain']);
91
+ if (!out.ok)
92
+ return null;
93
+ const found = new Map();
94
+ for (const block of out.stdout.split('\n\n')) {
95
+ const lines = block.split('\n');
96
+ const path = lines.find((l) => l.startsWith('worktree '))?.slice('worktree '.length);
97
+ if (!path)
98
+ continue;
99
+ found.set(path, lines.find((l) => l.startsWith('branch '))?.slice('branch '.length) ?? null);
100
+ }
101
+ return found;
102
+ };
103
+ const realOrSelf = (path) => {
104
+ try {
105
+ return (0, fs_1.realpathSync)(path);
106
+ }
107
+ catch {
108
+ return path;
109
+ }
110
+ };
111
+ /**
112
+ * The directory name of the worktree for branch `name` of the repo at `root`: `<repo>-wt-<name with separators as dashes>`.
113
+ * Null when nothing but separators is left. `hwip schedule add` names its session from this, so the two cannot drift.
114
+ */
115
+ const worktreeDirName = (root, name) => {
116
+ const slug = name.replace(/[/.:;\s]+/g, '-');
117
+ return slug.replace(/-/g, '') ? `${(0, path_1.basename)(root)}-wt-${slug}` : null;
118
+ };
119
+ exports.worktreeDirName = worktreeDirName;
120
+ /** Whether `name` is a branch name git accepts as it stands (`--branch` also expands `@{-1}`-style names; one that changes is not what was typed). */
121
+ const isValidBranchName = async (root, name, run = exports.gitRun) => {
122
+ const checked = await run(['-C', root, 'check-ref-format', '--branch', name]);
123
+ return checked.ok && checked.stdout.trim() === name;
124
+ };
125
+ exports.isValidBranchName = isValidBranchName;
126
+ /**
127
+ * The worktree for branch `name` next to the main checkout of the repo `dir` is
128
+ * in — made, or found already made by this same command. Anything else at the
129
+ * path is refused and left alone.
130
+ */
131
+ const ensureWorktree = async ({ dir, name }, run = exports.gitRun) => {
132
+ const root = (0, config_js_1.checkoutOf)(dir)?.root;
133
+ if (!root)
134
+ return refuse('--wt needs a git checkout with a main worktree');
135
+ if (!name || name.startsWith('-'))
136
+ return refuse(`--wt needs a branch name (got '${name}')`, true);
137
+ if (!(await (0, exports.isValidBranchName)(root, name, run)))
138
+ return refuse(`'${name}' is not a valid branch name`, true);
139
+ const dirName = (0, exports.worktreeDirName)(root, name);
140
+ if (!dirName)
141
+ return refuse(`'${name}' is not a usable worktree name`, true);
142
+ const path = (0, path_1.join)((0, path_1.dirname)(root), dirName);
143
+ const known = await listWorktrees(root, run);
144
+ if (!known)
145
+ return refuse(`could not list worktrees of ${root}`);
146
+ const there = [...known].find(([listed]) => realOrSelf(listed) === realOrSelf(path));
147
+ const present = (0, fs_1.lstatSync)(path, { throwIfNoEntry: false }) !== undefined;
148
+ if (present && there?.[1] === `refs/heads/${name}`)
149
+ return { ok: true, path: realOrSelf(path), branch: name, created: false };
150
+ // Registered but gone, a plain directory, a file, a worktree of another branch: none of them is ours to touch.
151
+ if (present || there)
152
+ return refuse(`${path} exists and is not the worktree for ${name}`);
153
+ const branchExists = (await run(['-C', root, 'show-ref', '--verify', '--quiet', `refs/heads/${name}`])).ok;
154
+ // A new branch starts where the command was typed; one that exists is checked out as it is.
155
+ const added = branchExists
156
+ ? await run(['-C', root, 'worktree', 'add', path, name], 0)
157
+ : await run(['-C', dir, 'worktree', 'add', '-b', name, path], 0);
158
+ if (!added.ok)
159
+ return refuse(added.error);
160
+ const made = realOrSelf(path);
161
+ const head = await run(['-C', made, 'rev-parse', 'HEAD']);
162
+ if (head.ok) {
163
+ const row = {
164
+ path: made, root, branch: name, baseSha: head.stdout.trim(),
165
+ createdBranch: !branchExists, createdAt: new Date().toISOString(),
166
+ };
167
+ (0, exports.updateWorktreeRows)((rows) => [...rows.filter((r) => r.path !== made), row]);
168
+ }
169
+ return { ok: true, path: made, branch: name, created: true };
170
+ };
171
+ exports.ensureWorktree = ensureWorktree;
172
+ const plural = (n, noun) => `${n} ${noun}${n === 1 ? '' : 's'}`;
173
+ /**
174
+ * Whether this worktree can go without losing anything. Asked in git's own
175
+ * terms rather than left to `git worktree remove`, which deletes a worktree
176
+ * holding only ignored files (a hand-written `.env`) and one whose commits
177
+ * were never pushed (measured, git 2.47.0). Both checks always run, so the
178
+ * reasons are the whole list; a check that cannot be answered is a reason too.
179
+ */
180
+ const inspectWorktree = async (row, run = exports.gitRun) => {
181
+ const reasons = [];
182
+ // A row ties a worktree to its path only. If the user removed this one and made their own at the same path, it is theirs.
183
+ const known = await listWorktrees(row.root, run);
184
+ const listed = known ? [...known].find(([path]) => realOrSelf(path) === realOrSelf(row.path)) : undefined;
185
+ if (!known)
186
+ reasons.push('could not check: worktrees not listed');
187
+ else if (!listed)
188
+ reasons.push('not a worktree git knows any more');
189
+ else if (listed[1] !== `refs/heads/${row.branch}`)
190
+ reasons.push(`no longer on branch ${row.branch}`);
191
+ // Explicit, because `status.showUntrackedFiles=no` and `diff.ignoreSubmodules` would otherwise hide what `worktree remove` deletes.
192
+ const status = await run(['-C', row.path, 'status', '--porcelain', '--ignored', '--untracked-files=normal', '--ignore-submodules=none']);
193
+ if (!status.ok) {
194
+ reasons.push(`could not check: ${status.error}`);
195
+ }
196
+ else {
197
+ const lines = status.stdout.split('\n').filter(Boolean);
198
+ const ignored = lines.filter((l) => l.startsWith('!!')).length;
199
+ if (lines.length > ignored)
200
+ reasons.push(`${lines.length - ignored} uncommitted or untracked`);
201
+ if (ignored)
202
+ reasons.push(`${ignored} ignored`);
203
+ }
204
+ // Not `--all`: that includes this worktree's own HEAD and always counts it.
205
+ const ahead = await run(['-C', row.path, 'rev-list', '--count', 'HEAD', '--not', row.baseSha, '--remotes']);
206
+ const count = ahead.ok ? Number(ahead.stdout.trim()) : NaN;
207
+ if (!ahead.ok)
208
+ reasons.push(`could not check: ${ahead.error}`);
209
+ else if (!Number.isInteger(count))
210
+ reasons.push('could not check: unreadable commit count');
211
+ else if (count > 0)
212
+ reasons.push(`${plural(count, 'unpushed commit')}`);
213
+ const distinct = [...new Set(reasons)];
214
+ return distinct.length ? { removable: false, reasons: distinct } : { removable: true };
215
+ };
216
+ exports.inspectWorktree = inspectWorktree;
217
+ const within = (cwd, path) => cwd === path || cwd.startsWith(path + path_1.sep);
218
+ /**
219
+ * Which of the worktrees hwip made to take back, and which to keep and say why.
220
+ *
221
+ * Automatic (`manual` off): a worktree is judged once, after a pane was last
222
+ * seen inside it and none is now. Judging again later would delete a directory
223
+ * somebody went back into and pushed from, so a worktree that was kept stays
224
+ * kept until `hwip worktree clean`, or until a session uses it again.
225
+ *
226
+ * Rows are re-read when written, so a row the wrapper added while git was
227
+ * being asked about is not lost.
228
+ */
229
+ const reconcileWorktrees = async (input, run = exports.gitRun) => {
230
+ const { manual = false, dryRun = false } = input;
231
+ const now = (input.now ?? new Date()).toISOString();
232
+ const panes = input.paneCwds.map(realOrSelf);
233
+ const report = { removed: [], kept: [], inUse: [], gone: [] };
234
+ const drop = new Set();
235
+ const patch = new Map();
236
+ for (const row of (0, exports.readWorktreeRows)()) {
237
+ if ((0, fs_1.lstatSync)(row.path, { throwIfNoEntry: false }) === undefined) {
238
+ report.gone.push(row.path);
239
+ drop.add(row.path);
240
+ continue;
241
+ }
242
+ if (panes.some((cwd) => within(cwd, row.path))) {
243
+ report.inUse.push(row.path);
244
+ patch.set(row.path, { sessionSeenAt: now, keptAt: undefined, reasons: undefined });
245
+ continue;
246
+ }
247
+ if (!manual && (!row.sessionSeenAt || row.keptAt))
248
+ continue;
249
+ const verdict = await (0, exports.inspectWorktree)(row, run);
250
+ if (!verdict.removable) {
251
+ report.kept.push({ path: row.path, branch: row.branch, reasons: verdict.reasons, removeFailed: false });
252
+ if (!manual)
253
+ patch.set(row.path, { keptAt: now, reasons: verdict.reasons });
254
+ continue;
255
+ }
256
+ if (dryRun) {
257
+ report.removed.push({ path: row.path, branch: row.branch, branchDeleted: false });
258
+ continue;
259
+ }
260
+ // No `--force`: git refuses a worktree that gained a change since the check.
261
+ const removed = await run(['-C', row.root, 'worktree', 'remove', row.path]);
262
+ if (!removed.ok) {
263
+ report.kept.push({ path: row.path, branch: row.branch, reasons: [removed.error], removeFailed: true });
264
+ if (!manual)
265
+ patch.set(row.path, { keptAt: now, reasons: [removed.error] });
266
+ continue;
267
+ }
268
+ // `-d`, never `-D`: git refuses a branch that is not merged, and that branch stays.
269
+ const branchDeleted = row.createdBranch && (await run(['-C', row.root, 'branch', '-d', row.branch])).ok;
270
+ report.removed.push({ path: row.path, branch: row.branch, branchDeleted });
271
+ drop.add(row.path);
272
+ }
273
+ if (!dryRun && (drop.size || patch.size)) {
274
+ (0, exports.updateWorktreeRows)((rows) => rows
275
+ .filter((r) => !drop.has(r.path))
276
+ .map((r) => (patch.has(r.path) ? { ...r, ...patch.get(r.path) } : r)));
277
+ }
278
+ return report;
279
+ };
280
+ exports.reconcileWorktrees = reconcileWorktrees;
281
+ /** What `hwip worktree` shows: every row with its state now. Read-only. */
282
+ const worktreeStatuses = async (paneCwds, run = exports.gitRun) => {
283
+ const panes = paneCwds.map(realOrSelf);
284
+ return Promise.all((0, exports.readWorktreeRows)().map(async (row) => {
285
+ if ((0, fs_1.lstatSync)(row.path, { throwIfNoEntry: false }) === undefined)
286
+ return { row, state: 'directory gone' };
287
+ if (panes.some((cwd) => within(cwd, row.path)))
288
+ return { row, state: 'in use' };
289
+ const verdict = await (0, exports.inspectWorktree)(row, run);
290
+ if (!verdict.removable)
291
+ return { row, state: verdict.reasons.join(', ') };
292
+ return { row, state: row.sessionSeenAt ? 'clean' : 'never used' };
293
+ }));
294
+ };
295
+ exports.worktreeStatuses = worktreeStatuses;
296
+ // Measured on tmux 3.5a: a server that is not there says `no server running on <socket>` (stale socket file) or
297
+ // `error connecting to <socket> (No such file or directory)`; `(Permission denied)` and the rest are not "no panes".
298
+ const NO_TMUX_SERVER = /^(no server running on |error connecting to .+ \(No such file or directory\)$)/;
299
+ /**
300
+ * The cwd of every live tmux pane, `[]` when there is no tmux server, null when
301
+ * tmux could not be asked. The difference matters: an unreadable list read as
302
+ * empty would make every worktree in use look abandoned.
303
+ */
304
+ const readPaneCwds = async (tmux = exports.tmuxRun) => {
305
+ const out = await tmux(['list-panes', '-a', '-F', '#{pane_current_path}']);
306
+ if (out.ok)
307
+ return out.stdout.split('\n').filter(Boolean);
308
+ return NO_TMUX_SERVER.test(out.error) ? [] : null;
309
+ };
310
+ exports.readPaneCwds = readPaneCwds;
@@ -0,0 +1,254 @@
1
+ import { type TmuxInstallPlan } from './tmux-install.js';
2
+ import type { RemoteAgent } from './remote-agents.js';
3
+ /** basename(), with a stable fallback for edge paths like `/`. */
4
+ export declare const safeBasename: (path: string) => string;
5
+ /** The directory the session is named for: env > git root > cwd. Null when git is the answer and there is no repo. */
6
+ export declare const detectProjectDir: () => string;
7
+ /** Resolve a project name for the tmux session: env > git root > cwd basename. */
8
+ export declare const detectProjectName: () => string;
9
+ /**
10
+ * tmux commands, chained after `new`, that tell the listener which project
11
+ * the session belongs to when its name does not say so: a `--label` tail and a
12
+ * worktree's directory name both read as part of the project otherwise, and
13
+ * each opened a card of its own. Nothing when the name already says it all, so
14
+ * a plain family session is reported exactly as before. The rule is config
15
+ * `groupOf`, which the listener also applies to sessions started without these.
16
+ *
17
+ * Each option names its session (`-t =<session>:`). An untargeted `set-option`
18
+ * applies to the client's current session, and a launch from inside tmux (a
19
+ * `--detach` from an agent's pane) would relabel the caller instead of the new
20
+ * session (measured on tmux 3.5a).
21
+ */
22
+ export declare const sessionSidecarArgs: (session: string, projectDir: string, label?: string) => string[];
23
+ /**
24
+ * tmux commands, chained after `new -A`, that point config
25
+ * `HERDR_SESSION_OPTIONS` at the herdr pane this attach runs in, or clear them
26
+ * outside herdr.
27
+ */
28
+ export declare const herdrSessionArgs: (session: string, env?: NodeJS.ProcessEnv) => string[];
29
+ /**
30
+ * `hwip-<project>` — the canonical tmux session base name. tmux itself turns
31
+ * `.` and `:` into `_` when it creates a session (3.5a: `hwip-foo.bar` becomes
32
+ * `hwip-foo_bar`), so the name is written that way up front: every later
33
+ * `-t =<name>` must match what tmux actually holds. `;` goes too — an argv
34
+ * element ending in one is a tmux command separator.
35
+ */
36
+ export declare const tmuxSessionName: (project: string) => string;
37
+ /** The first name in the family no live session holds — for a launch that can only create. */
38
+ export declare const firstFreeSession: (base: string) => string;
39
+ /**
40
+ * Pick a tmux session name that won't steal focus from another live
41
+ * `hwip cc`. Strategy:
42
+ * - Reuse the **highest-numbered detached** session in the family.
43
+ * - Otherwise take the lowest name that doesn't exist yet (create new),
44
+ * so an all-attached family gets an independent `<base>-N`.
45
+ *
46
+ * Highest-detached rather than lowest is what keeps the family reachable.
47
+ * The old scan walked the family in order and returned the first session that
48
+ * was missing *or* detached, which meant a detached `<base>` ended the scan
49
+ * every time: `<base>-2` could not be reached by any later `hwip cc`, not even
50
+ * after `<base>` was used and exited (a missing `<base>` is taken fresh). A
51
+ * session nobody can reach still holds a live agent — a Claude Code CLI plus
52
+ * its MCP fleet, 250-550MB measured — so stranding one leaks memory the user
53
+ * can neither see nor reclaim. Draining from the tail makes every suffix
54
+ * reachable again: the tail goes first, and `<base>` is picked on the run
55
+ * after that. Reuse also wins over a gap in the numbering, for the same
56
+ * reason — a free `<base>-2` must not hide a detached `<base>-3`.
57
+ *
58
+ * Reclaiming is deliberately all this does. Killing a detached session would
59
+ * take a live agent with it, and nothing here can tell "the user is done with
60
+ * this" from "the user closed the terminal and will come back".
61
+ */
62
+ export declare const findAvailableSession: (base: string,
63
+ /** The agent about to be started. A detached session that is running a
64
+ * different one is not a slot for it — see `sameAgent` below. Omitted by
65
+ * callers that have no kind to match, which keeps the old behaviour. */
66
+ agentKind?: string,
67
+ /** Test seam: what this machine recorded about a session name. */
68
+ recall?: (name: string) => {
69
+ agentKind: string;
70
+ } | null) => string;
71
+ export interface SpawnTarget {
72
+ /**
73
+ * `tmux-new` opens (or reattaches) a session and needs a terminal to
74
+ * attach to; `direct` runs the agent in the pane we are already in and
75
+ * does not. Several decisions downstream turn on which one this is, so
76
+ * `targetForAgent` states it rather than letting them re-derive it from
77
+ * `cmd === 'tmux'`.
78
+ */
79
+ kind: 'direct' | 'tmux-new' | 'tmux-detached';
80
+ cmd: string;
81
+ args: string[];
82
+ /** The tmux session name for the two tmux kinds — carried, not re-read off argv. */
83
+ session?: string;
84
+ }
85
+ /**
86
+ * `hwip <agent> --detach --label <x> --wt <name> …` — the wrapper-owned flags. They
87
+ * are read off the FRONT of the passthrough only, so an agent's own flags
88
+ * (`pi -n`, `claude --resume`) and its positional messages pass untouched.
89
+ */
90
+ export interface AgentLaunchOptions {
91
+ /** Create the tmux session without attaching, print its name, exit 0. */
92
+ detach?: boolean;
93
+ /** Name the session `hwip-<project>-<label>` instead of the `-2/-3` family. */
94
+ label?: string;
95
+ /** `--wt <name>`: run in the git worktree for this branch, made next to the repo if it is not there. */
96
+ worktree?: string;
97
+ /** Set by `handleAgentSession` once the worktree exists — not a flag. The directory the session is named for, in place of `detectProjectDir()`. */
98
+ projectDir?: string;
99
+ }
100
+ /**
101
+ * tmux forbids `.` and `:` in session names; whitespace would split the target;
102
+ * and an argv element ending in `;` is a tmux command separator.
103
+ */
104
+ export declare const sanitizeLabel: (label: string) => string;
105
+ export declare const splitAgentOptions: (extra: string[]) => {
106
+ opts: AgentLaunchOptions;
107
+ rest: string[];
108
+ error?: string;
109
+ };
110
+ export declare const targetForAgent: (agent: string, extra: string[], agentKind?: string, opts?: AgentLaunchOptions) => SpawnTarget;
111
+ /**
112
+ * Whether the daemon on record is running a different build than the one
113
+ * we're launching from. `npm i -g @hwip/cli` swaps dist/ but leaves the
114
+ * live process untouched, so a daemon can stay days behind the installed
115
+ * package — answering pushes (chat looks fine) while silently ignoring every
116
+ * message subtype added since it booted. That is exactly how a pre-1.24
117
+ * listener left the phone's live terminal spinning until a reboot.
118
+ *
119
+ * A missing stamp means the daemon predates version stamping, which puts it
120
+ * behind by definition — treat unknown as drifted.
121
+ *
122
+ * Strictly "installed is newer", never a plain inequality: one account can
123
+ * have several installs at different versions (nvm node versions, a repo
124
+ * build alongside the global one), and `!==` would make each `hwip cc` kill
125
+ * and respawn the other's daemon forever. An older `hwip cc` leaves a newer
126
+ * daemon alone — downgrading it would reintroduce the very gap this closes.
127
+ */
128
+ export declare const listenerVersionDrifted: (running: string | null, installed: string) => boolean;
129
+ /**
130
+ * Whether the listener started this process for a schedule (`LISTENER_LAUNCH_ENV`), and the marker used up: left in the
131
+ * 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.
132
+ */
133
+ export declare const takeListenerLaunchMarker: () => boolean;
134
+ /**
135
+ * Make sure the phone-bridge daemon is running AND is the build we just
136
+ * launched from. `hwip cc` is the right moment to replace a drifted one: the
137
+ * user is starting fresh work rather than mid-task, so the ~1s restart window
138
+ * (in which a push would be dropped — WS fan-out has no queue) costs nothing.
139
+ */
140
+ export declare const ensureListenerRunning: (launchedByListener?: boolean) => Promise<void>;
141
+ /**
142
+ * Launch the agent in a named tmux session (or directly if nested) and
143
+ * forward its exit code. `extra` is appended to the agent invocation, so
144
+ * `hwip cc --resume foo` runs `claude --resume foo` inside the session.
145
+ * Returns when the agent exits.
146
+ */
147
+ /** `process.execve` with the optionality resolved — see handOffExec. */
148
+ export type ProcessHandOff = NonNullable<typeof process.execve>;
149
+ /** The hand-off handOffExec returns: execve with the environment already bound. */
150
+ type HandOff = (file: string, argv: string[]) => never;
151
+ /**
152
+ * The exec that replaces this process with the child, or null when the wrapper
153
+ * has to stay and wait instead. Returning the function rather than a boolean
154
+ * keeps one check where two would otherwise drift: the platform gate and the
155
+ * policy are the same answer.
156
+ *
157
+ * `process.execve` is declared optional by @types/node, and is absent on node
158
+ * <22.15 (added there) and on Windows (its doc: "not available on Windows or
159
+ * IBM i"), so its presence covers both without a version check. `engines.node`
160
+ * still allows 20.x, 22.13–22.14 and 23.5–23.10 (added in 22.15 and 23.11, per
161
+ * the Node docs), so the spawn path below is not a transitional fallback — it
162
+ * stays.
163
+ *
164
+ * `isTTY` is `undefined` rather than `false` on a pipe, hence every `=== true`.
165
+ *
166
+ * The environment is bound here rather than left to execve's default: node
167
+ * 22.15.1 execs with an empty environment when the argument is omitted
168
+ * (measured — 22.23.2 and 24.x inherit process.env), and a tmux started without
169
+ * TERM dies with "open terminal failed: terminal does not support clear".
170
+ */
171
+ export declare const handOffExec: (deps: {
172
+ execve: ProcessHandOff | undefined;
173
+ env: NodeJS.ProcessEnv;
174
+ kind: SpawnTarget["kind"];
175
+ stdinTTY: boolean | undefined;
176
+ stdoutTTY: boolean | undefined;
177
+ }) => HandOff | null;
178
+ /**
179
+ * Inside tmux the agent runs in the current pane, and the listener reports
180
+ * only `hwip-*` sessions — so unless this pane already sits in one, the phone
181
+ * never sees the agent. Say so, and name the two ways to a session it does
182
+ * see. `session` is this pane's tmux session, null when tmux would not say.
183
+ */
184
+ export declare const inPaneNote: (subcommand: string, session: string | null) => string | undefined;
185
+ /**
186
+ * What to say when tmux is missing — the install hint `hwip install` gives,
187
+ * not just "not found". Undefined when tmux is there. Exported for tests.
188
+ */
189
+ export declare const tmuxMissingMessage: (command: string, plan: TmuxInstallPlan) => string | undefined;
190
+ /** What `handleAgentSession` does to the machine, swappable in tests. */
191
+ export interface AgentSessionDeps {
192
+ startListener?: () => Promise<void>;
193
+ launch?: (target: SpawnTarget, what: string) => Promise<number>;
194
+ }
195
+ export declare const handleAgentSession: (agent: RemoteAgent, extra?: string[], opts?: AgentLaunchOptions, deps?: AgentSessionDeps) => Promise<number>;
196
+ /** What tmux holds under a `hwip sh` session name. */
197
+ export type ShellSessionState = 'none' | 'marked' | 'unmarked';
198
+ /** A tmux call, injectable for tests. */
199
+ type RunTmux = (args: string[]) => {
200
+ status: number | null;
201
+ stdout: string;
202
+ stderr?: string;
203
+ };
204
+ /**
205
+ * How `hwip sh` proceeds, given what tmux holds under the name. `create` is
206
+ * the detached target, `attach` the attaching one (null for `--detach`).
207
+ *
208
+ * A missing session is created with `new -d` and `@hwip_shell` set to its own
209
+ * name in the same tmux call, so no sweep ever sees it unmarked, and `new -d`
210
+ * fails on a taken name: a session that appeared since the probe is never
211
+ * attached to and marked. The listener accepts the marker only when it names
212
+ * the session, so a stray `set -g @hwip_shell …` cannot mark anything. A
213
+ * session this command opened before is reattached (or, detached, left as it
214
+ * is — running `!hwip sh --detach` twice from the phone must not fail). Any
215
+ * other session under that name is refused: the marker would turn it into a
216
+ * typable shell.
217
+ */
218
+ export declare const planShellSession: (create: SpawnTarget, attach: SpawnTarget | null, state: ShellSessionState) => {
219
+ kind: "create";
220
+ args: string[];
221
+ attach: SpawnTarget | null;
222
+ } | {
223
+ kind: "attach";
224
+ target: SpawnTarget;
225
+ } | {
226
+ kind: "running";
227
+ } | {
228
+ kind: "refuse";
229
+ reason: string;
230
+ };
231
+ /**
232
+ * Read the marker off the session itself. `show-options` without `-A` shows
233
+ * only what is set on that session, never an inherited global value. A failed
234
+ * `has-session` reads as "none", which is safe: creation is `new -d`, and it
235
+ * fails on a name that does exist.
236
+ */
237
+ export declare const readShellSessionState: (session: string, run?: RunTmux) => ShellSessionState;
238
+ /**
239
+ * The two targets `hwip sh` works with. Inside tmux there is no attaching —
240
+ * `new -A` would nest and exit 1 after the session already exists — so the
241
+ * run is a detached one, as `--detach` is.
242
+ */
243
+ export declare const shellTargets: (shell: string, opts: AgentLaunchOptions, inTmux?: boolean) => {
244
+ create: SpawnTarget;
245
+ attach: SpawnTarget | null;
246
+ };
247
+ /**
248
+ * `hwip sh [--label x] [--detach]` — open (or reattach) `hwip-<project>-sh`
249
+ * running the login shell, marked so the phone may type into it. The label
250
+ * always pins a tmux session, so this never runs in the current pane.
251
+ */
252
+ export declare const handleShellSession: (opts: AgentLaunchOptions) => Promise<number>;
253
+ export {};
254
+ //# sourceMappingURL=wrapper.d.ts.map