@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,56 @@
1
+ /**
2
+ * Telling the phone why a command it sent was not typed (backlog C9).
3
+ *
4
+ * A refusal used to leave one log line on this machine, so the phone kept
5
+ * showing "sent" for a command that never landed. The addressed listener now
6
+ * answers on the ephemeral relay it already holds open, with
7
+ *
8
+ * { subtype: 'agent.command.refused', sessionName, pushId, reason }
9
+ *
10
+ * (the relay broadcasts it to the account's devices and stamps `deviceId` with
11
+ * this listener, as for every listener frame). The app matches it to the push
12
+ * it sent by `pushId`. Codes only: never the command text, keys or paths. A
13
+ * listener or an app that does not know the subtype ignores it; a code the app
14
+ * does not know is shown as a generic refusal.
15
+ *
16
+ * Stream input (`agent.command.input`) already had its own answer, the
17
+ * `input_rejected` stream-error frame; it carries the same codes in an
18
+ * optional `reason` field.
19
+ */
20
+ export declare const COMMAND_REFUSALS: readonly ["rate_limited", "unknown_session", "subagent_view_only", "shell_pane", "shell_input", "unknown_key", "invalid_command", "attachment_failed", "sealed_rejected", "inject_failed", "e2ee_required", "prompt_changed", "schedule_invalid", "schedule_limit", "schedule_failed"];
21
+ export type CommandRefusal = (typeof COMMAND_REFUSALS)[number];
22
+ export interface CommandRefusedFrame {
23
+ subtype: 'agent.command.refused';
24
+ sessionName: string;
25
+ pushId: string;
26
+ reason: CommandRefusal;
27
+ }
28
+ export declare const commandRefusedFrame: (sessionName: string, pushId: string, reason: CommandRefusal) => CommandRefusedFrame;
29
+ /**
30
+ * What the relay may learn. `shell_input` (a control character or newline in
31
+ * the text), `invalid_command` (empty text) and the two schedule reasons (a
32
+ * limit hit, a field wrong) are read off the text itself; for a sealed command
33
+ * that would tell the relay something about plaintext it must not see, so they
34
+ * go out as the generic `sealed_rejected`.
35
+ */
36
+ export declare const reasonForPhone: (reason: CommandRefusal, sealed: boolean) => CommandRefusal;
37
+ export declare const isSessionStateRefusal: (reason: CommandRefusal) => boolean;
38
+ /** Frames per session per minute. A phone flooding a session refuses itself blind past this; the log keeps counting. */
39
+ export declare const REFUSAL_FRAMES_PER_MINUTE = 30;
40
+ export declare const REFUSAL_WINDOW_MS = 60000;
41
+ /** Sessions the limiter tracks inside one window; the scan per call is bounded by this. */
42
+ export declare const REFUSAL_TRACKED_SESSIONS = 256;
43
+ export interface RefusalLimiter {
44
+ (key: string, now?: number): boolean;
45
+ /** Sessions currently tracked. */
46
+ size: () => number;
47
+ }
48
+ /** A sliding count per key: `max` yeses per `windowMs`, idle keys forgotten. */
49
+ export declare const createRefusalLimiter: (max: number, windowMs: number, maxKeys?: number) => RefusalLimiter;
50
+ /** The function the listener's WS loop hands `handlePush`: one frame per refusal, within the per-session limit. */
51
+ export declare const createRefusalReporter: (send: (data: Record<string, unknown>) => void, allow: RefusalLimiter) => ((refusal: {
52
+ sessionName: string;
53
+ pushId: string;
54
+ reason: CommandRefusal;
55
+ }) => void);
56
+ //# sourceMappingURL=command-refusal.d.ts.map
@@ -0,0 +1,127 @@
1
+ "use strict";
2
+ /**
3
+ * Telling the phone why a command it sent was not typed (backlog C9).
4
+ *
5
+ * A refusal used to leave one log line on this machine, so the phone kept
6
+ * showing "sent" for a command that never landed. The addressed listener now
7
+ * answers on the ephemeral relay it already holds open, with
8
+ *
9
+ * { subtype: 'agent.command.refused', sessionName, pushId, reason }
10
+ *
11
+ * (the relay broadcasts it to the account's devices and stamps `deviceId` with
12
+ * this listener, as for every listener frame). The app matches it to the push
13
+ * it sent by `pushId`. Codes only: never the command text, keys or paths. A
14
+ * listener or an app that does not know the subtype ignores it; a code the app
15
+ * does not know is shown as a generic refusal.
16
+ *
17
+ * Stream input (`agent.command.input`) already had its own answer, the
18
+ * `input_rejected` stream-error frame; it carries the same codes in an
19
+ * optional `reason` field.
20
+ */
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.createRefusalReporter = exports.createRefusalLimiter = exports.REFUSAL_TRACKED_SESSIONS = exports.REFUSAL_WINDOW_MS = exports.REFUSAL_FRAMES_PER_MINUTE = exports.isSessionStateRefusal = exports.reasonForPhone = exports.commandRefusedFrame = exports.COMMAND_REFUSALS = void 0;
23
+ exports.COMMAND_REFUSALS = [
24
+ /** More commands for this session than the per-minute budget allows. */
25
+ 'rate_limited',
26
+ /** No tmux session of that name on this machine. */
27
+ 'unknown_session',
28
+ /** The session is a subagent pane: view-only, the phone cannot type into it. */
29
+ 'subagent_view_only',
30
+ /** The pane sits at a shell prompt and is not an `hwip sh` session. */
31
+ 'shell_pane',
32
+ /** An `hwip sh` session refuses the input: attachments, control characters, a newline in an insert. */
33
+ 'shell_input',
34
+ /** A key name that is not on the allowed list. */
35
+ 'unknown_key',
36
+ /** The command makes no sense as sent (empty, or an insert beside a body). */
37
+ 'invalid_command',
38
+ /** A sealed attachment would not open or was missing, so the command was not typed. */
39
+ 'attachment_failed',
40
+ /** A sealed command that did not check out: sender key, label, replay. */
41
+ 'sealed_rejected',
42
+ /** The checks passed but tmux would not take the keystrokes. */
43
+ 'inject_failed',
44
+ /** The command came in plaintext, and this listener only takes sealed ones (body, keys, insert or attachment). */
45
+ 'e2ee_required',
46
+ /** The command was bound to a blocked prompt (`ifPrompt`) and the screen shows another one, or none. */
47
+ 'prompt_changed',
48
+ /** A schedule request that is malformed, past a limit on one schedule, or names no schedule of that session. */
49
+ 'schedule_invalid',
50
+ /** A schedule request that would make more schedules than a machine may have. */
51
+ 'schedule_limit',
52
+ /** A valid schedule request this machine could not carry out: its schedule files or session record would not read or write. */
53
+ 'schedule_failed',
54
+ ];
55
+ /** A push chooses these two strings; the listener echoes no more than this of either. */
56
+ const MAX_ECHO_CHARS = 200;
57
+ /** The first MAX_ECHO_CHARS UTF-16 units, minus a surrogate pair the cap would cut in half. */
58
+ const capEcho = (text) => {
59
+ const cut = text.slice(0, MAX_ECHO_CHARS);
60
+ const last = cut.charCodeAt(cut.length - 1);
61
+ return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut;
62
+ };
63
+ const commandRefusedFrame = (sessionName, pushId, reason) => ({
64
+ subtype: 'agent.command.refused',
65
+ sessionName: capEcho(sessionName),
66
+ pushId: capEcho(pushId),
67
+ reason,
68
+ });
69
+ exports.commandRefusedFrame = commandRefusedFrame;
70
+ /** Reasons read off the plaintext of a sealed command, which the relay must not learn. */
71
+ const TEXT_DEPENDENT_REFUSALS = ['shell_input', 'invalid_command', 'schedule_invalid', 'schedule_limit'];
72
+ /**
73
+ * What the relay may learn. `shell_input` (a control character or newline in
74
+ * the text), `invalid_command` (empty text) and the two schedule reasons (a
75
+ * limit hit, a field wrong) are read off the text itself; for a sealed command
76
+ * that would tell the relay something about plaintext it must not see, so they
77
+ * go out as the generic `sealed_rejected`.
78
+ */
79
+ const reasonForPhone = (reason, sealed) => sealed && TEXT_DEPENDENT_REFUSALS.includes(reason) ? 'sealed_rejected' : reason;
80
+ exports.reasonForPhone = reasonForPhone;
81
+ /**
82
+ * Reasons that come from the state of the session or the listener, never from
83
+ * the text. Only these ride a stream `input_rejected` frame: its text may be
84
+ * sealed, and a refusal that depends on it must not be told to the relay.
85
+ */
86
+ const SESSION_STATE_REFUSALS = [
87
+ 'rate_limited', 'unknown_session', 'subagent_view_only', 'shell_pane', 'inject_failed', 'prompt_changed',
88
+ ];
89
+ const isSessionStateRefusal = (reason) => SESSION_STATE_REFUSALS.includes(reason);
90
+ exports.isSessionStateRefusal = isSessionStateRefusal;
91
+ /** Frames per session per minute. A phone flooding a session refuses itself blind past this; the log keeps counting. */
92
+ exports.REFUSAL_FRAMES_PER_MINUTE = 30;
93
+ exports.REFUSAL_WINDOW_MS = 60_000;
94
+ /** Sessions the limiter tracks inside one window; the scan per call is bounded by this. */
95
+ exports.REFUSAL_TRACKED_SESSIONS = 256;
96
+ /** A sliding count per key: `max` yeses per `windowMs`, idle keys forgotten. */
97
+ const createRefusalLimiter = (max, windowMs, maxKeys = exports.REFUSAL_TRACKED_SESSIONS) => {
98
+ const seen = new Map();
99
+ const allow = ((key, now = Date.now()) => {
100
+ for (const [k, stamps] of seen) {
101
+ if (stamps.length === 0 || now - stamps[stamps.length - 1] >= windowMs)
102
+ seen.delete(k);
103
+ }
104
+ // A push picks the session name it asks about: past the cap, a name that is
105
+ // not already tracked gets no frame rather than a map entry.
106
+ if (!seen.has(key) && seen.size >= maxKeys)
107
+ return false;
108
+ const recent = (seen.get(key) ?? []).filter((t) => now - t < windowMs);
109
+ if (recent.length >= max) {
110
+ seen.set(key, recent);
111
+ return false;
112
+ }
113
+ recent.push(now);
114
+ seen.set(key, recent);
115
+ return true;
116
+ });
117
+ allow.size = () => seen.size;
118
+ return allow;
119
+ };
120
+ exports.createRefusalLimiter = createRefusalLimiter;
121
+ /** The function the listener's WS loop hands `handlePush`: one frame per refusal, within the per-session limit. */
122
+ const createRefusalReporter = (send, allow) => ({ sessionName, pushId, reason }) => {
123
+ if (!allow(sessionName))
124
+ return;
125
+ send({ ...(0, exports.commandRefusedFrame)(sessionName, pushId, reason) });
126
+ };
127
+ exports.createRefusalReporter = createRefusalReporter;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Scan the machine's installed agent skills into a name-only catalog.
3
+ *
4
+ * Payload discipline (plan PLAN-13-16-18): paths never leave this machine, so
5
+ * the output carries skill names and nothing else.
6
+ *
7
+ * An entry is the slash command itself, so it carries whatever prefix the agent
8
+ * registers the skill under (`AGENT_SKILL_PREFIX`, plugin names for Claude Code).
9
+ *
10
+ * Names come from the directory, not from the file — an ASSUMPTION, not a rule
11
+ * the agents enforce: pi resolves a skill's name as `frontmatter.name` and only
12
+ * falls back to the directory (`dist/core/skills.js:244`, pi 0.85.1), and its
13
+ * docs say so outright ("Pi allows skill names to differ from their parent
14
+ * directory"). It holds on every skill measured here — 132/132 under
15
+ * `~/.claude/skills` and the installed plugins (2026-09-13), 12/12 under
16
+ * `~/.agents/skills` (2026-09-13) — and a skill that breaks it shows the user a
17
+ * command that does not run, which is the same as not listing it.
18
+ *
19
+ * Reading the file would buy the real name, and cost a YAML parser this scanner
20
+ * is not equipped to be: 42 of 43 user skills quote their description and one
21
+ * uses a folded scalar, so the regex this replaced shipped the quotes and
22
+ * truncated to an unbalanced one. The file is opened for nothing but its own
23
+ * existence until a divergent skill is actually measured.
24
+ */
25
+ export interface AgentCommandEntry {
26
+ /** What the user types after `/` — see `AGENT_SKILL_PREFIX`. */
27
+ name: string;
28
+ }
29
+ /** agentKind -> skill entries; agents with no skills are omitted. */
30
+ export type AgentCommandCatalog = Record<string, AgentCommandEntry[]>;
31
+ /** Serialized-catalog budget. Names-only measures ~2KB here; ws frames die at 128KB. */
32
+ export declare const PAYLOAD_LIMIT_BYTES: number;
33
+ export interface ScanResult {
34
+ catalog: AgentCommandCatalog;
35
+ /** Skills cut to fit the budget. Non-zero is worth a log line — never shrink silently. */
36
+ dropped: number;
37
+ }
38
+ /**
39
+ * Scan every agent's skill directories into one catalog. A directory that is
40
+ * absent contributes nothing; a directory that exists and cannot be read throws,
41
+ * and the caller keeps its previous catalog rather than publishing an empty one.
42
+ * Dedup is within each agentKind only — the same skill installed for claude and
43
+ * pi legitimately appears in both.
44
+ */
45
+ export declare const scanAgentCommands: (homeDir: string) => ScanResult;
46
+ //# sourceMappingURL=command-scan.d.ts.map
@@ -0,0 +1,154 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.scanAgentCommands = exports.PAYLOAD_LIMIT_BYTES = void 0;
4
+ const fs_1 = require("fs");
5
+ const path_1 = require("path");
6
+ const agents_js_1 = require("./agents.js");
7
+ /**
8
+ * What each agent registers its skills as. The catalog exists to be typed into
9
+ * the agent, so it carries the invocation token, not the bare directory name.
10
+ *
11
+ * pi registers `skill:<name>` (`docs/skills.md § Skill Commands`, and
12
+ * `` `skill:${skill.name}` `` in its bundle, pi 0.85.1); typing `/01-plan` in a
13
+ * pi session was observed to abort rather than run the skill (2026-09-13).
14
+ * Claude Code's user skills are `/<name>`; its PLUGIN skills are
15
+ * `/<plugin>:<name>`, so those get their prefix from the install entry instead.
16
+ */
17
+ const AGENT_SKILL_PREFIX = {
18
+ pi: 'skill:',
19
+ };
20
+ /** Agents that register skills filed under grouping folders — see `scanSkillDir`. */
21
+ const AGENT_SKILL_RECURSIVE = new Set(['pi']);
22
+ /** Serialized-catalog budget. Names-only measures ~2KB here; ws frames die at 128KB. */
23
+ exports.PAYLOAD_LIMIT_BYTES = 64 * 1024;
24
+ /**
25
+ * True when `<dir>/SKILL.md` is a readable file. This is the whole membership
26
+ * test: it rejects non-skill directories, and it rejects dangling symlinks
27
+ * (`~/.agents/skills` had three) because the stat resolves through the link.
28
+ */
29
+ const isSkillDir = (dir) => {
30
+ try {
31
+ return (0, fs_1.statSync)((0, path_1.join)(dir, 'SKILL.md')).isFile();
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ };
37
+ /** A directory that is simply not there is not an error — every other one is. */
38
+ const isMissing = (err) => {
39
+ const code = err?.code;
40
+ return code === 'ENOENT' || code === 'ENOTDIR';
41
+ };
42
+ /**
43
+ * Directory entries, or none when the directory does not exist.
44
+ *
45
+ * Anything else — EACCES on a mode-000 skills dir, EIO on a stale network mount
46
+ * — is rethrown rather than read as "no skills". Swallowing it would report an
47
+ * empty catalog and blank the phone's menu, which is precisely the outcome the
48
+ * listener's try/catch keeps the previous catalog to avoid.
49
+ */
50
+ const entriesOf = (dir) => {
51
+ try {
52
+ return (0, fs_1.readdirSync)(dir);
53
+ }
54
+ catch (err) {
55
+ if (isMissing(err))
56
+ return [];
57
+ throw err;
58
+ }
59
+ };
60
+ /**
61
+ * How deep below a skills root a `SKILL.md` is still found. pi walks the tree
62
+ * (`loadSkillsFromDirInternal`, `dist/core/skills.js:160`), so a skill filed
63
+ * under a grouping folder is real to it; two levels covers the grouping folders
64
+ * its docs describe without turning a stray checkout into a scan of the disk.
65
+ */
66
+ const MAX_SKILL_DEPTH = 3;
67
+ /**
68
+ * Skill directory names under `dir`. A directory holding `SKILL.md` IS the
69
+ * skill and is not descended into — that is pi's own rule, and it keeps a
70
+ * skill's bundled fixtures from being read as more skills.
71
+ *
72
+ * `recursive` mirrors the agent: pi groups skills in folders, while Claude Code
73
+ * registers exactly `~/.claude/skills/<name>` — descending there would invent
74
+ * commands out of directories it holds but does not register (`_shared/`).
75
+ */
76
+ const scanSkillDir = (dir, recursive = false, depth = 1) => {
77
+ const names = [];
78
+ for (const entry of entriesOf(dir)) {
79
+ if (entry.startsWith('.') || entry === 'node_modules')
80
+ continue;
81
+ const child = (0, path_1.join)(dir, entry);
82
+ if (isSkillDir(child)) {
83
+ names.push(entry);
84
+ }
85
+ else if (recursive && depth < MAX_SKILL_DEPTH) {
86
+ names.push(...scanSkillDir(child, true, depth + 1));
87
+ }
88
+ }
89
+ return names;
90
+ };
91
+ /**
92
+ * Plugin skills live at `<installPath>/skills/<name>/SKILL.md`. Only install paths
93
+ * listed in `installed_plugins.json` are scanned — the plugin cache also holds
94
+ * uninstalled marketplace entries (514 of 710 on this machine) that must not
95
+ * appear.
96
+ */
97
+ const scanClaudePlugins = (home) => {
98
+ let parsed;
99
+ try {
100
+ parsed = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(home, '.claude', 'plugins', 'installed_plugins.json'), 'utf-8'));
101
+ }
102
+ catch {
103
+ return [];
104
+ }
105
+ const names = [];
106
+ for (const [key, installs] of Object.entries(parsed.plugins ?? {})) {
107
+ // Key is `<plugin>@<marketplace>`; the command prefix is the plugin half.
108
+ const plugin = key.split('@')[0];
109
+ for (const install of installs ?? []) {
110
+ if (!install.installPath)
111
+ continue;
112
+ names.push(...scanSkillDir((0, path_1.join)(install.installPath, 'skills')).map((name) => `${plugin}:${name}`));
113
+ }
114
+ }
115
+ return names;
116
+ };
117
+ /** Byte length of the serialized catalog — the cap the ws frame actually feels. */
118
+ const payloadBytes = (catalog) => Buffer.byteLength(JSON.stringify(catalog), 'utf-8');
119
+ /**
120
+ * Scan every agent's skill directories into one catalog. A directory that is
121
+ * absent contributes nothing; a directory that exists and cannot be read throws,
122
+ * and the caller keeps its previous catalog rather than publishing an empty one.
123
+ * Dedup is within each agentKind only — the same skill installed for claude and
124
+ * pi legitimately appears in both.
125
+ */
126
+ const scanAgentCommands = (homeDir) => {
127
+ const catalog = {};
128
+ for (const [agentId, dirs] of Object.entries(agents_js_1.AGENT_SKILL_DIRS)) {
129
+ const prefix = AGENT_SKILL_PREFIX[agentId] ?? '';
130
+ const recursive = AGENT_SKILL_RECURSIVE.has(agentId);
131
+ const names = dirs.flatMap((dir) => scanSkillDir((0, path_1.join)(homeDir, dir), recursive).map((name) => `${prefix}${name}`));
132
+ if (agentId === 'claude')
133
+ names.push(...scanClaudePlugins(homeDir));
134
+ const merged = [...new Set(names)].map((name) => ({ name }));
135
+ if (merged.length > 0)
136
+ catalog[agentId] = merged;
137
+ }
138
+ // Enforce the serialized budget by dropping whole skills from the tail. The
139
+ // catalog is ~2KB in practice, so this loop is a backstop, not a hot path —
140
+ // it re-measures the real payload each pass rather than keeping a running
141
+ // subtraction that no realistic input would ever exercise.
142
+ let dropped = 0;
143
+ while (payloadBytes(catalog) > exports.PAYLOAD_LIMIT_BYTES) {
144
+ const lastAgent = Object.keys(catalog).pop();
145
+ if (!lastAgent)
146
+ break;
147
+ catalog[lastAgent].pop();
148
+ dropped += 1;
149
+ if (catalog[lastAgent].length === 0)
150
+ delete catalog[lastAgent];
151
+ }
152
+ return { catalog, dropped };
153
+ };
154
+ exports.scanAgentCommands = scanAgentCommands;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `hwip agent send` (F2): the message goes to the target listener inside a push-v1 envelope, so the
3
+ * server relays it and reads nothing of what is typed into the other session.
4
+ *
5
+ * Every message is sealed, as every `hwip ask` question is (ask-seal.ts): when that cannot be done it is refused, never sent
6
+ * in the clear. No phone app is needed — the target is a computer.
7
+ *
8
+ * The receiving listener takes a command only from a device whose key it can check, and the server stamps a send made with
9
+ * an API key `api_<keyId>`, which names no machine. So the sealed label carries `from`, this machine's device id; the listener
10
+ * admits it by the key the account lists for that id and the one it pinned.
11
+ *
12
+ * Who reads the message is the account's device list: the target's slot is the listener's own, and every other listed device
13
+ * that has a key (a phone's bubble opens) gets one. Each device on the list is pinned on the way (`trust-pins.ts`), the target
14
+ * included, so no `hwip trust` comes first; a key that changed replaces its pin with one warning.
15
+ *
16
+ * Nothing here logs or returns a decrypt or encrypt error's message.
17
+ */
18
+ import { type DeviceRecipient, type PushBodyInput } from './crypto.js';
19
+ import { type AskSealDeps, type SealedFields } from './ask-seal.js';
20
+ import { type CommandSeqRefusal } from './command-seq.js';
21
+ import { type RegisterWhy } from './self-register.js';
22
+ export type CommandSealDeps = Pick<AskSealDeps, 'loadKeys' | 'self' | 'listDevices' | 'register' | 'trustPath' | 'warn'> & {
23
+ /** The next command number from the counter every sender on this machine shares. */
24
+ nextNumber?: () => Promise<number | {
25
+ refused: CommandSeqRefusal;
26
+ }>;
27
+ seal?: (content: PushBodyInput, recipients: DeviceRecipient[]) => Promise<SealedFields & {
28
+ isEncrypted: true;
29
+ }>;
30
+ };
31
+ export type CommandRefusal = 'keystore' | 'devices' | 'self-unlisted' | 'target-unlisted' | 'number-locked' | 'number-unusable' | 'encrypt';
32
+ export declare const COMMAND_REFUSAL_TEXT: Record<CommandRefusal, string>;
33
+ export declare const commandRefusalMessage: (cause: CommandRefusal, why?: RegisterWhy) => string;
34
+ /** What `POST /pushes/send` gets for an `agent.command`: the sealed fields and no text at all. */
35
+ export type AgentCommandPayload = {
36
+ targetDeviceId: string;
37
+ agentSessionName: string;
38
+ isEncrypted: true;
39
+ } & SealedFields;
40
+ export type PreparedCommand = {
41
+ kind: 'sealed';
42
+ fields: SealedFields;
43
+ } | {
44
+ kind: 'refused';
45
+ cause: CommandRefusal;
46
+ why?: RegisterWhy;
47
+ };
48
+ /**
49
+ * Seal `message` for `session` on `targetDeviceId`.
50
+ * The caller sends `sealed` with the returned fields (body, key, slots) and tells the user of `refused` without sending.
51
+ */
52
+ export declare const sealedCommand: (command: {
53
+ targetDeviceId: string;
54
+ session: string;
55
+ message: string;
56
+ }, deps: CommandSealDeps) => Promise<PreparedCommand>;
57
+ //# sourceMappingURL=command-seal.d.ts.map
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ /**
3
+ * `hwip agent send` (F2): the message goes to the target listener inside a push-v1 envelope, so the
4
+ * server relays it and reads nothing of what is typed into the other session.
5
+ *
6
+ * Every message is sealed, as every `hwip ask` question is (ask-seal.ts): when that cannot be done it is refused, never sent
7
+ * in the clear. No phone app is needed — the target is a computer.
8
+ *
9
+ * The receiving listener takes a command only from a device whose key it can check, and the server stamps a send made with
10
+ * an API key `api_<keyId>`, which names no machine. So the sealed label carries `from`, this machine's device id; the listener
11
+ * admits it by the key the account lists for that id and the one it pinned.
12
+ *
13
+ * Who reads the message is the account's device list: the target's slot is the listener's own, and every other listed device
14
+ * that has a key (a phone's bubble opens) gets one. Each device on the list is pinned on the way (`trust-pins.ts`), the target
15
+ * included, so no `hwip trust` comes first; a key that changed replaces its pin with one warning.
16
+ *
17
+ * Nothing here logs or returns a decrypt or encrypt error's message.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.sealedCommand = exports.commandRefusalMessage = exports.COMMAND_REFUSAL_TEXT = void 0;
21
+ const crypto_js_1 = require("./crypto.js");
22
+ const command_seq_js_1 = require("./command-seq.js");
23
+ const trust_pins_js_1 = require("./trust-pins.js");
24
+ const self_register_js_1 = require("./self-register.js");
25
+ exports.COMMAND_REFUSAL_TEXT = {
26
+ 'keystore': 'this machine\'s encryption keys did not load; the message was not sent',
27
+ 'devices': 'the device list could not be fetched; the message was not sent',
28
+ 'self-unlisted': (0, self_register_js_1.unregisteredText)('message'),
29
+ 'target-unlisted': 'the target computer is not in the account\'s device list with a key (run `hwip listener` on it once); the message was not sent',
30
+ 'number-locked': 'another hwip process holds ~/.hwip/command-seq.lock; try again in a moment (the message was not sent)',
31
+ 'number-unusable': 'the command counter ~/.hwip/command-seq cannot be read or written; the message was not sent',
32
+ 'encrypt': 'sealing the message failed; the message was not sent',
33
+ };
34
+ const commandRefusalMessage = (cause, why) => `sealed send: ${cause === 'self-unlisted' ? (0, self_register_js_1.unregisteredText)('message', why) : exports.COMMAND_REFUSAL_TEXT[cause]}`;
35
+ exports.commandRefusalMessage = commandRefusalMessage;
36
+ /**
37
+ * Seal `message` for `session` on `targetDeviceId`.
38
+ * The caller sends `sealed` with the returned fields (body, key, slots) and tells the user of `refused` without sending.
39
+ */
40
+ const sealedCommand = async (command, deps) => {
41
+ try {
42
+ await deps.loadKeys();
43
+ }
44
+ catch {
45
+ return { kind: 'refused', cause: 'keystore' };
46
+ }
47
+ const self = deps.self();
48
+ if (!self)
49
+ return { kind: 'refused', cause: 'keystore' };
50
+ let devices;
51
+ try {
52
+ devices = await deps.listDevices();
53
+ if (!Array.isArray(devices))
54
+ throw new Error('not a list');
55
+ }
56
+ catch {
57
+ return { kind: 'refused', cause: 'devices' };
58
+ }
59
+ // The listener admits this machine by the key the list holds for it: one missing or under another key is put there first.
60
+ const listing = await (0, self_register_js_1.ensureSelfListed)(devices, self, deps.register);
61
+ if (!listing.ok)
62
+ return { kind: 'refused', cause: 'self-unlisted', why: listing.why };
63
+ devices = listing.devices;
64
+ (0, trust_pins_js_1.observeDevices)(deps.trustPath, devices, { selfId: self.deviceId, warn: deps.warn ?? (() => undefined) });
65
+ // The target's slot is the listener's own; this computer's key is its own. Everything else the list holds with a key reads too.
66
+ const targetKey = command.targetDeviceId === self.deviceId ? self.publicKey : devices.find((d) => d.deviceId === command.targetDeviceId)?.publicKey;
67
+ if (!targetKey || !(0, crypto_js_1.samePublicKey)(targetKey, targetKey))
68
+ return { kind: 'refused', cause: 'target-unlisted' };
69
+ const readers = (0, crypto_js_1.selectRecipients)(devices).filter((d) => d.deviceId !== self.deviceId && d.deviceId !== command.targetDeviceId);
70
+ const recipients = [{ deviceId: command.targetDeviceId, publicKey: targetKey }, ...readers];
71
+ const n = await (deps.nextNumber ?? (() => (0, command_seq_js_1.nextCommandNumber)()))();
72
+ if (typeof n !== 'number')
73
+ return { kind: 'refused', cause: n.refused === 'lock' ? 'number-locked' : 'number-unusable' };
74
+ try {
75
+ const sealed = await (deps.seal ?? crypto_js_1.encryptPushBodyForDevices)({ body: command.message, cmd: { n, session: command.session, device: command.targetDeviceId, from: self.deviceId } }, recipients);
76
+ // A recipient whose key would not import is skipped by the sealer: the send must not go without the target's slot.
77
+ if (!sealed.deviceKeyMap[command.targetDeviceId])
78
+ return { kind: 'refused', cause: 'encrypt' };
79
+ return {
80
+ kind: 'sealed',
81
+ fields: { body: sealed.body, senderPublicKey: sealed.senderPublicKey, deviceKeyMap: sealed.deviceKeyMap },
82
+ };
83
+ }
84
+ catch {
85
+ return { kind: 'refused', cause: 'encrypt' };
86
+ }
87
+ };
88
+ exports.sealedCommand = sealedCommand;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The number a sealed command carries (`cmd.n`): the listener takes only a number greater than the
3
+ * last it saw from that sender, so every process on this machine that sends as this machine — the
4
+ * CLI and any number of MCP servers — has to draw from one counter. It is a file with a single
5
+ * line, `~/.hwip/command-seq`, read and rewritten under an exclusive lock file beside it.
6
+ *
7
+ * `n = max(last + 1, now)`: the clock keeps numbers moving forward when the file is lost, which
8
+ * would otherwise start over at 1 and have every listener drop the new commands as replays. This is
9
+ * the formula the phone uses for its own counter (`PeerPins.nextCommand`).
10
+ *
11
+ * The MCP server carries a copy of this file; the file's name, its one-line format and the lock's
12
+ * name are the contract between them.
13
+ */
14
+ export type CommandSeqRefusal =
15
+ /** The lock could not be taken: another sender holds it. */
16
+ 'lock'
17
+ /** The counter could not be read, or written, or no longer fits a number. */
18
+ | 'io';
19
+ export interface CommandSeqDeps {
20
+ /** Where the counter lives. Default `~/.hwip`. */
21
+ dir?: string;
22
+ /** The clock, in ms since the epoch. */
23
+ now?: () => number;
24
+ /** Wait between tries for the lock. */
25
+ sleep?: (ms: number) => Promise<void>;
26
+ }
27
+ export declare const nextCommandNumber: (deps?: CommandSeqDeps) => Promise<number | {
28
+ refused: CommandSeqRefusal;
29
+ }>;
30
+ //# sourceMappingURL=command-seq.d.ts.map