@hwip/cli 0.0.1 → 0.1.1

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 +1983 -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 +305 -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 +39 -0
  39. package/dist/blocked-prompt-seal.js +92 -0
  40. package/dist/blocked-prompt.d.ts +62 -0
  41. package/dist/blocked-prompt.js +518 -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 +278 -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 +5681 -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,12 @@
1
+ /** Agents whose prompt-submit hooks this command serves. Claude Code is
2
+ * NOT here — its hook ships with the plugin (hooks/hwip-remote.sh). */
3
+ export declare const REMOTE_HOOK_AGENTS: readonly ["gemini", "codex", "pi"];
4
+ export type RemoteHookAgent = (typeof REMOTE_HOOK_AGENTS)[number];
5
+ export declare const remoteEntrySections: () => string;
6
+ export declare const isRemoteHookAgent: (raw: string) => raw is RemoteHookAgent;
7
+ /**
8
+ * Core of the hook: match the stdin payload against the marker and return
9
+ * the JSON to print, or null for a silent no-op. Never throws.
10
+ */
11
+ export declare const runRemoteHook: (agent: RemoteHookAgent, stdin: string, env?: NodeJS.ProcessEnv, now?: () => number) => string | null;
12
+ //# sourceMappingURL=remote-hook.d.ts.map
@@ -0,0 +1,196 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runRemoteHook = exports.isRemoteHookAgent = exports.remoteEntrySections = exports.REMOTE_HOOK_AGENTS = void 0;
4
+ /**
5
+ * `hwip remote-hook <agent>` — prompt-submit hook handler for agents whose
6
+ * hooks the cli installs directly: Gemini CLI (BeforeAgent), Codex CLI
7
+ * (UserPromptSubmit), and Pi (before_agent_start, consumed by the hwip
8
+ * extension itself). TS twin of the Claude Code plugin's
9
+ * hooks/hwip-remote.sh (ADR-0002).
10
+ *
11
+ * The listener records every phone→pane text injection as a one-shot
12
+ * marker (`remote-<hash>` in the hwip state dir) holding the epoch second
13
+ * and sha256 of the injected text. When the submitted prompt matches that
14
+ * record — same project (cksum of the cwd), fresh (≤15 min), and
15
+ * byte-identical trimmed text — the hook emits additionalContext telling
16
+ * the model the user is driving the session from their phone, which enters
17
+ * sticky REMOTE mode.
18
+ *
19
+ * Detection is exact: a terminal keystroke racing a phone message can
20
+ * never false-match. Parity with the writer (listener.ts writeRemoteMarker)
21
+ * is by construction — both sides share gate.ts remoteDigest/remoteMarkerPath.
22
+ *
23
+ * The marker is one-shot, but REMOTE is not: entry records the mode (gate.ts
24
+ * touchRemoteActive) so it outlives the entry turn and survives compaction.
25
+ * A prompt that reaches this hook without a marker was typed at the terminal —
26
+ * the only way text becomes a prompt without one is the user's own keyboard,
27
+ * since a phone answer to a hwip_ask comes back as a tool_result and never
28
+ * reaches a prompt hook. The user is back, so the mode ends here (gate.ts
29
+ * clearRemoteActive) and the hook says so once.
30
+ *
31
+ * No marker and no live state → null (silent no-op); this hook only ever adds
32
+ * context and must never block a prompt.
33
+ */
34
+ const fs_1 = require("fs");
35
+ const config_js_1 = require("./config.js");
36
+ const core_sections_js_1 = require("./core-sections.js");
37
+ const hwip_core_generated_js_1 = require("./hwip-core.generated.js");
38
+ const gate_js_1 = require("./gate.js");
39
+ /** Agents whose prompt-submit hooks this command serves. Claude Code is
40
+ * NOT here — its hook ships with the plugin (hooks/hwip-remote.sh). */
41
+ exports.REMOTE_HOOK_AGENTS = ['gemini', 'codex', 'pi'];
42
+ /** Per-agent hookEventName echoed back in hookSpecificOutput — each CLI
43
+ * validates it against the event that invoked the hook. */
44
+ const HOOK_EVENT_NAME = {
45
+ gemini: 'BeforeAgent',
46
+ codex: 'UserPromptSubmit',
47
+ pi: 'before_agent_start',
48
+ };
49
+ /**
50
+ * Freshness window (seconds), same as the bash reader: deliberately
51
+ * generous — false positives are already impossible without an exact hash
52
+ * match; its only job is to stop the SAME text typed at the terminal much
53
+ * later from re-flagging. It must survive the real gap between injection
54
+ * and prompt submit (a message sent mid-turn queues until the turn ends).
55
+ */
56
+ const FRESH_WINDOW_SEC = 900;
57
+ const TWO_WAY_CONTEXT = `# System note (Hwip remote-origin detect)
58
+
59
+ This user message arrived from the user's phone via Hwip agent chat (verified by the listener — exact text match). The user is driving this session remotely and is NOT at the terminal. Enter sticky REMOTE mode now (the "Sticky REMOTE mode" rule in your Hwip rules): end EVERY response with \`hwip_ask\` (buttons + free-text) until the user exits — an exit signal (done/stop/exit), or a prompt they type at the terminal, which this hook will tell you about. Plain-text questions are invisible to them.`;
60
+ // The static rule file of these agents carries the NORMAL branch only
61
+ // (templates.ts PROMPT_HOOK_CORE), so the turn that enters REMOTE is where the
62
+ // contract has to arrive in full — the twin of plugin/hooks/hwip-remote.sh
63
+ // reading CORE_RULES.md on entry. Read from the generated core rather than
64
+ // restated here: a copy would be one more place for the rule to drift. The
65
+ // four sections together cite only rules they carry (1–4, 7–9), so no
66
+ // cross-reference dangles; remote-hook.test.ts checks that.
67
+ //
68
+ // Where it goes depends on what the agent's hook can carry:
69
+ // - gemini/codex: additionalContext, once per entry — a later phone prompt on
70
+ // a session already in REMOTE gets the note alone, since the contract is
71
+ // already in its context.
72
+ // - pi: `systemPrompt`, on every phone prompt. pi's before_agent_start can
73
+ // replace the system prompt for that run, and a system-prompt change is not
74
+ // persisted — so the contract costs nothing after REMOTE ends, whereas an
75
+ // injected message rides every later turn of the session (2026-09-13: a
76
+ // "hello" from the phone left 7.5K chars in a 300-turn implement session
77
+ // until compaction, and the compaction summary then restated the rules).
78
+ // The additionalContext note stays short on every phone prompt.
79
+ // Sliced on that turn only — this command runs on every prompt submit, and
80
+ // the common path is a silent no-op. Exported for the tests.
81
+ const remoteEntrySections = () => ['When hwip_ask is MANDATORY', 'When hwip_ask is the DEFAULT', 'Sticky REMOTE mode', 'When to use AskUserQuestion vs hwip_ask']
82
+ .map((heading) => (0, core_sections_js_1.coreSection)(hwip_core_generated_js_1.HWIP_CORE_HOOK_DRIVEN, heading))
83
+ .join('\n\n');
84
+ exports.remoteEntrySections = remoteEntrySections;
85
+ const ONE_WAY_CONTEXT = `# System note (Hwip remote-origin detect)
86
+
87
+ This user message arrived from the user's phone via Hwip agent chat (verified by the listener — exact text match), but no hook id is configured (neither \`HWIP_HOOK_ID\` nor \`hookId\` in ~/.hwip/config.json), so the two-way tool (hwip_ask) is unavailable. Make your final message self-contained — the completion push is the user's only feedback channel. If you have not already mentioned it this session, tell the user once that running \`npx @hwip/cli setup\` upgrades this into a two-way remote session (buttons + text replies from the phone).`;
88
+ /**
89
+ * The first prompt a remote session's user types at the terminal. Emitted
90
+ * once — the state is cleared with it, so every later terminal turn is a
91
+ * silent no-op. Kept in step with the wording in
92
+ * plugin/hooks/hwip-remote.sh.
93
+ */
94
+ const EXIT_CONTEXT = `# System note (Hwip)
95
+
96
+ The user typed this prompt at the terminal, so this session has LEFT sticky REMOTE mode — answer normally (the NORMAL branch of your Hwip rules) and do not end this response with \`hwip_ask\` just to keep the loop alive. One rule still holds: if you actually ask the user something, ask it with \`hwip_ask\`. Re-entry is automatic the moment they send another message from their phone.`;
97
+ const isRemoteHookAgent = (raw) => exports.REMOTE_HOOK_AGENTS.includes(raw);
98
+ exports.isRemoteHookAgent = isRemoteHookAgent;
99
+ /**
100
+ * Core of the hook: match the stdin payload against the marker and return
101
+ * the JSON to print, or null for a silent no-op. Never throws.
102
+ */
103
+ const runRemoteHook = (agent, stdin, env = process.env, now = Date.now) => {
104
+ let prompt;
105
+ let cwd;
106
+ try {
107
+ const input = JSON.parse(stdin);
108
+ prompt = typeof input.prompt === 'string' ? input.prompt : '';
109
+ cwd = typeof input.cwd === 'string' ? input.cwd : '';
110
+ }
111
+ catch {
112
+ return null;
113
+ }
114
+ // `cwd` keys every state file, so without it there is nothing to look up.
115
+ if (!cwd)
116
+ return null;
117
+ // Mute outranks everything (the mute rule) — stay silent and leave both the marker
118
+ // and the state untouched (the next inject overwrites the marker anyway).
119
+ if ((0, gate_js_1.isMuted)(cwd))
120
+ return null;
121
+ const emit = (additionalContext, systemPrompt) => JSON.stringify({
122
+ hookSpecificOutput: { hookEventName: HOOK_EVENT_NAME[agent], additionalContext, ...(systemPrompt ? { systemPrompt } : {}) },
123
+ });
124
+ // Exactly one additionalContext per invocation, and the verdict decides
125
+ // which: 'phone' enters, 'keyboard' leaves, 'unclear' says nothing and
126
+ // leaves the mode as it was.
127
+ const origin = remoteOrigin(prompt, cwd, now);
128
+ if (origin === 'phone') {
129
+ // Resolved on the two branches that need it, not up front — the common
130
+ // no-marker path never reads the config file.
131
+ if (!(0, config_js_1.resolveHookId)(env))
132
+ return emit(ONE_WAY_CONTEXT);
133
+ // Only a two-way session has a mode to stay in — without hwip_ask there is
134
+ // nothing for a later turn to be reminded of, so no state is recorded.
135
+ const entering = !(0, gate_js_1.isRemoteActive)(cwd, now);
136
+ (0, gate_js_1.touchRemoteActive)(cwd, now);
137
+ if (agent === 'pi')
138
+ return emit(TWO_WAY_CONTEXT, (0, exports.remoteEntrySections)());
139
+ return emit(entering ? `${TWO_WAY_CONTEXT}\n\n${(0, exports.remoteEntrySections)()}` : TWO_WAY_CONTEXT);
140
+ }
141
+ if (origin === 'keyboard' && (0, gate_js_1.isRemoteActive)(cwd, now) && (0, config_js_1.resolveHookId)(env)) {
142
+ (0, gate_js_1.clearRemoteActive)(cwd);
143
+ return emit(EXIT_CONTEXT);
144
+ }
145
+ return null;
146
+ };
147
+ exports.runRemoteHook = runRemoteHook;
148
+ const remoteOrigin = (prompt, cwd, now) => {
149
+ const hash = (0, gate_js_1.projectHash)(cwd);
150
+ const marker = (0, gate_js_1.remoteMarkerPath)(hash);
151
+ let content;
152
+ try {
153
+ content = (0, fs_1.readFileSync)(marker, 'utf-8');
154
+ }
155
+ catch {
156
+ return 'keyboard';
157
+ }
158
+ if (!prompt)
159
+ return 'unclear';
160
+ // Marker format: "<epochSec> <sha256hex>\n" (listener.ts writeRemoteMarker).
161
+ // At most 12 digits, like the REMOTE state's time (gate.ts isRemoteActive; bash `hwip_age_sec`): a longer one is not a time.
162
+ const record = content.match(/^(\d{1,12}) ([0-9a-f]{64})\n?$/);
163
+ if (!record) {
164
+ // Junk can only ever be re-read and re-rejected — same housekeeping a
165
+ // stale marker gets. Twin: hwip-remote.sh's `rm -f` on the parse branches.
166
+ try {
167
+ (0, fs_1.unlinkSync)(marker);
168
+ }
169
+ catch {
170
+ /* best-effort housekeeping */
171
+ }
172
+ return 'keyboard';
173
+ }
174
+ if (Math.floor(now() / 1000) - Number(record[1]) > FRESH_WINDOW_SEC) {
175
+ // Stale markers are dead weight (can never flag) — delete on sight.
176
+ try {
177
+ (0, fs_1.unlinkSync)(marker);
178
+ }
179
+ catch {
180
+ /* best-effort housekeeping */
181
+ }
182
+ return 'keyboard';
183
+ }
184
+ if ((0, gate_js_1.remoteDigest)(prompt) !== record[2])
185
+ return 'unclear';
186
+ // Matched — consume the marker so an identical later prompt (e.g. typed
187
+ // at the terminal) can't re-flag. Housekeeping is not the verdict: the
188
+ // match is valid whether or not the delete lands.
189
+ try {
190
+ (0, fs_1.unlinkSync)(marker);
191
+ }
192
+ catch {
193
+ /* emit anyway — the match itself is valid */
194
+ }
195
+ return 'phone';
196
+ };
@@ -0,0 +1,50 @@
1
+ /** A file hwip is about to rewrite is not valid UTF-8: reading it as text would turn its bytes into U+FFFD and the write would keep that. */
2
+ export declare class NotUtf8Error extends Error {
3
+ constructor(filePath: string);
4
+ }
5
+ /** The file changed between hwip reading it and writing it: the write would drop the other writer's change. */
6
+ export declare class FileChangedError extends Error {
7
+ constructor(filePath: string);
8
+ }
9
+ /** hwip may not write the file, or may not create the temp file it writes beside it: it is left as it is, and the user is told which. */
10
+ export declare class NotWritableError extends Error {
11
+ constructor(filePath: string, why: string);
12
+ }
13
+ /** A file's text, exactly: throws `NotUtf8Error` instead of decoding bad bytes to U+FFFD. A byte order mark is kept. */
14
+ export declare const readText: (filePath: string) => string;
15
+ /** `readText`, or null for a file that is not there (any other failure — no permission — still throws). */
16
+ export declare const readTextIfExists: (filePath: string) => string | null;
17
+ /**
18
+ * Delete a file hwip owned whole, unless it changed since it was read: a delete is a write too, and
19
+ * a line the user added in between would go with it. `expected` is what was read.
20
+ */
21
+ export declare const removeFile: (filePath: string, expected: string) => void;
22
+ /**
23
+ * Write a user's file so it is either the old content or the new one, never a
24
+ * cut-off mix: a plain write empties the file first, and a full disk or a kill
25
+ * in between leaves the agent's settings truncated. The new content goes to a
26
+ * temp file beside the real one and is renamed over it.
27
+ *
28
+ * A symlink is followed, not replaced (dotfile managers link these files into
29
+ * a repo), and an existing file keeps its mode. The rename makes a new inode,
30
+ * so a hard link to the old file keeps the old content (not when it is written in place).
31
+ *
32
+ * `expected` is what the caller read before it built `content` (null: the file
33
+ * was not there). Just before the rename the file is read again, and a write
34
+ * that would drop what someone else wrote in between throws `FileChangedError`
35
+ * instead. Left out, nothing is checked (a file hwip owns whole). The window
36
+ * between that read and the rename is not closed, only made very small.
37
+ *
38
+ * `mode` is the mode the new file has when it appears under its name, new file
39
+ * or old: a file with a secret in it must not be wider than that for a moment.
40
+ * Left out, a new file is 0644 and an old one keeps its mode.
41
+ *
42
+ * A file the user cannot write is not replaced: the rename needs no permission on it, but the
43
+ * user made it read-only on purpose, so `NotWritableError` (root can write any file, so for root
44
+ * nothing is read-only). A writable file in a directory that
45
+ * takes no new entries is written in place (what a plain write did before this function): the
46
+ * user can change the file, so changing it is not a bypass. A file that is not there, in such a
47
+ * directory, cannot be made: `NotWritableError`.
48
+ */
49
+ export declare const replaceFile: (filePath: string, content: string, expected?: string | null, mode?: number) => void;
50
+ //# sourceMappingURL=replace-file.d.ts.map
@@ -0,0 +1,328 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.replaceFile = exports.removeFile = exports.readTextIfExists = exports.readText = exports.NotWritableError = exports.FileChangedError = exports.NotUtf8Error = void 0;
4
+ const fs_1 = require("fs");
5
+ const crypto_1 = require("crypto");
6
+ const path_1 = require("path");
7
+ const private_files_js_1 = require("./private-files.js");
8
+ /** A file hwip is about to rewrite is not valid UTF-8: reading it as text would turn its bytes into U+FFFD and the write would keep that. */
9
+ class NotUtf8Error extends Error {
10
+ constructor(filePath) {
11
+ super(`${filePath} is not valid UTF-8 text, left it unchanged`);
12
+ this.name = 'NotUtf8Error';
13
+ }
14
+ }
15
+ exports.NotUtf8Error = NotUtf8Error;
16
+ /** The file changed between hwip reading it and writing it: the write would drop the other writer's change. */
17
+ class FileChangedError extends Error {
18
+ constructor(filePath) {
19
+ super(`${filePath} changed while hwip was editing it, left it as it is (run the command again)`);
20
+ this.name = 'FileChangedError';
21
+ }
22
+ }
23
+ exports.FileChangedError = FileChangedError;
24
+ /** hwip may not write the file, or may not create the temp file it writes beside it: it is left as it is, and the user is told which. */
25
+ class NotWritableError extends Error {
26
+ constructor(filePath, why) {
27
+ super(`${filePath} ${why}, left it unchanged`);
28
+ this.name = 'NotWritableError';
29
+ }
30
+ }
31
+ exports.NotWritableError = NotWritableError;
32
+ /** A file's text, exactly: throws `NotUtf8Error` instead of decoding bad bytes to U+FFFD. A byte order mark is kept. */
33
+ const readText = (filePath) => {
34
+ const bytes = (0, fs_1.readFileSync)(filePath);
35
+ try {
36
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
37
+ }
38
+ catch {
39
+ throw new NotUtf8Error(filePath);
40
+ }
41
+ };
42
+ exports.readText = readText;
43
+ /** `readText`, or null for a file that is not there (any other failure — no permission — still throws). */
44
+ const readTextIfExists = (filePath) => {
45
+ try {
46
+ return (0, exports.readText)(filePath);
47
+ }
48
+ catch (err) {
49
+ if (err instanceof Error && 'code' in err && err.code === 'ENOENT')
50
+ return null;
51
+ throw err;
52
+ }
53
+ };
54
+ exports.readTextIfExists = readTextIfExists;
55
+ /**
56
+ * Delete a file hwip owned whole, unless it changed since it was read: a delete is a write too, and
57
+ * a line the user added in between would go with it. `expected` is what was read.
58
+ */
59
+ const removeFile = (filePath, expected) => {
60
+ let now;
61
+ try {
62
+ now = (0, exports.readTextIfExists)(filePath);
63
+ }
64
+ catch {
65
+ throw new FileChangedError(filePath);
66
+ }
67
+ if (now !== expected)
68
+ throw new FileChangedError(filePath);
69
+ (0, fs_1.rmSync)(filePath, { force: true });
70
+ };
71
+ exports.removeFile = removeFile;
72
+ /** The kernel follows at most 40 links in a path (Linux `ELOOP`; macOS stops at 32): so does this. */
73
+ const MAX_LINK_HOPS = 40;
74
+ /** The file a write to `filePath` lands in: the end of a symlink chain, even
75
+ * one that does not exist yet (a plain write creates it there). */
76
+ const writeTarget = (filePath) => {
77
+ try {
78
+ return (0, fs_1.realpathSync)(filePath);
79
+ }
80
+ catch {
81
+ /* missing, or a link (chain) to a missing file */
82
+ }
83
+ // `realpath` cannot say where a chain that ends in nothing ends: walk it a link at a time.
84
+ // One step only would stop at the middle link, and the rename would replace it with a file.
85
+ let at = filePath;
86
+ for (let hops = 0; (0, fs_1.lstatSync)(at, { throwIfNoEntry: false })?.isSymbolicLink(); hops++) {
87
+ if (hops === MAX_LINK_HOPS)
88
+ throw new Error(`${filePath} links to itself, or through too many links, so it was left unchanged`);
89
+ // A relative link is read from the directory the link is really in, which `..` makes a difference for
90
+ // when that directory is itself reached through a symlink (`~/.cursor` into a dotfiles repo).
91
+ let base = (0, path_1.dirname)(at);
92
+ try {
93
+ base = (0, fs_1.realpathSync)(base);
94
+ }
95
+ catch {
96
+ /* the directory is not there either: the lexical one is all there is */
97
+ }
98
+ at = (0, path_1.resolve)(base, (0, fs_1.readlinkSync)(at));
99
+ }
100
+ return at;
101
+ };
102
+ const lacksPermission = (err) => err instanceof Error && 'code' in err && (err.code === 'EACCES' || err.code === 'EPERM');
103
+ /**
104
+ * Whether someone else already wrote exactly `content` since `expected` was read (nothing left to do).
105
+ * Throws `FileChangedError` when the file holds anything but `expected` or `content`.
106
+ */
107
+ const alreadyWritten = (target, filePath, expected, content) => {
108
+ if (expected === undefined)
109
+ return false;
110
+ let now;
111
+ try {
112
+ now = (0, exports.readTextIfExists)(target);
113
+ }
114
+ catch {
115
+ throw new FileChangedError(filePath); // unreadable, or no longer text: not what was read either
116
+ }
117
+ if (now === expected)
118
+ return false;
119
+ if (now === content)
120
+ return true;
121
+ throw new FileChangedError(filePath);
122
+ };
123
+ /**
124
+ * Write over the file itself, for a directory that takes no new entries (a temp file cannot be made
125
+ * there). Not atomic: a kill in between leaves a cut-off file, so the old bytes are put back if the
126
+ * write fails, and the asked-for mode is set first so a secret is never in a wider file.
127
+ */
128
+ const writeInPlace = (target, content, mode) => {
129
+ const { O_RDWR, O_NOFOLLOW } = fs_1.constants;
130
+ // Without `O_NOFOLLOW` (Windows, which has no such modes either) the old by-name path stays.
131
+ if (O_NOFOLLOW === undefined) {
132
+ const old = (0, fs_1.readFileSync)(target);
133
+ if (mode !== undefined)
134
+ (0, fs_1.chmodSync)(target, mode);
135
+ try {
136
+ (0, fs_1.writeFileSync)(target, content);
137
+ }
138
+ catch (err) {
139
+ try {
140
+ (0, fs_1.writeFileSync)(target, old);
141
+ }
142
+ catch {
143
+ throw new Error(`${target} could not be written and its old content could not be put back, so it may be cut off`, { cause: err });
144
+ }
145
+ throw err;
146
+ }
147
+ return;
148
+ }
149
+ // Opened without following a link at its end, and read through what was opened: a link
150
+ // planted after `writeTarget` resolved it fails closed here (ELOOP) instead of redirecting
151
+ // the read, the mode change and the write onto another file.
152
+ let fd;
153
+ try {
154
+ fd = (0, fs_1.openSync)(target, O_RDWR | O_NOFOLLOW);
155
+ }
156
+ catch {
157
+ throw new FileChangedError(target);
158
+ }
159
+ try {
160
+ const old = (0, fs_1.readFileSync)(fd);
161
+ if (mode !== undefined)
162
+ (0, fs_1.fchmodSync)(fd, mode);
163
+ // An fd write starts where the last one stopped (a truncate does not move the offset):
164
+ // cut and write at 0, so a retry and the put-back below both start at the beginning.
165
+ (0, fs_1.ftruncateSync)(fd, 0);
166
+ try {
167
+ (0, fs_1.writeSync)(fd, content, 0, 'utf8');
168
+ }
169
+ catch (err) {
170
+ try {
171
+ (0, fs_1.ftruncateSync)(fd, 0);
172
+ (0, fs_1.writeSync)(fd, old, 0, null, 0);
173
+ }
174
+ catch {
175
+ throw new Error(`${target} could not be written and its old content could not be put back, so it may be cut off`, { cause: err });
176
+ }
177
+ throw err;
178
+ }
179
+ }
180
+ finally {
181
+ (0, fs_1.closeSync)(fd);
182
+ }
183
+ };
184
+ /**
185
+ * Write a user's file so it is either the old content or the new one, never a
186
+ * cut-off mix: a plain write empties the file first, and a full disk or a kill
187
+ * in between leaves the agent's settings truncated. The new content goes to a
188
+ * temp file beside the real one and is renamed over it.
189
+ *
190
+ * A symlink is followed, not replaced (dotfile managers link these files into
191
+ * a repo), and an existing file keeps its mode. The rename makes a new inode,
192
+ * so a hard link to the old file keeps the old content (not when it is written in place).
193
+ *
194
+ * `expected` is what the caller read before it built `content` (null: the file
195
+ * was not there). Just before the rename the file is read again, and a write
196
+ * that would drop what someone else wrote in between throws `FileChangedError`
197
+ * instead. Left out, nothing is checked (a file hwip owns whole). The window
198
+ * between that read and the rename is not closed, only made very small.
199
+ *
200
+ * `mode` is the mode the new file has when it appears under its name, new file
201
+ * or old: a file with a secret in it must not be wider than that for a moment.
202
+ * Left out, a new file is 0644 and an old one keeps its mode.
203
+ *
204
+ * A file the user cannot write is not replaced: the rename needs no permission on it, but the
205
+ * user made it read-only on purpose, so `NotWritableError` (root can write any file, so for root
206
+ * nothing is read-only). A writable file in a directory that
207
+ * takes no new entries is written in place (what a plain write did before this function): the
208
+ * user can change the file, so changing it is not a bypass. A file that is not there, in such a
209
+ * directory, cannot be made: `NotWritableError`.
210
+ */
211
+ const replaceFile = (filePath, content, expected, mode) => {
212
+ (0, private_files_js_1.mkdirHandedBack)((0, path_1.dirname)(filePath));
213
+ const target = writeTarget(filePath);
214
+ (0, private_files_js_1.mkdirHandedBack)((0, path_1.dirname)(target));
215
+ const existing = (0, fs_1.statSync)(target, { throwIfNoEntry: false });
216
+ if (existing) {
217
+ try {
218
+ (0, fs_1.accessSync)(target, fs_1.constants.W_OK);
219
+ }
220
+ catch {
221
+ throw new NotWritableError(filePath, 'is read-only');
222
+ }
223
+ }
224
+ // Random name, exclusive create: a predictable name is one another process
225
+ // could pre-create as a symlink for this write to follow.
226
+ const tmp = `${target}.${(0, crypto_1.randomBytes)(6).toString('hex')}.tmp`;
227
+ let created = false;
228
+ try {
229
+ // Created without following a link at its end (C149 ④): `writeFileSync` with `wx`
230
+ // opens what a pre-placed link points at. The fd below fails closed (ELOOP) instead,
231
+ // and mode/owner go onto the fd, never by name onto whatever `tmp` became after.
232
+ if (fs_1.constants.O_NOFOLLOW === undefined) {
233
+ try {
234
+ (0, fs_1.writeFileSync)(tmp, content, { mode: 0o600, flag: 'wx' });
235
+ }
236
+ catch (err) {
237
+ // The exclusive create may have made the file and then failed writing it (a full disk): that file is ours.
238
+ // EEXIST means the name was taken before, by someone else.
239
+ if (!(err instanceof Error && 'code' in err && err.code === 'EEXIST')) {
240
+ try {
241
+ (0, fs_1.rmSync)(tmp, { force: true });
242
+ }
243
+ catch {
244
+ /* the write's own error is the one to report */
245
+ }
246
+ }
247
+ if (!lacksPermission(err))
248
+ throw err;
249
+ if (!existing)
250
+ throw new NotWritableError(filePath, `cannot be created: ${(0, path_1.dirname)(target)} takes no new files`);
251
+ if (alreadyWritten(target, filePath, expected, content))
252
+ return;
253
+ writeInPlace(target, content, mode);
254
+ return;
255
+ }
256
+ created = true;
257
+ // After the write: the create mode is cut by the umask.
258
+ (0, fs_1.chmodSync)(tmp, mode ?? (existing ? existing.mode & 0o777 : 0o644));
259
+ // The rename makes a new inode owned by whoever runs this: root (sudo, HOME kept) must not take a user's file.
260
+ if (existing && process.getuid?.() === 0 && existing.uid !== 0)
261
+ (0, fs_1.chownSync)(tmp, existing.uid, existing.gid);
262
+ }
263
+ else {
264
+ const flags = fs_1.constants.O_CREAT | fs_1.constants.O_EXCL | fs_1.constants.O_WRONLY | fs_1.constants.O_NOFOLLOW;
265
+ let tmpFd;
266
+ try {
267
+ tmpFd = (0, fs_1.openSync)(tmp, flags, 0o600);
268
+ }
269
+ catch (err) {
270
+ if (!lacksPermission(err))
271
+ throw err;
272
+ if (!existing)
273
+ throw new NotWritableError(filePath, `cannot be created: ${(0, path_1.dirname)(target)} takes no new files`);
274
+ if (alreadyWritten(target, filePath, expected, content))
275
+ return;
276
+ writeInPlace(target, content, mode);
277
+ return;
278
+ }
279
+ created = true;
280
+ try {
281
+ (0, fs_1.writeFileSync)(tmpFd, content);
282
+ // After the write: the create mode is cut by the umask.
283
+ (0, fs_1.fchmodSync)(tmpFd, mode ?? (existing ? existing.mode & 0o777 : 0o644));
284
+ // A file root creates (a first login: no config.json yet) goes to the person who ran sudo, as its folder does.
285
+ // The rename-over-an-existing-file case is the existing-owner line below.
286
+ if (!existing) {
287
+ const owner = (0, private_files_js_1.sudoOwner)(tmp);
288
+ if (owner) {
289
+ try {
290
+ (0, fs_1.fchownSync)(tmpFd, owner.uid, owner.gid);
291
+ }
292
+ catch { /* not ours to change */ }
293
+ }
294
+ }
295
+ // The rename makes a new inode owned by whoever runs this: root (sudo, HOME kept) must not take a user's file.
296
+ if (existing && process.getuid?.() === 0 && existing.uid !== 0)
297
+ (0, fs_1.fchownSync)(tmpFd, existing.uid, existing.gid);
298
+ }
299
+ finally {
300
+ (0, fs_1.closeSync)(tmpFd);
301
+ }
302
+ }
303
+ // Narrow the rename-by-name window (no `renameat` in Node): refuse a `tmp` that
304
+ // became a link or gained a second name since the exclusive create.
305
+ const tmpSeen = (0, fs_1.lstatSync)(tmp, { throwIfNoEntry: false });
306
+ if (!tmpSeen?.isFile() || tmpSeen.nlink !== 1) {
307
+ throw new FileChangedError(filePath);
308
+ }
309
+ if (alreadyWritten(target, filePath, expected, content)) {
310
+ // Someone else (a second `hwip install` at the same time) already wrote exactly this: nothing left to do.
311
+ (0, fs_1.unlinkSync)(tmp);
312
+ return;
313
+ }
314
+ (0, fs_1.renameSync)(tmp, target);
315
+ }
316
+ catch (err) {
317
+ if (created) {
318
+ try {
319
+ (0, fs_1.unlinkSync)(tmp);
320
+ }
321
+ catch {
322
+ /* already renamed or removed */
323
+ }
324
+ }
325
+ throw err;
326
+ }
327
+ };
328
+ exports.replaceFile = replaceFile;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `hwip schedule` — add, list and remove scheduled sessions (F8).
3
+ *
4
+ * This only writes and reads the files in `schedule.ts`. The listener starts the sessions
5
+ * (`schedule-runner.ts`), so nothing here needs a running listener except the sessions firing.
6
+ */
7
+ import { type Checked, type Schedule, type ScheduleWhen } from './schedule.js';
8
+ export interface ScheduleCommandDeps {
9
+ now: () => number;
10
+ /** The directory the session is named for (`detectProjectDir`). */
11
+ projectDir: () => string;
12
+ /** The main checkout of the repo `dir` is in; null outside git. For `--wt`. */
13
+ checkoutRoot: (dir: string) => string | null;
14
+ /** Whether git takes `name` as a branch name. For `--wt`. */
15
+ validBranch: (root: string, name: string) => Promise<boolean>;
16
+ listenerRunning: () => boolean;
17
+ /** The agent kinds a prompt can be scheduled for: those whose detection rules can say `idle` (`schedule-ready.ts`). */
18
+ promptKinds: () => readonly string[];
19
+ out: (line: string) => void;
20
+ err: (line: string) => void;
21
+ }
22
+ /** `daily 09:00`, `weekdays 09:00`, `mon,wed 09:00`, `every 30m`, `every 2h`. */
23
+ export declare const describeWhen: (when: ScheduleWhen) => string;
24
+ interface Built {
25
+ make: (id: string) => Schedule;
26
+ agentName: string;
27
+ }
28
+ /** The schedule `hwip schedule add <words>` asks for, before it has an id. */
29
+ export declare const buildSchedule: (words: string[], deps: ScheduleCommandDeps) => Promise<Checked<Built>>;
30
+ /** `words`: argv after `schedule`. Returns the exit code. */
31
+ export declare const runSchedule: (words: string[], deps: ScheduleCommandDeps) => Promise<number>;
32
+ export declare const liveScheduleDeps: () => ScheduleCommandDeps;
33
+ export declare const handleSchedule: (words: string[]) => Promise<number>;
34
+ export {};
35
+ //# sourceMappingURL=schedule-command.d.ts.map