@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,327 @@
1
+ "use strict";
2
+ /**
3
+ * Reading a session's repository for the phone's file view (F5): the one place
4
+ * the working tree is opened for it. Design: hwip `docs/file-view.md` (V1~V14,
5
+ * D1~D13); this module is V6, V7, V9, V10, V11, D4 and D5.
6
+ *
7
+ * What a path may be and what the phone may see of the tree is decided here and
8
+ * nowhere else. Nothing here knows about sealing, the wire or who asked — the
9
+ * listener decides that; this decides what is read and what is never opened.
10
+ *
11
+ * git is run only through `gitIn`, with constant arguments. What this module adds
12
+ * is `ls-files` (twice) for the visible set and `check-attr` (through `filteredPaths`)
13
+ * for `filter=`; `resolveBase` and `filteredPaths` bring their own `rev-parse` and
14
+ * `hash-object`. None of them runs a filter. One caller path goes to git, after `--`.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.readBlob = exports.listTree = exports.visibleSet = exports.isViewablePath = exports.FILE_VIEW_MAX_BYTES = exports.TREE_PAGE_MAX_ENTRIES = void 0;
18
+ const buffer_1 = require("buffer");
19
+ const fs_1 = require("fs");
20
+ const path_1 = require("path");
21
+ const secret_mask_js_1 = require("./secret-mask.js");
22
+ const session_diff_js_1 = require("./session-diff.js");
23
+ /** The longest path, in UTF-8 bytes, the phone may name. */
24
+ const PATH_MAX_BYTES = 1024;
25
+ /** Most entries in one page of a directory listing. */
26
+ exports.TREE_PAGE_MAX_ENTRIES = 500;
27
+ /** A file past this is not read. Same value as an untracked file's line count limit in the diff. */
28
+ exports.FILE_VIEW_MAX_BYTES = session_diff_js_1.UNTRACKED_COUNT_MAX_BYTES;
29
+ /** A NUL in this many leading bytes makes a file binary — the length git itself looks at. */
30
+ const BINARY_PROBE_BYTES = 8000;
31
+ const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
32
+ /**
33
+ * V7 ①, the string rules. A path is repo-relative and names one thing: not empty
34
+ * (a directory request's `""` for the root is the tree handler's to allow), no NUL
35
+ * or other control character, not absolute, at most 1,024 UTF-8 bytes, and no
36
+ * segment that is empty, `.`, `..` or — at any depth, in any case — `.git` (a
37
+ * case-insensitive file system opens `.GIT` as `.git`, D11). A leading `-` is fine:
38
+ * the path only ever follows `--`.
39
+ */
40
+ const isViewablePath = (path) => {
41
+ if (path === '' || path.startsWith('/') || CONTROL_CHARACTER.test(path))
42
+ return false;
43
+ if (buffer_1.Buffer.byteLength(path, 'utf-8') > PATH_MAX_BYTES)
44
+ return false;
45
+ return path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..' && segment.toLowerCase() !== '.git');
46
+ };
47
+ exports.isViewablePath = isViewablePath;
48
+ /** U+FFFD is what `gitIn` makes of a byte that is not UTF-8 (O1): such a name cannot be matched byte for byte. */
49
+ const REPLACEMENT_CHARACTER = '\uFFFD';
50
+ /**
51
+ * V6, the visible set: what git lists as tracked (`ls-files --stage`) plus what it lists as
52
+ * untracked and not ignored (`ls-files --others --exclude-standard`), under `scope` (the
53
+ * whole repository when absent). An ignored path is simply not there, so asking for it by
54
+ * name answers like a path that does not exist. The caller matches a path against the keys
55
+ * exactly; the pathspec only narrows what git has to print. A path the phone could never
56
+ * ask for (`isViewablePath`) or whose bytes are not UTF-8 is left out, so a listing never
57
+ * shows a name that cannot be opened.
58
+ */
59
+ const visibleSet = (root, scope, deadline = (0, session_diff_js_1.newDeadline)()) => {
60
+ const pathspec = scope === undefined ? [] : ['--', scope];
61
+ const tracked = (0, session_diff_js_1.gitIn)(root, ['ls-files', '-z', '--stage', ...pathspec], deadline);
62
+ if (tracked.status !== 0)
63
+ return { ok: false, error: tracked.tooLarge ? 'too_large' : 'git_failed' };
64
+ const untracked = (0, session_diff_js_1.gitIn)(root, ['ls-files', '-z', '--others', '--exclude-standard', ...pathspec], deadline);
65
+ if (untracked.status !== 0)
66
+ return { ok: false, error: untracked.tooLarge ? 'too_large' : 'git_failed' };
67
+ const entries = new Map();
68
+ const admit = (path, entry) => {
69
+ if (path.includes(REPLACEMENT_CHARACTER) || !(0, exports.isViewablePath)(path))
70
+ return;
71
+ // A path in conflict is listed once per stage; the first row stands.
72
+ if (!entries.has(path))
73
+ entries.set(path, entry);
74
+ };
75
+ for (const record of tracked.stdout.split('\0')) {
76
+ // `<mode> <oid> <stage>\t<path>`
77
+ if (!record)
78
+ continue;
79
+ const tab = record.indexOf('\t');
80
+ // A row of another shape means git's output is not what this reads: say so, not "nothing is visible".
81
+ if (tab < 0)
82
+ return { ok: false, error: 'git_failed' };
83
+ const [mode, oid] = record.slice(0, tab).split(' ');
84
+ admit(record.slice(tab + 1), { mode, oid });
85
+ }
86
+ for (const record of untracked.stdout.split('\0')) {
87
+ if (!record)
88
+ continue;
89
+ if (record.endsWith('/'))
90
+ admit(record.slice(0, -1), { nested: true });
91
+ else
92
+ admit(record, {});
93
+ }
94
+ return { ok: true, entries };
95
+ };
96
+ exports.visibleSet = visibleSet;
97
+ /** The errno of a file-system call that failed because nothing is there, or what is there cannot be followed. */
98
+ const isGone = (e) => e instanceof Error && 'code' in e && (e.code === 'ENOENT' || e.code === 'ENOTDIR' || e.code === 'ELOOP');
99
+ /**
100
+ * V7 ③: no directory of `dir` (a repo-relative path, `""` for none) is a symlink, and each is a directory. git does
101
+ * not track through a symlink, but a tracked directory can be replaced by one after the
102
+ * index was written. Walked a segment at a time with `lstat`, so a root that itself sits
103
+ * under a symlink (`/tmp` → `/private/tmp`) and a case-insensitive file system make no
104
+ * difference. A segment that does not exist ends the walk with a yes: there is nothing
105
+ * to follow there (a deleted file stays in the index until it is committed). null when the
106
+ * file system failed in some other way (permissions, I/O): that is not an answer about symlinks.
107
+ */
108
+ const reachesWithoutSymlink = (root, dir) => {
109
+ let at = root;
110
+ for (const segment of dir === '' ? [] : dir.split('/')) {
111
+ at = (0, path_1.join)(at, segment);
112
+ try {
113
+ if (!(0, fs_1.lstatSync)(at).isDirectory())
114
+ return false;
115
+ }
116
+ catch (e) {
117
+ return isGone(e) ? true : null;
118
+ }
119
+ }
120
+ return true;
121
+ };
122
+ /** `a/b/c` → `a/b`; `c` → `""`. */
123
+ const parentOf = (path) => path.slice(0, Math.max(0, path.lastIndexOf('/')));
124
+ const entryTypeOf = (entry) => {
125
+ if (entry.nested || entry.mode === '160000')
126
+ return 'submodule';
127
+ return entry.mode === '120000' ? 'symlink' : 'file';
128
+ };
129
+ const utf8 = (text) => buffer_1.Buffer.from(text, 'utf-8');
130
+ /**
131
+ * One page of the entries directly under `dir` (`""` is the repository root), by
132
+ * name in UTF-8 byte order. `cursor` is the last name of the page before; the page is
133
+ * at most `TREE_PAGE_MAX_ENTRIES` and `truncated` says there is more. A directory is
134
+ * only a name some visible path continues under, so an empty one, an ignored one and
135
+ * one that is not there are all `absent`.
136
+ */
137
+ const listTree = (root, dir, cursor, deadline = (0, session_diff_js_1.newDeadline)()) => {
138
+ if (dir !== '' && !(0, exports.isViewablePath)(dir))
139
+ return { ok: false, error: 'bad_path' };
140
+ const set = (0, exports.visibleSet)(root, dir === '' ? undefined : dir, deadline);
141
+ if (!set.ok)
142
+ return set;
143
+ const prefix = dir === '' ? '' : `${dir}/`;
144
+ const found = new Map();
145
+ for (const [path, entry] of set.entries) {
146
+ if (!path.startsWith(prefix))
147
+ continue;
148
+ const rest = path.slice(prefix.length);
149
+ const slash = rest.indexOf('/');
150
+ if (slash < 0)
151
+ found.set(rest, entryTypeOf(entry));
152
+ else
153
+ found.set(rest.slice(0, slash), 'dir');
154
+ }
155
+ if (dir !== '' && found.size === 0)
156
+ return { ok: true, body: 'absent' };
157
+ const clear = reachesWithoutSymlink(root, dir);
158
+ if (clear === null)
159
+ return { ok: false, error: 'git_failed' };
160
+ if (!clear)
161
+ return { ok: true, body: 'absent' };
162
+ // Each name's bytes are made once: the sort would otherwise make two buffers per comparison.
163
+ const cursorBytes = cursor === undefined ? undefined : utf8(cursor);
164
+ const after = [...found]
165
+ .map(([name, type]) => ({ name, type, bytes: utf8(name) }))
166
+ .sort((a, b) => buffer_1.Buffer.compare(a.bytes, b.bytes))
167
+ .filter((entry) => cursorBytes === undefined || buffer_1.Buffer.compare(entry.bytes, cursorBytes) > 0);
168
+ const page = after.slice(0, exports.TREE_PAGE_MAX_ENTRIES).map(({ name, type }) => ({ name, type }));
169
+ const truncated = after.length > page.length;
170
+ return {
171
+ ok: true,
172
+ body: 'entries',
173
+ entries: page,
174
+ truncated,
175
+ ...(truncated ? { cursor: page[page.length - 1].name } : {}),
176
+ };
177
+ };
178
+ exports.listTree = listTree;
179
+ /**
180
+ * D4: names that stay unread even when a repository tracks them. The list is code, not
181
+ * configuration. Compared on the last segment, ignoring case (the file systems that
182
+ * open `.GIT` as `.git` open `.ENV` as `.env`).
183
+ */
184
+ const BLOCKED_NAME = /^(?:\.env(?:\..*)?|.*\.(?:pem|key|p12|pfx|keystore)|id_rsa.*|id_ed25519.*|credentials.*)$/;
185
+ const ALLOWED_ENV_NAMES = new Set(['.env.example', '.env.sample']);
186
+ const isBlockedName = (path) => {
187
+ const name = (path.split('/').pop() ?? '').toLowerCase();
188
+ return !ALLOWED_ENV_NAMES.has(name) && BLOCKED_NAME.test(name);
189
+ };
190
+ const ABSENT = { ok: true, body: 'absent' };
191
+ const FILTERED = { ok: true, body: 'filtered' };
192
+ /** Is `path` still, right now, a regular file with the identity of `opened`? */
193
+ const sameFile = (path, opened) => {
194
+ try {
195
+ const now = (0, fs_1.lstatSync)(path, { bigint: true });
196
+ return now.isFile() && now.dev === opened.dev && now.ino === opened.ino;
197
+ }
198
+ catch (e) {
199
+ // Gone is a swap (false); anything else is the file system failing, which the caller's catch reports.
200
+ if (isGone(e))
201
+ return false;
202
+ throw e;
203
+ }
204
+ };
205
+ /** Read up to `limit` bytes from the start of `fd`; fewer when the file ends first. */
206
+ const readUpTo = (fd, limit) => {
207
+ const buffer = buffer_1.Buffer.alloc(limit);
208
+ let filled = 0;
209
+ while (filled < limit) {
210
+ const n = (0, fs_1.readSync)(fd, buffer, filled, limit - filled, filled);
211
+ if (n === 0)
212
+ break;
213
+ filled += n;
214
+ }
215
+ return buffer.subarray(0, filled);
216
+ };
217
+ /**
218
+ * What a file looks like to the phone. The order is the design's V7: ① the string rules
219
+ * (`bad_path`) · ② the visible set, exact match · ③ no symlink on the way · ④ `lstat`: a
220
+ * symlink is shown as its target string and never followed, a gitlink or nested repository
221
+ * is shown as a submodule and never entered, anything but a regular file is `absent` ·
222
+ * D4 and V9 (`filter=`) say `filtered` · ⑤ open with `O_NOFOLLOW | O_NONBLOCK`, `fstat`
223
+ * the descriptor and compare it with ④ — a different file is `absent`, a hard link is
224
+ * `filtered` (D5) · V11: past 1 MiB is `too_large`, a NUL in the first 8,000 bytes is
225
+ * `binary` · V10: the whole text is masked before the caller pages it. A path outside the
226
+ * set, ignored, deleted or swapped is `absent`, never an error: the phone cannot tell them apart.
227
+ */
228
+ const readBlob = (root, path, options = {}) => {
229
+ if (!(0, exports.isViewablePath)(path))
230
+ return { ok: false, error: 'bad_path' };
231
+ const deadline = options.deadline ?? (0, session_diff_js_1.newDeadline)();
232
+ const set = (0, exports.visibleSet)(root, path, deadline);
233
+ if (!set.ok)
234
+ return set;
235
+ const entry = set.entries.get(path);
236
+ if (!entry)
237
+ return ABSENT;
238
+ const parent = parentOf(path);
239
+ const clear = reachesWithoutSymlink(root, parent);
240
+ if (clear === null)
241
+ return { ok: false, error: 'git_failed' };
242
+ if (!clear)
243
+ return ABSENT;
244
+ if (entry.nested)
245
+ return { ok: true, body: 'submodule' };
246
+ if (entry.mode === '160000')
247
+ return { ok: true, body: 'submodule', commit: entry.oid };
248
+ const full = (0, path_1.join)(root, path);
249
+ options.beforeLstat?.();
250
+ let seen;
251
+ try {
252
+ // bigint: an inode past 2^53 (FUSE, NFS) would otherwise compare equal to its neighbour.
253
+ seen = (0, fs_1.lstatSync)(full, { bigint: true });
254
+ }
255
+ catch (e) {
256
+ return isGone(e) ? ABSENT : { ok: false, error: 'git_failed' };
257
+ }
258
+ if (seen.isSymbolicLink()) {
259
+ try {
260
+ return { ok: true, body: 'symlink', target: (0, fs_1.readlinkSync)(full) };
261
+ }
262
+ catch (e) {
263
+ // EINVAL: it stopped being a symlink since the lstat — a swap, like a vanished file.
264
+ const swapped = isGone(e) || (e instanceof Error && 'code' in e && e.code === 'EINVAL');
265
+ return swapped ? ABSENT : { ok: false, error: 'git_failed' };
266
+ }
267
+ }
268
+ if (!seen.isFile())
269
+ return ABSENT;
270
+ if (isBlockedName(path))
271
+ return FILTERED;
272
+ const base = (0, session_diff_js_1.resolveBase)(root, 'head', deadline);
273
+ if (!base.ok)
274
+ return base;
275
+ const filtered = (0, session_diff_js_1.filteredPaths)(root, [path], base.rev, deadline);
276
+ if (!filtered)
277
+ return { ok: false, error: 'git_failed' };
278
+ if (filtered.size > 0)
279
+ return FILTERED;
280
+ options.beforeOpen?.();
281
+ let fd;
282
+ try {
283
+ fd = (0, fs_1.openSync)(full, fs_1.constants.O_RDONLY | fs_1.constants.O_NOFOLLOW | fs_1.constants.O_NONBLOCK);
284
+ }
285
+ catch (e) {
286
+ // ELOOP is `O_NOFOLLOW` catching a symlink put there since the lstat; running out of descriptors is not "absent".
287
+ return isGone(e) ? ABSENT : { ok: false, error: 'git_failed' };
288
+ }
289
+ let bytes;
290
+ try {
291
+ const opened = (0, fs_1.fstatSync)(fd, { bigint: true });
292
+ if (!opened.isFile() || opened.dev !== seen.dev || opened.ino !== seen.ino)
293
+ return ABSENT;
294
+ // The walk and the `lstat` ran before the open; a directory swapped for a symlink in between would
295
+ // have made `seen` the outside file's. The descriptor is the one fixed thing: ask again, and the
296
+ // swap has to be undone and redone inside this window to pass.
297
+ const stillClear = reachesWithoutSymlink(root, parent);
298
+ if (stillClear === null)
299
+ return { ok: false, error: 'git_failed' };
300
+ if (!stillClear || !sameFile(full, opened))
301
+ return ABSENT;
302
+ if (opened.nlink !== 1n)
303
+ return FILTERED;
304
+ if (opened.size > BigInt(exports.FILE_VIEW_MAX_BYTES))
305
+ return { ok: false, error: 'too_large' };
306
+ // One byte past the limit: a file that grew after `fstat` is still caught.
307
+ bytes = readUpTo(fd, exports.FILE_VIEW_MAX_BYTES + 1);
308
+ }
309
+ catch {
310
+ // The file system failed under an open descriptor (EIO and the like): nothing was read.
311
+ return { ok: false, error: 'git_failed' };
312
+ }
313
+ finally {
314
+ try {
315
+ (0, fs_1.closeSync)(fd);
316
+ }
317
+ catch {
318
+ // intentionally ignored: the bytes (or the answer) are already in hand; a failed close of a read-only descriptor loses nothing.
319
+ }
320
+ }
321
+ if (bytes.length > exports.FILE_VIEW_MAX_BYTES)
322
+ return { ok: false, error: 'too_large' };
323
+ if (bytes.subarray(0, BINARY_PROBE_BYTES).includes(0))
324
+ return { ok: true, body: 'binary', size: bytes.length };
325
+ return { ok: true, body: 'text', ...(0, secret_mask_js_1.maskText)(bytes.toString('utf-8')) };
326
+ };
327
+ exports.readBlob = readBlob;
package/dist/gate.d.ts ADDED
@@ -0,0 +1,141 @@
1
+ export type GateMarker = 'skip' | 'push' | 'high' | 'none';
2
+ export type GatePushMode = 'quiet' | 'loud' | 'normal';
3
+ export interface GateInput {
4
+ /** Total tool_use blocks this turn. */
5
+ toolCount: number;
6
+ /** Tools that are NOT read-only (Read/Grep/Glob). */
7
+ nonReadonlyCount: number;
8
+ /** A hwip_ask already notified this turn. */
9
+ alreadyAsked: boolean;
10
+ marker: GateMarker;
11
+ pushMode: GatePushMode;
12
+ /** The user is away from the terminal (presence.ts awayForGate) — lets quiet
13
+ * push. Optional so existing callers keep compiling; absent = present,
14
+ * matching the bash twin's missing 6th argument. */
15
+ away?: boolean;
16
+ }
17
+ export interface GateVerdict {
18
+ push: boolean;
19
+ priority: 'high' | 'normal';
20
+ }
21
+ /**
22
+ * "Assume real work" defaults for hooks that can't supply turn facts
23
+ * (most non-Claude agents pass no counts): in normal mode the push still
24
+ * fires — preserving the historical always-push behavior of the dumb
25
+ * hooks — while quiet/loud now work everywhere.
26
+ *
27
+ * These defaults cannot rescue a quiet dial: quiet only lets a `high` marker
28
+ * or an away user (presence.ts) through, and a hook with no turn facts has no
29
+ * marker either. That is why the installed templates pass `--pushmode-default
30
+ * normal` (see templates.ts) — for them quiet is not a lower volume, it is
31
+ * silence while the user is at the terminal.
32
+ */
33
+ export declare const GATE_DEFAULTS: {
34
+ readonly toolCount: 2;
35
+ readonly nonReadonlyCount: 1;
36
+ readonly alreadyAsked: false;
37
+ };
38
+ export declare const normalizeMarker: (raw: string | undefined) => GateMarker;
39
+ export declare const normalizePushMode: (raw: string | undefined) => GatePushMode;
40
+ export declare const decidePush: (input: GateInput) => GateVerdict;
41
+ export declare const stateDir: () => string;
42
+ /**
43
+ * The first field of POSIX `cksum` over `data`: the CRC of the bytes, then of
44
+ * the length (low byte first, no trailing zero bytes), complemented.
45
+ */
46
+ export declare const posixCksum: (data: Uint8Array) => number;
47
+ /** `cksum` of the project dir as the plugin computes it. */
48
+ export declare const projectHash: (dir: string) => string;
49
+ /** Marker path for a project hash: `<stateDir>/remote-<cksum(projectDir)>`. */
50
+ export declare const remoteMarkerPath: (hash: string) => string;
51
+ /**
52
+ * sha256 over the ASCII-only-trimmed text — recorded at inject time,
53
+ * recomputed at prompt submit. Trim strips ONLY ' \t\r\n\f\v', NOT
54
+ * String.trim(): trim() also eats Unicode whitespace (U+00A0 etc.) that
55
+ * the bash reader keeps, and both sides must hash identical bytes.
56
+ */
57
+ export declare const remoteDigest: (text: string) => string;
58
+ /**
59
+ * How long REMOTE stays live without a refresh. Generous on purpose: the state
60
+ * is refreshed on every phone prompt and every answered `hwip_ask`, so it only
61
+ * has to outlive a working session, never an idle user. It is the backstop and
62
+ * not the usual exit — a terminal-typed prompt, a Done-like answer and the
63
+ * model's `<!-- hwip: exit -->` all delete the file outright. What none of them
64
+ * covers is a session that simply died: there is no SessionEnd hook, so without
65
+ * a TTL a crash or Ctrl-C would latch REMOTE forever.
66
+ */
67
+ export declare const REMOTE_TTL_SEC = 14400;
68
+ /** State path for a project hash: `<stateDir>/remote-active-<cksum(dir)>`. */
69
+ export declare const remoteStatePath: (hash: string) => string;
70
+ /**
71
+ * True while REMOTE is live for this project. A time ahead of the clock counts
72
+ * as now (and is rewritten as now). State that is expired or
73
+ * unparseable is deleted on sight — the same housekeeping a stale entry
74
+ * marker gets, since state that can never flag again is dead weight.
75
+ */
76
+ export declare const isRemoteActive: (dir: string, now?: () => number) => boolean;
77
+ /**
78
+ * Enter REMOTE, or push its expiry back. Best-effort by contract: the prompt
79
+ * hooks that call this must never fail a prompt over state IO.
80
+ */
81
+ export declare const touchRemoteActive: (dir: string, now?: () => number) => void;
82
+ /**
83
+ * Leave REMOTE. Best-effort like its twin: an absent file is success, and the
84
+ * caller is a hook with its own job to finish. Bash twin: gate.sh
85
+ * hwip_remote_clear.
86
+ */
87
+ export declare const clearRemoteActive: (dir: string) => void;
88
+ /** True when the user ran /hwip-mode mute for this project. */
89
+ export declare const isMuted: (dir: string) => boolean;
90
+ /**
91
+ * Push mode for an install that has never set a dial. The twin is
92
+ * plugin/hooks/gate.sh's missing-5th-argument default; the shared vectors
93
+ * never reach either one (every vector passes pushMode explicitly), so both
94
+ * sides pin it in their own tests.
95
+ */
96
+ export declare const PUSHMODE_DEFAULT: GatePushMode;
97
+ /**
98
+ * `notify` flag naming the push mode to assume when the project has no dial.
99
+ * Written by templates.ts into every hook-driven agent's completion hook and
100
+ * read back in cli.ts — shared so the two can never drift apart.
101
+ */
102
+ export declare const PUSHMODE_DEFAULT_FLAG = "pushmode-default";
103
+ /**
104
+ * `notify` flags carrying the turn's tool counts. Only the drop-in artifacts
105
+ * (pi extension, opencode plugin) can supply them — they are the only installed
106
+ * hooks that see per-tool events. Shared for the same reason as the flag above:
107
+ * templates.ts writes them, cli.ts reads them, and a rename on one side alone
108
+ * would send `gateCount` back to GATE_DEFAULTS with nothing to show for it.
109
+ */
110
+ export declare const TOOL_COUNT_FLAG = "tools";
111
+ export declare const NONREADONLY_COUNT_FLAG = "nonreadonly";
112
+ /**
113
+ * The user's session push-mode dial (/hwip-mode quiet | /hwip-mode loud | /hwip-mode normal).
114
+ *
115
+ * Three failure shapes, three answers — "no dial" is the only one that gets
116
+ * the quiet default:
117
+ * - no dial file → `fallback` (PUSHMODE_DEFAULT unless the caller
118
+ * overrides it with --pushmode-default)
119
+ * - unusable dial file → normal. An unreadable
120
+ * file, or a garbled/empty value is a broken
121
+ * setting, and resolving breakage to silence
122
+ * leaves the user with no symptom to debug. The
123
+ * point of the new default is quiet, not hidden
124
+ * errors.
125
+ * - readable dial file → whatever it says.
126
+ */
127
+ export declare const readPushMode: (dir: string, fallback?: GatePushMode) => GatePushMode;
128
+ /**
129
+ * Push mode for a `--auto` notify: the user's dial if they set one, otherwise
130
+ * the mode named by `--pushmode-default`, otherwise the built-in quiet.
131
+ *
132
+ * The dial outranks the flag deliberately. The flag exists so an agent whose
133
+ * hook cannot participate in the heuristic still pushes out of the box; if it
134
+ * outranked the dial, `/hwip-mode quiet` would silently do nothing for that agent.
135
+ *
136
+ * A flag value that isn't one of the three modes resolves to `normal`, not to
137
+ * the quiet default — same rule as a garbled dial file. A caller that passes
138
+ * nonsense has a bug, and answering a bug with silence hides it.
139
+ */
140
+ export declare const autoPushMode: (dir: string, flag: string | boolean | undefined) => GatePushMode;
141
+ //# sourceMappingURL=gate.d.ts.map