@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,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleAskContext = exports.runAskContext = exports.describeAskContext = exports.setAskContext = exports.askContextEnabled = exports.askContextOffBy = exports.askContextProjectDir = void 0;
4
+ const args_js_1 = require("./args.js");
5
+ const project_switch_js_1 = require("./project-switch.js");
6
+ const session_diff_js_1 = require("./session-diff.js");
7
+ const askContext = (0, project_switch_js_1.projectSwitch)('ask-context', 'HWIP_ASK_CONTEXT');
8
+ /** The project a question belongs to: the repository root, else the directory itself, so a question asked from a subfolder reads the same switch. */
9
+ exports.askContextProjectDir = project_switch_js_1.projectDirOf;
10
+ /** Why the context is off for this project, or `null` when it is on. `HWIP_ASK_CONTEXT=0` turns it off for one run. */
11
+ exports.askContextOffBy = askContext.offBy;
12
+ const askContextEnabled = (dir, env = process.env) => (0, exports.askContextOffBy)(dir, env) === null;
13
+ exports.askContextEnabled = askContextEnabled;
14
+ /** Turn the context on or off for this project, or for every project. Throws when the state file cannot be written or removed. */
15
+ exports.setAskContext = askContext.set;
16
+ const REASON = {
17
+ env: 'HWIP_ASK_CONTEXT is set to off',
18
+ project: 'turned off for this project',
19
+ global: 'turned off for every project',
20
+ unreadable: 'the switch file cannot be read, so it is treated as off',
21
+ };
22
+ const describeAskContext = (offBy) => offBy === null ? 'ask context: on' : `ask context: off (${REASON[offBy]})`;
23
+ exports.describeAskContext = describeAskContext;
24
+ const USAGE = 'Usage: hwip ask-context [status | on | off | show] [--global]';
25
+ const runAskContext = (sub, global, deps) => {
26
+ const word = sub ?? 'status';
27
+ if (word !== 'status' && word !== 'on' && word !== 'off') {
28
+ deps.err(`hwip ask-context: unknown subcommand '${word}'. ${USAGE}`);
29
+ return 2;
30
+ }
31
+ if (global && word === 'status') {
32
+ deps.err(`hwip ask-context: --global goes with on or off. ${USAGE}`);
33
+ return 2;
34
+ }
35
+ const dir = (0, exports.askContextProjectDir)(deps.cwd(), deps.repoRoot);
36
+ if (word !== 'status') {
37
+ try {
38
+ (0, exports.setAskContext)(dir, word === 'on', global ? 'global' : 'project');
39
+ }
40
+ catch (err) {
41
+ deps.err(`hwip ask-context: could not change the switch: ${err.message}`);
42
+ return 1;
43
+ }
44
+ }
45
+ deps.out((0, exports.describeAskContext)((0, exports.askContextOffBy)(dir, deps.env)));
46
+ return 0;
47
+ };
48
+ exports.runAskContext = runAskContext;
49
+ /**
50
+ * `show` prints, as one JSON line, what a question asked from this directory would carry: `{ context, off? }`
51
+ * (`collectAskContext`). It reads the switch itself, so a caller that only seals (mcp-server's `hwip_ask`) needs
52
+ * no switch logic of its own. Nothing is sent.
53
+ */
54
+ const handleAskContext = async (args) => {
55
+ if (args._arg1 === 'show') {
56
+ if ((0, args_js_1.flagOn)(args, 'global')) {
57
+ console.error(`hwip ask-context: --global goes with on or off. ${USAGE}`);
58
+ return 2;
59
+ }
60
+ const { collectAskContext } = await import('./ask-context-attach.js');
61
+ const line = `${JSON.stringify(await collectAskContext(process.cwd(), process.env))}\n`;
62
+ // The caller exits right after: a pipe on macOS is written asynchronously, and 64 KiB would be cut short.
63
+ await new Promise((resolve, reject) => process.stdout.write(line, (err) => (err ? reject(err) : resolve())));
64
+ return 0;
65
+ }
66
+ return (0, exports.runAskContext)(typeof args._arg1 === 'string' && args._arg1 ? args._arg1 : undefined, (0, args_js_1.flagOn)(args, 'global'), {
67
+ cwd: () => process.cwd(),
68
+ out: (line) => console.log(line),
69
+ err: (line) => console.error(line),
70
+ env: process.env,
71
+ repoRoot: session_diff_js_1.repoRootOf,
72
+ });
73
+ };
74
+ exports.handleAskContext = handleAskContext;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The context a sealed question could carry: what the session changed (git diff against HEAD) and what its
3
+ * terminal shows, masked and held to one size. Nothing here sends anything or touches a wire; a caller that
4
+ * seals the result owns that. The git half reuses `session-diff.ts` (which keeps `.gitignore`d files, filtered
5
+ * files and oversized trees out) and the masking half reuses `secret-mask.ts` — there is no git or mask logic
6
+ * of its own.
7
+ *
8
+ * Two parts, because they travel differently: `summary` is small and rides in a sealed body, `original` is the
9
+ * text itself and is sized for one sealed attachment.
10
+ */
11
+ import { type DiffFileEntry, type DiffResult } from './session-diff.js';
12
+ /** What `original` may weigh once serialized as JSON, in bytes. */
13
+ export declare const ASK_CONTEXT_MAX_BYTES: number;
14
+ /** Lines of the terminal screen that are kept (the last ones). */
15
+ export declare const ASK_CONTEXT_SCREEN_LINES = 60;
16
+ /** What the screen may weigh once serialized as JSON, in bytes. */
17
+ export declare const ASK_CONTEXT_SCREEN_MAX_BYTES: number;
18
+ /** Files the summary lists by name. */
19
+ export declare const ASK_CONTEXT_SUMMARY_FILES = 20;
20
+ /** What `summary` may weigh once serialized as JSON, in bytes; listed files past it are counted in `moreFiles` instead. */
21
+ export declare const ASK_CONTEXT_SUMMARY_MAX_BYTES: number;
22
+ /** Lines of the screen that are read and masked before the last ones are picked, so a key block that began above the picked lines still hides. */
23
+ export declare const ASK_CONTEXT_SCREEN_WINDOW_LINES = 500;
24
+ export interface AskContextSummary {
25
+ /** `ok` the diff half is there; `no_repo` the directory is in no repository; `failed` git could not answer. */
26
+ diff: 'ok' | 'no_repo' | 'failed';
27
+ files: number;
28
+ added: number;
29
+ removed: number;
30
+ listed: Array<Pick<DiffFileEntry, 'path' | 'status' | 'added' | 'removed' | 'binary'>>;
31
+ /** Files changed that are not in `listed`. */
32
+ moreFiles: number;
33
+ screenLines: number;
34
+ /** Secret-looking spots masked in the text that was read (a spot in a cut-off part counts too). */
35
+ masked: number;
36
+ /** Whether the diff / the screen holds less than there was (files left out, a patch cut, screen rows scrolled off or dropped). */
37
+ cut: {
38
+ diff: boolean;
39
+ screen: boolean;
40
+ };
41
+ }
42
+ export interface AskContext {
43
+ summary: AskContextSummary;
44
+ original: {
45
+ diff: string;
46
+ screen: string;
47
+ };
48
+ }
49
+ export interface AskContextInput {
50
+ files: DiffFileEntry[];
51
+ /** Git had more changed files than `files` holds. */
52
+ truncated?: boolean;
53
+ /** One file's masked patch; `null` when it could not be read. An empty text is a file with no patch (binary, filtered). */
54
+ patchOf: (path: string) => {
55
+ text: string;
56
+ masked: number;
57
+ } | null;
58
+ /** The terminal screen as captured, not yet masked. */
59
+ screen: string | null;
60
+ diff?: AskContextSummary['diff'];
61
+ }
62
+ /** What `original` weighs once serialized. */
63
+ export declare const askContextBytes: (ctx: Pick<AskContext, "original">) => number;
64
+ /** The context for the files changed and the screen shown. Pure: same input, same value, nothing is read or sent. */
65
+ export declare const composeAskContext: (input: AskContextInput) => AskContext;
66
+ export interface GatherDeps {
67
+ repoRootOf: (cwd: string) => string | null;
68
+ collectDiffFiles: (root: string, base: 'head') => DiffResult<{
69
+ files: DiffFileEntry[];
70
+ truncated: boolean;
71
+ }>;
72
+ collectFilePatch: (root: string, base: 'head', path: string) => DiffResult<{
73
+ text: string;
74
+ masked: number;
75
+ }>;
76
+ now: () => number;
77
+ }
78
+ /**
79
+ * The context of the session at `cwd`: the changes against HEAD (new files included) and `screen`. `null` when
80
+ * there is neither a change nor a screen — nothing to carry. A directory outside a repository, or a git that
81
+ * cannot answer, leaves the screen alone and says so in `summary.diff`.
82
+ */
83
+ export declare const gatherAskContext: (cwd: string, screen: string | null, overrides?: Partial<GatherDeps>) => AskContext | null;
84
+ //# sourceMappingURL=ask-context.d.ts.map
@@ -0,0 +1,172 @@
1
+ "use strict";
2
+ /**
3
+ * The context a sealed question could carry: what the session changed (git diff against HEAD) and what its
4
+ * terminal shows, masked and held to one size. Nothing here sends anything or touches a wire; a caller that
5
+ * seals the result owns that. The git half reuses `session-diff.ts` (which keeps `.gitignore`d files, filtered
6
+ * files and oversized trees out) and the masking half reuses `secret-mask.ts` — there is no git or mask logic
7
+ * of its own.
8
+ *
9
+ * Two parts, because they travel differently: `summary` is small and rides in a sealed body, `original` is the
10
+ * text itself and is sized for one sealed attachment.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.gatherAskContext = exports.composeAskContext = exports.askContextBytes = exports.ASK_CONTEXT_SCREEN_WINDOW_LINES = exports.ASK_CONTEXT_SUMMARY_MAX_BYTES = exports.ASK_CONTEXT_SUMMARY_FILES = exports.ASK_CONTEXT_SCREEN_MAX_BYTES = exports.ASK_CONTEXT_SCREEN_LINES = exports.ASK_CONTEXT_MAX_BYTES = void 0;
14
+ const session_diff_js_1 = require("./session-diff.js");
15
+ const secret_mask_js_1 = require("./secret-mask.js");
16
+ /** What `original` may weigh once serialized as JSON, in bytes. */
17
+ exports.ASK_CONTEXT_MAX_BYTES = 64 * 1024;
18
+ /** Lines of the terminal screen that are kept (the last ones). */
19
+ exports.ASK_CONTEXT_SCREEN_LINES = 60;
20
+ /** What the screen may weigh once serialized as JSON, in bytes. */
21
+ exports.ASK_CONTEXT_SCREEN_MAX_BYTES = 8 * 1024;
22
+ /** Files the summary lists by name. */
23
+ exports.ASK_CONTEXT_SUMMARY_FILES = 20;
24
+ /** What `summary` may weigh once serialized as JSON, in bytes; listed files past it are counted in `moreFiles` instead. */
25
+ exports.ASK_CONTEXT_SUMMARY_MAX_BYTES = 4 * 1024;
26
+ /** Longest path the summary shows, in bytes; a longer one is cut (the summary rides in a size-capped body). */
27
+ const SUMMARY_PATH_MAX_BYTES = 128;
28
+ /** Lines of the screen that are read and masked before the last ones are picked, so a key block that began above the picked lines still hides. */
29
+ exports.ASK_CONTEXT_SCREEN_WINDOW_LINES = 500;
30
+ /** How long gathering may spend asking git for patches. */
31
+ const GATHER_BUDGET_MS = 5_000;
32
+ const CUT_MARKER = '[hwip: cut]\n';
33
+ /** Bytes `text` takes inside a JSON string (quotes not counted): newlines, quotes and control characters cost more than one. */
34
+ const jsonBytes = (text) => Buffer.byteLength(JSON.stringify(text)) - 2;
35
+ /** What `original` weighs once serialized. */
36
+ const askContextBytes = (ctx) => Buffer.byteLength(JSON.stringify(ctx.original));
37
+ exports.askContextBytes = askContextBytes;
38
+ /** The last lines of a screen, masked and held to the screen budget. Blank rows under the last text are not lines. */
39
+ const composeScreen = (screen) => {
40
+ const rows = (screen ?? '').split('\n').map((row) => row.replace(/\r$/, ''));
41
+ while (rows.length > 0 && rows[rows.length - 1].trim() === '')
42
+ rows.pop();
43
+ // Mask the window as a whole before picking its last lines: the BEGIN of a key block may sit above them.
44
+ const masked = (0, secret_mask_js_1.maskText)(rows.slice(-exports.ASK_CONTEXT_SCREEN_WINDOW_LINES).join('\n'));
45
+ const kept = masked.text === '' ? [] : masked.text.split('\n').slice(-exports.ASK_CONTEXT_SCREEN_LINES);
46
+ let bytes = kept.reduce((sum, row) => sum + jsonBytes(row) + 2, -2);
47
+ let cut = masked.text !== '' && masked.text.split('\n').length > kept.length;
48
+ while (kept.length > 1 && bytes > exports.ASK_CONTEXT_SCREEN_MAX_BYTES) {
49
+ bytes -= jsonBytes(kept.shift()) + 2;
50
+ cut = true;
51
+ }
52
+ // One line that is over the budget on its own: its start stays (it is masked already, so a cut leaves no secret's head).
53
+ if (kept.length === 1 && jsonBytes(kept[0]) > exports.ASK_CONTEXT_SCREEN_MAX_BYTES) {
54
+ while (jsonBytes(kept[0]) > exports.ASK_CONTEXT_SCREEN_MAX_BYTES)
55
+ kept[0] = kept[0].slice(0, Math.floor(kept[0].length * 0.9));
56
+ cut = true;
57
+ }
58
+ return { text: kept.join('\n'), lines: kept.length, masked: masked.masked, cut };
59
+ };
60
+ /** `text` up to the last whole line that fits `room` JSON bytes. */
61
+ const wholeLinesWithin = (text, room) => {
62
+ let used = 0;
63
+ let end = 0;
64
+ for (const line of text.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
65
+ used += jsonBytes(line);
66
+ if (used > room)
67
+ break;
68
+ end += line.length;
69
+ }
70
+ return text.slice(0, end);
71
+ };
72
+ /** The patches of the files, in order, up to `budget` JSON bytes; a patch that does not fit is cut at a line and the rest is not asked for. */
73
+ const composeDiff = (input, budget) => {
74
+ let text = '';
75
+ let masked = 0;
76
+ let unread = false;
77
+ let room = budget - jsonBytes(CUT_MARKER);
78
+ for (const entry of input.files) {
79
+ if (room <= 0)
80
+ return { text: text + CUT_MARKER, masked, cut: true };
81
+ const patch = input.patchOf(entry.path);
82
+ if (!patch) {
83
+ unread = true;
84
+ continue;
85
+ }
86
+ if (patch.text === '')
87
+ continue;
88
+ masked += patch.masked;
89
+ const whole = patch.text.endsWith('\n') ? patch.text : `${patch.text}\n`;
90
+ if (jsonBytes(whole) <= room) {
91
+ text += whole;
92
+ room -= jsonBytes(whole);
93
+ continue;
94
+ }
95
+ return { text: text + wholeLinesWithin(whole, room) + CUT_MARKER, masked, cut: true };
96
+ }
97
+ return { text, masked, cut: unread };
98
+ };
99
+ /** A path over the limit is cut between characters and marked with `…`; the mark counts, so what is shown never passes `SUMMARY_PATH_MAX_BYTES` (the reader of the summary refuses a longer one). */
100
+ const summaryPath = (path) => {
101
+ if (Buffer.byteLength(path) <= SUMMARY_PATH_MAX_BYTES)
102
+ return path;
103
+ const room = SUMMARY_PATH_MAX_BYTES - Buffer.byteLength('…');
104
+ let kept = '';
105
+ let bytes = 0;
106
+ for (const char of path) {
107
+ bytes += Buffer.byteLength(char);
108
+ if (bytes > room)
109
+ break;
110
+ kept += char;
111
+ }
112
+ return `${kept}…`;
113
+ };
114
+ /** The context for the files changed and the screen shown. Pure: same input, same value, nothing is read or sent. */
115
+ const composeAskContext = (input) => {
116
+ const screen = composeScreen(input.screen);
117
+ const room = exports.ASK_CONTEXT_MAX_BYTES - (0, exports.askContextBytes)({ original: { diff: '', screen: screen.text } });
118
+ const diff = composeDiff(input, room);
119
+ const listed = input.files.slice(0, exports.ASK_CONTEXT_SUMMARY_FILES).map((f) => ({
120
+ path: summaryPath(f.path),
121
+ status: f.status,
122
+ added: f.added,
123
+ removed: f.removed,
124
+ ...(f.binary ? { binary: true } : {}),
125
+ }));
126
+ const added = input.files.reduce((sum, f) => sum + f.added, 0);
127
+ const removed = input.files.reduce((sum, f) => sum + f.removed, 0);
128
+ const summaryWith = (count) => ({
129
+ diff: input.diff ?? 'ok',
130
+ files: input.files.length,
131
+ added,
132
+ removed,
133
+ listed: listed.slice(0, count),
134
+ moreFiles: input.files.length - count,
135
+ screenLines: screen.lines,
136
+ masked: diff.masked + screen.masked,
137
+ cut: { diff: diff.cut || input.truncated === true, screen: screen.cut },
138
+ });
139
+ // A path of escapes (control characters cost six bytes each in JSON) can take the list far past its budget.
140
+ let count = listed.length;
141
+ while (count > 0 && Buffer.byteLength(JSON.stringify(summaryWith(count))) > exports.ASK_CONTEXT_SUMMARY_MAX_BYTES)
142
+ count -= 1;
143
+ return { summary: summaryWith(count), original: { diff: diff.text, screen: screen.text } };
144
+ };
145
+ exports.composeAskContext = composeAskContext;
146
+ const LIVE_DEPS = { repoRootOf: session_diff_js_1.repoRootOf, collectDiffFiles: session_diff_js_1.collectDiffFiles, collectFilePatch: session_diff_js_1.collectFilePatch, now: Date.now };
147
+ /**
148
+ * The context of the session at `cwd`: the changes against HEAD (new files included) and `screen`. `null` when
149
+ * there is neither a change nor a screen — nothing to carry. A directory outside a repository, or a git that
150
+ * cannot answer, leaves the screen alone and says so in `summary.diff`.
151
+ */
152
+ const gatherAskContext = (cwd, screen, overrides = {}) => {
153
+ const deps = { ...LIVE_DEPS, ...overrides };
154
+ const root = deps.repoRootOf(cwd);
155
+ const listing = root ? deps.collectDiffFiles(root, 'head') : null;
156
+ const diff = !root ? 'no_repo' : listing?.ok ? 'ok' : 'failed';
157
+ const gaveUp = deps.now() + GATHER_BUDGET_MS;
158
+ const ctx = (0, exports.composeAskContext)({
159
+ files: listing?.ok ? listing.files : [],
160
+ truncated: listing?.ok ? listing.truncated : false,
161
+ patchOf: (path) => {
162
+ if (!root || deps.now() > gaveUp)
163
+ return null;
164
+ const patch = deps.collectFilePatch(root, 'head', path);
165
+ return patch.ok ? { text: patch.text, masked: patch.masked } : null;
166
+ },
167
+ screen,
168
+ diff,
169
+ });
170
+ return ctx.summary.files === 0 && ctx.original.screen === '' ? null : ctx;
171
+ };
172
+ exports.gatherAskContext = gatherAskContext;
@@ -0,0 +1,47 @@
1
+ /** What makes two asks the same question: the same words, asked from the same place. */
2
+ export interface AskedQuestion {
3
+ hookId: string;
4
+ agentDeviceId?: string;
5
+ agentSessionName?: string;
6
+ title: string;
7
+ body?: string;
8
+ actions?: {
9
+ id: string;
10
+ label: string;
11
+ }[];
12
+ acceptsExit?: boolean;
13
+ }
14
+ /** A question on the phone: where its answer is read, what opens it, and when its window ends (ms). */
15
+ export interface PendingAsk {
16
+ eventId: string;
17
+ askId: string;
18
+ deadline: number;
19
+ }
20
+ /** One question's place in the store. */
21
+ export interface PendingSlot {
22
+ /** The question as an earlier run left it, now this process's to wait on; null when there is none to take. */
23
+ claim(): PendingAsk | null;
24
+ /** Keep the question this process has just put up. */
25
+ record(ask: PendingAsk): void;
26
+ /** The question is over: forget it. False when a record of it is still there, to be read by the next run. */
27
+ settle(): boolean;
28
+ }
29
+ export interface PendingAsks {
30
+ of(question: AskedQuestion): PendingSlot;
31
+ }
32
+ export interface PendingDeps {
33
+ now: () => number;
34
+ pid: number;
35
+ alive: (pid: number) => boolean;
36
+ /** Part of the match: machines that share a home folder do not share a process table. */
37
+ host: string;
38
+ cwd: () => string;
39
+ }
40
+ /** The shape of an id as it goes into a URL path and a seal: the server's event ids (`hevt_` + ULID) and ours (base64url). */
41
+ export declare const ID: RegExp;
42
+ export declare const matchKey: (question: AskedQuestion, where: {
43
+ host: string;
44
+ cwd: string;
45
+ }) => string;
46
+ export declare const pendingAsks: (dir: string, over?: Partial<PendingDeps>) => PendingAsks;
47
+ //# sourceMappingURL=ask-pending.d.ts.map
@@ -0,0 +1,221 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.pendingAsks = exports.matchKey = exports.ID = void 0;
4
+ /**
5
+ * The questions `hwip ask` has on the phone, kept so that one cut short can be taken up again.
6
+ *
7
+ * `hwip ask` blocks, and what runs it does not always wait: an agent's tool call times out, a shell is
8
+ * closed. The question stays on the phone and the answer stays on the server, with nobody left to read
9
+ * it — and asking again puts a second card next to the first. So each question that went up leaves a
10
+ * record here, and the same command run again from the same place finds it and waits on that question
11
+ * instead of sending another.
12
+ *
13
+ * A record holds where the answer is read and what opens it, and nothing of the wording — not the
14
+ * buttons' ids either, which a bare `--actions` makes of their labels: the question is matched by a hash. Its file is named for the process that waits on it, so whose it is never has to be
15
+ * read out of it. It is taken up only once that process has died — two that run side by side each ask
16
+ * their own — and by one process only: taking is a rename to the taker's own name, which one of them
17
+ * wins. A record is under some process's name at every moment, and no name is used twice.
18
+ *
19
+ * All of it is best effort. State that cannot be read or written costs the taking up, never the ask.
20
+ */
21
+ const node_crypto_1 = require("node:crypto");
22
+ const node_fs_1 = require("node:fs");
23
+ const node_os_1 = require("node:os");
24
+ const node_path_1 = require("node:path");
25
+ const private_files_js_1 = require("./private-files.js");
26
+ /** The first hex digits of the match that name the file: 128 bits. */
27
+ const NAME_HEX = 32;
28
+ const NONCE_HEX = 8;
29
+ const PREFIX = 'ask-pending-';
30
+ /** A record's file: the question, the pid that waits on it, and a nonce that keeps a pid used again from meeting an old name. */
31
+ const RECORD = new RegExp(`^${PREFIX}([0-9a-f]{${NAME_HEX}})\\.([1-9][0-9]*)\\.[0-9a-f]{${NONCE_HEX}}$`);
32
+ /** A file of ours that is not a record — one half written — this old belongs to a process that died at it. */
33
+ const LEFTOVER_MAX_AGE_MS = 60 * 60 * 1000;
34
+ /** The shape of an id as it goes into a URL path and a seal: the server's event ids (`hevt_` + ULID) and ours (base64url). */
35
+ exports.ID = /^[A-Za-z0-9_-]{1,128}$/;
36
+ const matchKey = (question, where) => (0, node_crypto_1.createHash)('sha256')
37
+ .update(JSON.stringify([
38
+ where.host,
39
+ question.hookId,
40
+ question.agentDeviceId ?? null,
41
+ question.agentSessionName ?? null,
42
+ where.cwd,
43
+ question.title,
44
+ question.body ?? null,
45
+ (question.actions ?? []).map(({ id, label }) => [id, label]),
46
+ question.acceptsExit === true,
47
+ ]))
48
+ .digest('hex');
49
+ exports.matchKey = matchKey;
50
+ /** The failure of a file that is not there. */
51
+ const isMissing = (err) => err instanceof Error && 'code' in err && err.code === 'ENOENT';
52
+ /** Signal 0 reaches nothing and fails for a pid that is not there (ESRCH). Any other failure — EPERM, another user's process — is of one that is. */
53
+ const processAlive = (pid) => {
54
+ try {
55
+ process.kill(pid, 0);
56
+ return true;
57
+ }
58
+ catch (err) {
59
+ return !(err instanceof Error && 'code' in err && err.code === 'ESRCH');
60
+ }
61
+ };
62
+ /** A file is read as a record only when every field is what this module writes: the file is the user's own, and may have been touched. */
63
+ const isStored = (value) => typeof value === 'object' && value !== null
64
+ && 'v' in value && value.v === 1
65
+ && 'match' in value && typeof value.match === 'string'
66
+ && 'hookId' in value && typeof value.hookId === 'string'
67
+ && 'eventId' in value && typeof value.eventId === 'string' && exports.ID.test(value.eventId)
68
+ && 'askId' in value && typeof value.askId === 'string' && exports.ID.test(value.askId)
69
+ && 'deadline' in value && Number.isSafeInteger(value.deadline) && Number(value.deadline) > 0;
70
+ /**
71
+ * The record at `path`: `undefined` when there is no file, `null` when what is there is not a record.
72
+ * Throws when the file is there and cannot be read: that may pass, and is no reason to drop it.
73
+ */
74
+ const read = (path) => {
75
+ let raw;
76
+ try {
77
+ raw = (0, node_fs_1.readFileSync)(path, 'utf-8');
78
+ }
79
+ catch (err) {
80
+ if (isMissing(err))
81
+ return undefined;
82
+ throw err;
83
+ }
84
+ try {
85
+ const value = JSON.parse(raw);
86
+ return isStored(value) ? value : null;
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ };
92
+ const remove = (path) => {
93
+ try {
94
+ (0, node_fs_1.unlinkSync)(path);
95
+ }
96
+ catch { /* gone already, or not ours to remove */ }
97
+ };
98
+ /** The slot of a question that cannot be placed: nothing is kept, and nothing taken up. */
99
+ const NO_SLOT = { claim: () => null, record: () => undefined, settle: () => true };
100
+ const pendingAsks = (dir, over = {}) => {
101
+ const deps = { now: Date.now, pid: process.pid, alive: processAlive, host: (0, node_os_1.hostname)(), cwd: () => process.cwd(), ...over };
102
+ /** Records past their deadline or not records at all, and leftovers of processes that died mid-write. */
103
+ const sweep = (names) => {
104
+ const now = deps.now();
105
+ for (const name of names) {
106
+ if (!name.startsWith(PREFIX))
107
+ continue;
108
+ const path = (0, node_path_1.join)(dir, name);
109
+ // Each file on its own: one that another process removes meanwhile, or that cannot be read, is passed over.
110
+ try {
111
+ if (!RECORD.test(name)) {
112
+ if (now - (0, node_fs_1.statSync)(path).mtimeMs > LEFTOVER_MAX_AGE_MS)
113
+ remove(path);
114
+ continue;
115
+ }
116
+ const kept = read(path);
117
+ if (kept === null || (kept && kept.deadline <= now))
118
+ remove(path);
119
+ }
120
+ catch { /* left for the next sweep */ }
121
+ }
122
+ };
123
+ return {
124
+ of: (question) => {
125
+ let match;
126
+ try {
127
+ // The directory this runs in may be gone (a removed worktree): `process.cwd()` throws then.
128
+ match = (0, exports.matchKey)(question, { host: deps.host, cwd: deps.cwd() });
129
+ }
130
+ catch {
131
+ return NO_SLOT;
132
+ }
133
+ const tag = match.slice(0, NAME_HEX);
134
+ /** The pid a file of this question is kept under; `undefined` for any other file. */
135
+ const ownerOf = (name) => {
136
+ const parts = RECORD.exec(name);
137
+ return parts && parts[1] === tag ? Number(parts[2]) : undefined;
138
+ };
139
+ const ownPath = () => (0, node_path_1.join)(dir, `${PREFIX}${tag}.${deps.pid}.${(0, node_crypto_1.randomUUID)().slice(0, NONCE_HEX)}`);
140
+ return {
141
+ claim: () => {
142
+ try {
143
+ const names = (0, node_fs_1.readdirSync)(dir);
144
+ sweep(names);
145
+ // One listing for both: a record the sweep removed reads as not there.
146
+ for (const name of names) {
147
+ const owner = ownerOf(name);
148
+ // Not this question, or one a process is still waiting on. A record under this process's
149
+ // own pid is from before it asked anything: the pid has been used again.
150
+ if (owner === undefined || (owner !== deps.pid && deps.alive(owner)))
151
+ continue;
152
+ const path = (0, node_path_1.join)(dir, name);
153
+ try {
154
+ const kept = read(path);
155
+ if (kept === undefined)
156
+ continue;
157
+ if (kept === null || kept.match !== match || kept.hookId !== question.hookId) {
158
+ remove(path);
159
+ continue;
160
+ }
161
+ if (kept.deadline <= deps.now())
162
+ continue;
163
+ // Taken by one process only: the rename fails for every other that tries, and for
164
+ // this one when the record has been taken — or settled — since the look. What it
165
+ // moves is what was read: a name is written once and never used again.
166
+ (0, node_fs_1.renameSync)(path, ownPath());
167
+ return { eventId: kept.eventId, askId: kept.askId, deadline: kept.deadline };
168
+ }
169
+ catch { /* another's by now, or unreadable: the next one */ }
170
+ }
171
+ return null;
172
+ }
173
+ catch {
174
+ return null;
175
+ }
176
+ },
177
+ record: (ask) => {
178
+ const path = ownPath();
179
+ const tmp = `${path}.tmp-${(0, node_crypto_1.randomUUID)()}`;
180
+ try {
181
+ // The names say which questions are open and for which processes: nobody else's to list.
182
+ (0, private_files_js_1.ensurePrivateDir)(dir);
183
+ // Written whole under another name, then moved into place: a reader never sees half of one.
184
+ (0, node_fs_1.writeFileSync)(tmp, JSON.stringify({ v: 1, match, hookId: question.hookId, ...ask }), { mode: 0o600, flag: 'wx' });
185
+ (0, private_files_js_1.handBackToSudoUser)(tmp);
186
+ (0, node_fs_1.renameSync)(tmp, path);
187
+ }
188
+ catch {
189
+ // The folder cannot be written: this question is not taken up again.
190
+ remove(tmp);
191
+ }
192
+ },
193
+ settle: () => {
194
+ let names;
195
+ try {
196
+ names = (0, node_fs_1.readdirSync)(dir);
197
+ }
198
+ catch {
199
+ // Nothing can be listed, so nothing is there for the next run to take either.
200
+ return true;
201
+ }
202
+ let gone = true;
203
+ // By name, not by what the file says: a record that cannot be read is ended all the same.
204
+ for (const name of names) {
205
+ if (ownerOf(name) !== deps.pid)
206
+ continue;
207
+ try {
208
+ (0, node_fs_1.unlinkSync)((0, node_path_1.join)(dir, name));
209
+ }
210
+ catch (err) {
211
+ if (!isMissing(err))
212
+ gone = false;
213
+ }
214
+ }
215
+ return gone;
216
+ },
217
+ };
218
+ },
219
+ };
220
+ };
221
+ exports.pendingAsks = pendingAsks;