@hwip/cli 0.1.23 → 0.1.25

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.
@@ -0,0 +1,225 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.backgroundTasksFor = exports.recordBackgroundTasks = exports.trackBackgroundTasks = exports.lsofWritersAsync = exports.lsofWriters = exports.runProbe = exports.lsofBin = exports.initialBackgroundTaskState = exports.MAX_BACKGROUND_TASKS = exports.BACKGROUND_TASK_PROBE_MS = void 0;
4
+ /**
5
+ * Background commands a Claude Code session is running (`Bash` with
6
+ * `run_in_background: true`), read off its transcript.
7
+ *
8
+ * The phone shows them above the composer and can stop one. Measured
9
+ * 2026-11-11 (Claude Code 2.x, `hwip cc`):
10
+ *
11
+ * - The launch is an assistant `tool_use` named `Bash` whose input has
12
+ * `run_in_background: true` and a `description` Claude wrote for it.
13
+ * - Its `tool_result` reads `Command running in background with ID: <id>.
14
+ * Output is being written to: <path>` and carries the id again on
15
+ * `toolUseResult.backgroundTaskId`.
16
+ * - The end is a `<task-notification>` naming the id. Current builds write it
17
+ * the moment the task ends — mid-turn too — as a `queue-operation`
18
+ * (`operation: enqueue`) and a `queued_command` attachment; earlier ones as
19
+ * a user turn with `origin.kind: 'task-notification'`. All three are read.
20
+ *
21
+ * Only the harness's own records count as an end: a tool result that prints
22
+ * a notice (a session reading a transcript) carries the words but is not an
23
+ * event. The command line is never kept — it is what a secret would be typed
24
+ * into; the description is what the phone shows.
25
+ *
26
+ * A session whose Claude died never writes the notice, and the task's process
27
+ * may be gone with it. So a running task also needs a live writer on its
28
+ * output file, asked at most once per `BACKGROUND_TASK_PROBE_MS` for all of a
29
+ * session's tasks together.
30
+ */
31
+ const node_fs_1 = require("node:fs");
32
+ const node_child_process_1 = require("node:child_process");
33
+ const subagent_transcripts_js_1 = require("./subagent-transcripts.js");
34
+ const transcript_tail_js_1 = require("./transcript-tail.js");
35
+ /** How often a session's tasks have their output writers checked. */
36
+ exports.BACKGROUND_TASK_PROBE_MS = 15_000;
37
+ /** Most tasks one session keeps — a runaway loop of launches cannot grow the listener without bound. */
38
+ exports.MAX_BACKGROUND_TASKS = 50;
39
+ /** Longest description kept; Claude writes a few words. */
40
+ const MAX_DESCRIPTION_CHARS = 200;
41
+ const initialBackgroundTaskState = () => ({
42
+ tail: null,
43
+ launches: new Map(),
44
+ running: new Map(),
45
+ probedAt: Number.NEGATIVE_INFINITY,
46
+ });
47
+ exports.initialBackgroundTaskState = initialBackgroundTaskState;
48
+ const STARTED = /Command running in background with ID: ([A-Za-z0-9]{1,32})\. Output is being written to: (\S+?\.output)\b/;
49
+ const textOf = (content) => {
50
+ if (typeof content === 'string')
51
+ return content;
52
+ if (!Array.isArray(content))
53
+ return null;
54
+ const texts = content.flatMap((b) => b?.type === 'text' && typeof b.text === 'string' ? [b.text] : []);
55
+ return texts.length > 0 ? texts.join('\n') : null;
56
+ };
57
+ /** The notice text of a harness record that reports a task ending, or null. */
58
+ const noticeOf = (entry) => {
59
+ if (entry.type === 'queue-operation' && entry.operation === 'enqueue')
60
+ return typeof entry.content === 'string' ? entry.content : null;
61
+ if (entry.type === 'attachment' && entry.attachment?.commandMode === 'task-notification') {
62
+ return typeof entry.attachment.prompt === 'string' ? entry.attachment.prompt : null;
63
+ }
64
+ if (entry.type === 'user' && entry.origin?.kind === 'task-notification')
65
+ return textOf(entry.message?.content);
66
+ return null;
67
+ };
68
+ const fold = (state, line) => {
69
+ // Cheap reject: every line the session writes passes through here.
70
+ if (!line.includes('run_in_background') && !line.includes('in background with ID') && !line.includes('<task-notification>'))
71
+ return;
72
+ let entry;
73
+ try {
74
+ entry = JSON.parse(line);
75
+ }
76
+ catch {
77
+ return;
78
+ }
79
+ const notice = noticeOf(entry);
80
+ if (notice !== null) {
81
+ const id = subagent_transcripts_js_1.TASK_END.test(notice) ? subagent_transcripts_js_1.TASK_ID.exec(notice)?.[1] : undefined;
82
+ if (id)
83
+ state.running.delete(id);
84
+ return;
85
+ }
86
+ const content = entry.message?.content;
87
+ if (!Array.isArray(content))
88
+ return;
89
+ for (const block of content) {
90
+ if (entry.type === 'assistant' && block?.type === 'tool_use' && block.name === 'Bash' && typeof block.id === 'string') {
91
+ const input = block.input;
92
+ if (input?.run_in_background !== true)
93
+ continue;
94
+ const description = typeof input.description === 'string' && input.description.trim()
95
+ ? input.description.trim().slice(0, MAX_DESCRIPTION_CHARS)
96
+ : null;
97
+ // A launch whose result never comes is forgotten past the cap, like the running list.
98
+ if (state.launches.size >= exports.MAX_BACKGROUND_TASKS)
99
+ state.launches.delete(state.launches.keys().next().value);
100
+ state.launches.set(block.id, description);
101
+ }
102
+ else if (entry.type === 'user' && block?.type === 'tool_result' && typeof block.tool_use_id === 'string') {
103
+ if (!state.launches.has(block.tool_use_id))
104
+ continue;
105
+ const description = state.launches.get(block.tool_use_id) ?? null;
106
+ state.launches.delete(block.tool_use_id);
107
+ const match = STARTED.exec(textOf(block.content) ?? '');
108
+ if (!match)
109
+ continue;
110
+ const id = match[1];
111
+ // The structured id, when there, must agree with the text: two answers to "which task" are no answer.
112
+ const structured = entry.toolUseResult?.backgroundTaskId;
113
+ if (structured !== undefined && structured !== id)
114
+ continue;
115
+ if (state.running.size >= exports.MAX_BACKGROUND_TASKS)
116
+ continue;
117
+ state.running.set(id, {
118
+ id,
119
+ description,
120
+ startedAt: typeof entry.timestamp === 'string' ? entry.timestamp : null,
121
+ outputPath: match[2],
122
+ });
123
+ }
124
+ }
125
+ };
126
+ const realPathOrSelf = (p) => {
127
+ try {
128
+ return (0, node_fs_1.realpathSync)(p);
129
+ }
130
+ catch {
131
+ return p;
132
+ }
133
+ };
134
+ const LSOF_CANDIDATES = ['/usr/sbin/lsof', '/usr/bin/lsof'];
135
+ const PROBE_TIMEOUT_MS = 3_000;
136
+ const PROBE_MAX_BUFFER = 16 * 1024 * 1024;
137
+ /** The lsof binary, or null when this machine has none. */
138
+ const lsofBin = () => LSOF_CANDIDATES.find((candidate) => (0, node_fs_1.existsSync)(candidate)) ?? null;
139
+ exports.lsofBin = lsofBin;
140
+ /**
141
+ * Run a probe (`lsof`, `ps`) without blocking the main thread, which serves
142
+ * the socket (inventory-offload.ts). Its stdout, or null on a spawn failure,
143
+ * a timeout or an exit status outside `okCodes` — lsof's exit 1 ("open
144
+ * nowhere") is still an answer.
145
+ */
146
+ const runProbe = (bin, args, okCodes) => new Promise((resolve) => {
147
+ (0, node_child_process_1.execFile)(bin, args, { encoding: 'utf8', timeout: PROBE_TIMEOUT_MS, maxBuffer: PROBE_MAX_BUFFER, env: { ...process.env, LC_ALL: 'C' } }, (err, stdout) => {
148
+ const code = err?.code;
149
+ resolve(!err || (typeof code === 'number' && okCodes.includes(code)) ? stdout : null);
150
+ });
151
+ });
152
+ exports.runProbe = runProbe;
153
+ /**
154
+ * `lsof -F n -- <paths>`: one `n<name>` line per open file. lsof exits 1 when
155
+ * any one of the paths is open nowhere — still an answer, printed for the
156
+ * rest; a spawn failure, a timeout or another status is no answer.
157
+ */
158
+ const lsofWriters = (paths) => {
159
+ const bin = (0, exports.lsofBin)();
160
+ if (!bin || paths.length === 0)
161
+ return null;
162
+ const r = (0, node_child_process_1.spawnSync)(bin, ['-F', 'n', '--', ...paths], { encoding: 'utf8', timeout: PROBE_TIMEOUT_MS });
163
+ if (r.error || (r.status !== 0 && r.status !== 1))
164
+ return null;
165
+ return openAmong(paths, r.stdout);
166
+ };
167
+ exports.lsofWriters = lsofWriters;
168
+ /** `lsofWriters` without blocking: a synchronous spawn on the main thread delays frames and pongs. */
169
+ const lsofWritersAsync = async (paths) => {
170
+ const bin = (0, exports.lsofBin)();
171
+ if (!bin || paths.length === 0)
172
+ return null;
173
+ const out = await (0, exports.runProbe)(bin, ['-F', 'n', '--', ...paths], [1]);
174
+ return out === null ? null : openAmong(paths, out);
175
+ };
176
+ exports.lsofWritersAsync = lsofWritersAsync;
177
+ /** Which of `paths` lsof's `-F n` output names. lsof names a file by its real path (/tmp is /private/tmp on macOS). */
178
+ const openAmong = (paths, stdout) => {
179
+ const open = new Set();
180
+ for (const row of stdout.split('\n'))
181
+ if (row.startsWith('n'))
182
+ open.add(row.slice(1));
183
+ return new Set(paths.filter((p) => open.has(p) || open.has(realPathOrSelf(p))));
184
+ };
185
+ /**
186
+ * Fold what the transcript appended since the last call, then drop tasks
187
+ * whose output nobody writes any more (at most once per probe window).
188
+ * Returns the running tasks in launch order.
189
+ */
190
+ const trackBackgroundTasks = (transcriptPath, state, opts = {}) => {
191
+ // From the start: a command launched before the last 256 KB of turns is still running (a dev server), and a
192
+ // window off the end would never see its launch. A long transcript catches up one tick's read at a time.
193
+ const read = (0, transcript_tail_js_1.readTranscriptDelta)(transcriptPath, state.tail, { fromStart: true });
194
+ if (read) {
195
+ state.tail = read.state;
196
+ for (const line of read.lines)
197
+ fold(state, line);
198
+ }
199
+ const now = opts.now ?? Date.now();
200
+ if (state.running.size > 0 && now - state.probedAt >= exports.BACKGROUND_TASK_PROBE_MS) {
201
+ state.probedAt = now;
202
+ const tasks = [...state.running.values()];
203
+ const open = (opts.probe ?? exports.lsofWriters)(tasks.map((t) => t.outputPath));
204
+ if (open)
205
+ for (const t of tasks)
206
+ if (!open.has(t.outputPath))
207
+ state.running.delete(t.id);
208
+ }
209
+ return [...state.running.values()];
210
+ };
211
+ exports.trackBackgroundTasks = trackBackgroundTasks;
212
+ /**
213
+ * The main thread's copy of the last sweep's tasks. The sweep may run on the
214
+ * inventory worker, whose module state the main thread cannot see, so the
215
+ * result travels back on `CollectResult` and lands here — the phone's
216
+ * requests are answered from this.
217
+ */
218
+ let tasksBySession = new Map();
219
+ const recordBackgroundTasks = (bySession) => {
220
+ tasksBySession = new Map(Object.entries(bySession ?? {}));
221
+ };
222
+ exports.recordBackgroundTasks = recordBackgroundTasks;
223
+ /** The session's running background commands as of the last sweep; null when it has none. */
224
+ const backgroundTasksFor = (sessionName) => tasksBySession.get(sessionName) ?? null;
225
+ exports.backgroundTasksFor = backgroundTasksFor;
package/dist/crypto.d.ts CHANGED
@@ -204,7 +204,7 @@ export declare const decryptPushBodyForDevice: (body: string, entryJson: string,
204
204
  schedule?: Record<string, unknown>;
205
205
  context?: SealedAskContext;
206
206
  }>;
207
- export type SealPurpose = 'stream.frame' | 'stream.input' | 'stream.alive' | 'stream.started' | 'screen.snapshot' | 'screen.history' | 'session.diff' | 'schedule.list' | 'turn.batch';
207
+ export type SealPurpose = 'stream.frame' | 'stream.input' | 'stream.alive' | 'stream.started' | 'screen.snapshot' | 'screen.history' | 'session.diff' | 'schedule.list' | 'agent.tasks' | 'agent.task.stop' | 'agent.task.output' | 'turn.batch';
208
208
  export interface SealedV2 {
209
209
  v: 2;
210
210
  /** Base64, 32 bytes — the HKDF salt, fresh per message. */
@@ -76,16 +76,26 @@ export interface ProxyConfig {
76
76
  *
77
77
  * Not through Node's own `http.setGlobalProxyFromEnv`: it exists only from 24.14
78
78
  * and 25.4, and the CLI runs on older Nodes. `fetch` gets undici's
79
- * `EnvHttpProxyAgent` as the global dispatcher (undici 6, Node >= 18.17); `ws`
79
+ * `EnvHttpProxyAgent` as the global dispatcher (undici 6, Node >= 18.17 — Node 26's
80
+ * own fetch ignores it; the daemon swaps in undici's fetch, `useHttp1Transport`); `ws`
80
81
  * opens its own connection, so it gets a CONNECT-tunnelling agent from
81
- * `https-proxy-agent` (`webSocketProxyOptions`). Both libraries load only here,
82
- * so a run with no proxy pays nothing for them.
82
+ * `https-proxy-agent` (`webSocketProxyOptions`). Both libraries load only here
83
+ * (undici also in the daemon), so a command run with no proxy pays nothing for them.
83
84
  *
84
85
  * A proxy that cannot be set up is an error, not a direct connection: going
85
86
  * around a proxy someone configured is the wrong way to fail. The error names
86
87
  * the setting and never quotes it: the value may carry the proxy's password.
87
88
  */
88
89
  export declare const useProxyFromEnv: (env?: ProxyEnv, config?: ProxyConfig) => Promise<void>;
90
+ /**
91
+ * Pin the daemon's `fetch` to HTTP/1.1: an aborted HTTP/2 upload on Node 26 wedged the
92
+ * event loop in nghttp2's send loop (one shared session); HTTP/1.1 gives each request its
93
+ * own socket. Node 26's fetch (undici 8) reads `undici.globalDispatcher.2`, undici 6 writes
94
+ * `.1`, and an undici 6 dispatcher handed to Node 26's fetch fails "invalid onError method"
95
+ * — so the fetch is swapped for undici 6's own, which also makes `useProxyFromEnv`'s proxy
96
+ * agent apply again. Daemon only (`cli-lazy-load.test.ts`); call after `useProxyFromEnv`.
97
+ */
98
+ export declare const useHttp1Transport: () => Promise<void>;
89
99
  /**
90
100
  * Options for the listener's `new WebSocket`: `ws` opens its own connection
91
101
  * and never reaches fetch's dispatcher, so with a proxy set it is handed an
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.webSocketProxyOptions = exports.useProxyFromEnv = exports.proxyForUrl = exports.AUTH_HINT = exports.httpFailureMessage = exports.readJsonBody = exports.networkFailureMessage = exports.isUnsendableHeader = exports.isConfigFailure = exports.isInvalidUrl = exports.neverConnected = exports.isRedirectRefusal = void 0;
3
+ exports.webSocketProxyOptions = exports.useHttp1Transport = exports.useProxyFromEnv = exports.proxyForUrl = exports.AUTH_HINT = exports.httpFailureMessage = exports.readJsonBody = exports.networkFailureMessage = exports.isUnsendableHeader = exports.isConfigFailure = exports.isInvalidUrl = exports.neverConnected = exports.isRedirectRefusal = void 0;
4
4
  const redact_js_1 = require("./redact.js");
5
5
  const ws_url_js_1 = require("./ws-url.js");
6
6
  /**
@@ -273,10 +273,11 @@ const configProxyValue = (key, config) => {
273
273
  *
274
274
  * Not through Node's own `http.setGlobalProxyFromEnv`: it exists only from 24.14
275
275
  * and 25.4, and the CLI runs on older Nodes. `fetch` gets undici's
276
- * `EnvHttpProxyAgent` as the global dispatcher (undici 6, Node >= 18.17); `ws`
276
+ * `EnvHttpProxyAgent` as the global dispatcher (undici 6, Node >= 18.17 — Node 26's
277
+ * own fetch ignores it; the daemon swaps in undici's fetch, `useHttp1Transport`); `ws`
277
278
  * opens its own connection, so it gets a CONNECT-tunnelling agent from
278
- * `https-proxy-agent` (`webSocketProxyOptions`). Both libraries load only here,
279
- * so a run with no proxy pays nothing for them.
279
+ * `https-proxy-agent` (`webSocketProxyOptions`). Both libraries load only here
280
+ * (undici also in the daemon), so a command run with no proxy pays nothing for them.
280
281
  *
281
282
  * A proxy that cannot be set up is an error, not a direct connection: going
282
283
  * around a proxy someone configured is the wrong way to fail. The error names
@@ -334,6 +335,22 @@ const useProxyFromEnv = async (env = process.env, config = {}) => {
334
335
  };
335
336
  };
336
337
  exports.useProxyFromEnv = useProxyFromEnv;
338
+ /**
339
+ * Pin the daemon's `fetch` to HTTP/1.1: an aborted HTTP/2 upload on Node 26 wedged the
340
+ * event loop in nghttp2's send loop (one shared session); HTTP/1.1 gives each request its
341
+ * own socket. Node 26's fetch (undici 8) reads `undici.globalDispatcher.2`, undici 6 writes
342
+ * `.1`, and an undici 6 dispatcher handed to Node 26's fetch fails "invalid onError method"
343
+ * — so the fetch is swapped for undici 6's own, which also makes `useProxyFromEnv`'s proxy
344
+ * agent apply again. Daemon only (`cli-lazy-load.test.ts`); call after `useProxyFromEnv`.
345
+ */
346
+ const useHttp1Transport = async () => {
347
+ const undici = await import('undici');
348
+ // With a proxy, its agent is already the dispatcher (HTTP/1.1, undici 6's default).
349
+ if (!active)
350
+ undici.setGlobalDispatcher(new undici.Agent({ allowH2: false }));
351
+ globalThis.fetch = undici.fetch;
352
+ };
353
+ exports.useHttp1Transport = useHttp1Transport;
337
354
  /**
338
355
  * Options for the listener's `new WebSocket`: `ws` opens its own connection
339
356
  * and never reaches fetch's dispatcher, so with a proxy set it is handed an
@@ -25,6 +25,7 @@ import { type ShellAuditEntry } from './shell-audit.js';
25
25
  import { type KnownSession } from './session-registry.js';
26
26
  import { type AgentKind } from './remote-agents.js';
27
27
  import type { ProviderNameSource } from './session-title.js';
28
+ import { type SessionBackgroundTasks } from './background-tasks.js';
28
29
  import { type TranscriptSource } from './transcript-tail.js';
29
30
  import { type AgentState } from './agent-state.js';
30
31
  import { type SealedFileMeta, type SealedV2 } from './crypto.js';
@@ -101,6 +102,9 @@ interface AgentSession {
101
102
  * transcript subagent has one: a pane subagent is watched as a screen, and
102
103
  * nothing counts what happens on it. */
103
104
  toolCount?: number;
105
+ /** Background commands this Claude session is running — a count only;
106
+ * what they are travels sealed (`agent.tasks.request`). Absent at zero. */
107
+ backgroundTaskCount?: number;
104
108
  /** Set on a subagent with no pane at all (Claude Code's `Agent` tool): there
105
109
  * is no screen to stream, only its transcript. Absent on a pane subagent —
106
110
  * which can run Claude Code too, so the kind cannot stand in for this. */
@@ -515,6 +519,63 @@ export interface ScheduleListRequest {
515
519
  * seal is never answered in the clear. Directories and agent arguments are not in the list at all.
516
520
  */
517
521
  export declare const handleScheduleListRequest: (req: ScheduleListRequest, send: (data: Record<string, unknown>) => void) => boolean;
522
+ export interface BackgroundTasksRequest {
523
+ subtype?: string;
524
+ targetDeviceId?: string;
525
+ sessionName?: string;
526
+ requestId?: string;
527
+ subscriberPublicKey?: string;
528
+ /** The asking device, stamped by the relay: whose registered key the one above has to be. */
529
+ deviceId?: string;
530
+ }
531
+ /**
532
+ * Answer a phone's request for the background commands a Claude session runs
533
+ * (`Bash` with `run_in_background`). The rows are Claude's descriptions of
534
+ * them, which can say what a command is for, so the answer is sealed for a key
535
+ * that checked out and bound to the session — never in the clear, whatever
536
+ * this listener's seal history. Each task is checked for a live writer on its
537
+ * output now, not only at the last sweep, so a dead one is not listed.
538
+ */
539
+ export declare const handleBackgroundTasksRequest: (req: BackgroundTasksRequest, send: (data: Record<string, unknown>) => void) => boolean;
540
+ export interface BackgroundTaskStopRequest {
541
+ subtype?: string;
542
+ targetDeviceId?: string;
543
+ sessionName?: string;
544
+ requestId?: string;
545
+ subscriberPublicKey?: string;
546
+ /** `{v: 1, requestId, taskId, at}`, sealed by the phone for this listener (`agent.task.stop`), bound to the session. */
547
+ encrypted?: unknown;
548
+ deviceId?: string;
549
+ }
550
+ /**
551
+ * Stop a background command a Claude session runs, for a trusted phone. The
552
+ * one remote path here that ends a process, so the request has to prove who
553
+ * sent it: it is sealed by the phone's key, which must be the asking device's
554
+ * trusted key, and names the request, the task and its time inside the seal.
555
+ * A key alone is public and proves nothing. The phone names a task by id;
556
+ * which process group that is, and whether it may be signalled at all, is
557
+ * decided on this machine (`background-tasks-stop.ts`). The answer is a status
558
+ * in the clear: it says nothing about the task beyond whether it was stopped.
559
+ */
560
+ export declare const handleBackgroundTaskStopRequest: (req: BackgroundTaskStopRequest, send: (data: Record<string, unknown>) => void) => boolean;
561
+ export interface BackgroundTaskOutputRequest {
562
+ subtype?: string;
563
+ targetDeviceId?: string;
564
+ sessionName?: string;
565
+ requestId?: string;
566
+ /** Which task, by the id the list gave. Never a path: the path is the one the transcript gave. */
567
+ taskId?: unknown;
568
+ subscriberPublicKey?: string;
569
+ deviceId?: string;
570
+ }
571
+ /**
572
+ * Answer a phone's request for the end of a background command's output
573
+ * (`readTaskOutputTail`: the last 16 KB of the file the transcript named, if
574
+ * it is that task's `tasks/<id>.output`). A log can hold anything a command
575
+ * printed, so the answer is sealed for a key that checked out and bound to
576
+ * the session, the request and the task — never in the clear.
577
+ */
578
+ export declare const handleBackgroundTaskOutputRequest: (req: BackgroundTaskOutputRequest, send: (data: Record<string, unknown>) => void) => boolean;
518
579
  export interface SessionResumeRequest {
519
580
  subtype?: string;
520
581
  targetDeviceId?: string;
@@ -1155,6 +1216,10 @@ export interface CollectResult {
1155
1216
  * name → the transcript it writes and the agent kind whose projector reads
1156
1217
  * it. Only sessions this machine can actually read; no subagent rows. */
1157
1218
  recordable: Record<string, RecordableSession>;
1219
+ /** Also local: Claude session name → its running background commands and
1220
+ * pane pid. Built on whichever thread sweeps; the main thread, which
1221
+ * answers the phone's requests, stores it (`recordBackgroundTasks`). */
1222
+ backgroundTasks: Record<string, SessionBackgroundTasks>;
1158
1223
  }
1159
1224
  /**
1160
1225
  * Inventory pass that also records *why* each `hwip-*` session was