@hwip/cli 0.1.22 → 0.1.24

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.
package/README.md CHANGED
@@ -799,6 +799,13 @@ installs on one machine don't fight over it):
799
799
  hwip: listener 1.25.0 is stale — restarting on 1.26.0
800
800
  ```
801
801
 
802
+ With the login service installed, launchd (or systemd) does the restart —
803
+ and it starts the install the plist was written from, which need not be
804
+ the `hwip` on your PATH (Homebrew's next to an nvm node's, say). When the
805
+ restarted daemon is still the older build, `hwip cc` says which install
806
+ the service runs instead of restarting it again on every launch; re-run
807
+ `hwip listener --install-service` from the `hwip` you use to repoint it.
808
+
802
809
  If the PID file is missing (a different account started the daemon, or
803
810
  it was removed by hand) the singleton guard can't see it. Find the real
804
811
  process instead:
@@ -870,6 +877,19 @@ The phone also reads this machine's schedules (see [Scheduled sessions](#schedul
870
877
 
871
878
  `schedules` is the list as a JSON string; `encrypted` is the list sealed for the asking device (purpose `schedule.list`, bound to this machine's device id) as `{ v: 1, requestId, at, schedules: [...], unreadable? }` — the app checks that the `requestId` inside is the one it asked, so an older sealed list is not taken for a newer answer. `unreadable` is how many schedule files could not be read; a directory that cannot be listed at all, or that holds more than 20 files, is the error `read_failed`, never an empty list (and a remove then fails as `schedule_failed`; `hwip schedule rm` still works on the computer). A list too long for one frame has its prompts shortened, step by step (`too_large` only when it does not fit even without them). Each entry holds `id`, `source`, `agentKind`, `project` (the directory's name, not its path), `session`, `when`, `prompt` (cut to 500 characters, shorter when the list would not fit; `promptLocked: true` says the phone cannot change it — it is not the whole text, or the schedule has agent arguments — so the app leaves it to `hwip schedule edit`), `maxRuntimeMin`, `nextAt` and the `last` result — never a directory path or the agent's arguments. `when` is `{ kind: 'daily', at, days }`, `{ kind: 'every', minutes }` or `{ kind: 'once', at }` (an ISO 8601 instant). A schedule into a session also holds `target: 'session'` and the `runId` it types into, and `sessionEnded: true` when that run is over (absent while it runs, or when tmux could not say). `last.outcome` may be `session_busy` or `session_ended` for those.
872
879
 
880
+ Background commands a Claude session runs (`Bash` with `run_in_background`) are read off its transcript. The session report carries only their number, `backgroundTaskCount`; the phone asks for the rest:
881
+
882
+ ```
883
+ { subtype: 'agent.tasks.request', targetDeviceId, sessionName, requestId, subscriberPublicKey }
884
+ { subtype: 'agent.tasks.result', requestId, sessionName, at, encrypted | error }
885
+ { subtype: 'agent.task.output.request', targetDeviceId, sessionName, requestId, taskId, subscriberPublicKey }
886
+ { subtype: 'agent.task.output.result', requestId, sessionName, at, encrypted | error }
887
+ { subtype: 'agent.task.stop.request', targetDeviceId, sessionName, requestId, subscriberPublicKey, encrypted }
888
+ { subtype: 'agent.task.stop.result', requestId, sessionName, at, result | error }
889
+ ```
890
+
891
+ The list is sealed for the asking device (purpose `agent.tasks`, bound to the session) as `{ v: 1, requestId, at, tasks: [{ id, description, startedAt }] }` — Claude's description of each command, never the command line or where its output goes, and only commands whose output some process still writes (`lsof`). The end of one command's output is sealed the same way (purpose `agent.task.output`) as `{ v: 1, requestId, at, taskId, text, size, truncated }`: the last 16 KB of the file the transcript named, cut to whole characters and from the top again when the sealed frame would pass 20 KB (a coloured log's escape codes grow six-fold in JSON), read only when its real path is a `tasks/<taskId>.output` file with no other name (a symlink out of there, a hard link, a directory or another task's file is `path_refused`; a task this session does not run, or a file that is gone, is `not_found`; `read_failed` and `encrypt_failed` say what broke). A stop request seals `{ v: 1, requestId, taskId, at }` for this machine (purpose `agent.task.stop`). The listener finds the process group to signal on its own: the one group whose leader writes the task's output file and descends from the session's pane, never this listener's group, the pane's foreground group (Claude itself) or one between the leader and the pane. It sends that group `SIGTERM` and answers `result: 'stopped'`; otherwise it signals nothing and answers `not_found` (nobody writes the output, or the command left its group), `ambiguous` (more than one group qualifies), `refused` (a group a stop never ends, or an output that is not the task's own `tasks/<taskId>.output` file) or `kill_failed`. Claude then writes the task's end, and the count drops on the next report. Errors are `e2ee_required` (no key), `subscriber_unverified`, `bad_request` (an envelope that does not open, a mismatch, a stale or repeated request, a task id that is not one) and `rate_limited`.
892
+
873
893
  ### Your API key and redirects
874
894
 
875
895
  Every request that carries your API key refuses to follow a redirect: a host
@@ -1005,7 +1025,8 @@ handing the listener a key:
1005
1025
  inside envelope v2: ECDH P-256 between the two device keys, HKDF-SHA256
1006
1026
  with a fresh salt and the message's purpose (`stream.frame`,
1007
1027
  `stream.input`, `stream.alive`, `stream.started`, `screen.snapshot`,
1008
- `screen.history`, `session.diff`, `schedule.list`, `turn.batch`) as the key's info, and
1028
+ `screen.history`, `session.diff`, `schedule.list`, `agent.tasks`,
1029
+ `agent.task.output`, `agent.task.stop`, `turn.batch`) as the key's info, and
1009
1030
  AES-256-GCM over data that names the purpose, both keys in send →
1010
1031
  receive order and the session. The sealed frame also holds the
1011
1032
  session name, its `seq`/`epoch` and that nonce, so a relay that cannot
@@ -1076,6 +1097,16 @@ request without a key while this listener pins no device, and otherwise an
1076
1097
  error code (`e2ee_required`, `subscriber_unverified`, …) and no schedule. Prompts are in
1077
1098
  it, so a request that asked for a seal is never answered in the clear.
1078
1099
 
1100
+ A Claude session's background commands — the answer to `agent.tasks.request`,
1101
+ and the end of one's output for `agent.task.output.request` — are sealed for a
1102
+ key that checked out, always: there is no plaintext answer,
1103
+ pinned devices or not. Stopping one (`agent.task.stop.request`) ends a
1104
+ process, so the request itself is sealed by the phone's key (purpose
1105
+ `agent.task.stop`, bound to the session) and that key has to be the asking
1106
+ device's trusted one; the request id, task id and time inside the seal must
1107
+ match the frame, sit within five minutes of this machine's clock, and not have
1108
+ been acted on before.
1109
+
1079
1110
  **Reading a session's changes** (`agent.diff.files.request` and
1080
1111
  `agent.diff.file.request`) is sealed both ways and never answered in the
1081
1112
  clear: a request without a key gets `e2ee_required` whether or not this
@@ -1353,7 +1384,7 @@ hwip notify --title "Hello" --json
1353
1384
  | `forget <session>` | Drop an ended session from this machine's record, so it leaves the app's past list (and the resume whitelist — the same thing deleting the row on the phone does). Refuses a session that is still running: kill it first. `--ended` forgets every remembered session tmux no longer has, which is what a machine that launches agents under a fresh name per task eventually needs |
1354
1385
  | `test` | Verify connection and API key |
1355
1386
  | `trust [deviceId]` | Advanced — nothing needs it: every device on the account is pinned the first time hwip seals for it, and a changed key replaces its pin with a warning. Shows this computer's key fingerprint and the devices pinned here — compare each with the fingerprint the app shows on that device's page. It also lists the computers of the account not pinned here yet, with their fingerprints; the first send seals for each and pins it. With a `deviceId`, pin the key the account now lists for that device: prints the pinned and the listed fingerprint and asks first, `--yes` skips the question. `--reset` forgets every pin (each is pinned again as hwip sees it), and until then the listener answers a request for a screen in plaintext. Exit 0 done, 1 refused or declined, 2 not a device id |
1356
- | `cc` · `codex` · `gemini` · `cursor` · `hermes` · `pi` · `opencode` · `kimi` · `grok` · `agy` · `qwen` · `auggie` · `kilo` · `copilot` · `kiro-cli` · `droid` | Run the agent in a `hwip-<project>` tmux session — reattaches a detached session of that project (newest suffix first) when there is one, else auto-suffixes `-2`, `-3`, …. Auto-spawns the background listener on first invocation so the phone picker just works; when that daemon stops within a second and a half (no key, no tmux, a rejected key) it prints the daemon's last log line instead of "autostarted". Once a day it asks npm whether a newer hwip is out and prints the update line (cached in `~/.hwip/update-check.json`; skipped under `CI` or `HWIP_NO_UPDATE_CHECK`). Trailing args pass through to the agent (`hwip cc --resume "..."`). On node 22.15+ the wrapper hands its process to tmux rather than waiting on it, so a running session shows no `hwip cc` of its own in `ps` — older runtimes and Windows keep the waiting wrapper `--detach` / `--label <x>` (front of the args) create without attaching / pin the name; `--wt <name>` runs it in a git worktree made for the session — see "Run agents through the wrapper" above. |
1387
+ | `cc` · `codex` · `gemini` · `cursor` · `hermes` · `pi` · `opencode` · `kimi` · `grok` · `agy` · `qwen` · `auggie` · `kilo` · `copilot` · `kiro-cli` · `droid` | Run the agent in a `hwip-<project>` tmux session — reattaches a detached session of that project (newest suffix first) when there is one, else auto-suffixes `-2`, `-3`, …. Auto-spawns the background listener on first invocation so the phone picker just works; when that daemon stops within a second and a half (no key, no tmux, a rejected key) it prints the daemon's last log line instead of "autostarted". Once a day it asks npm whether a newer hwip is out and prints the update line (cached in `~/.hwip/update-check.json`; skipped under `CI` or `HWIP_NO_UPDATE_CHECK`). Trailing args pass through to the agent (`hwip cc --resume "..."`). When the tmux session ends (not on a detach), it prints the command that picks the conversation back up — `hwip: to resume: claude --resume <id> (or hwip cc --resume <id>)` — for `cc` · `codex` · `hermes` · `pi` · `opencode`, from the id the listener recorded while the session ran (a session shorter than one 5 s sweep has none yet, and gets no line); the tmux command runs under a small `sh` for this, so no node process waits on the session. On node 22.15+ the wrapper hands its process to tmux rather than waiting on it, so a running session shows no `hwip cc` of its own in `ps` — older runtimes and Windows keep the waiting wrapper `--detach` / `--label <x>` (front of the args) create without attaching / pin the name; `--wt <name>` runs it in a git worktree made for the session — see "Run agents through the wrapper" above. |
1357
1388
  | `worktree [clean]` | List the git worktrees `--wt` made (path, branch, and `in use` / `clean` / `never used` / what keeps it: uncommitted, untracked or ignored files, commits not pushed). `clean` removes the ones nothing keeps and prints why the others stay; `--dry-run` removes nothing and records nothing. A worktree you made by hand is never listed or removed. Exit 0, also when some stay; `clean` exits 1 when git refused a removal it had cleared or tmux could not be asked (the list still prints, without `in use`); 2 for an unknown subcommand. See "Run agents through the wrapper" for when the listener does the same on its own |
1358
1389
  | `schedule` | Start an agent session on a timer, in the background. The listener starts it, so it has to be running (installing the listener as a login service keeps it up). A run missed while the computer slept or the listener was down is skipped, never started late, and the phone is told. At most 20 schedules and 3 scheduled sessions at once; a session is asked to end after `--max-runtime` (never killed). Schedules live in `~/.local/state/hwip/schedules`. `schedule add <agent> (--at HH:MM \| --on YYYY-MM-DD --at HH:MM \| --every <N>m\|<N>h) [-- <agent args…>]` adds one: `<agent>` is an agent command above (`cc`, `codex`, …) and the project is this one; `--on` runs it once. `schedule add --session <name> (…) --prompt <text>` types the prompt into that running session instead of starting one (waits up to 30 min for it to be idle and its terminal quiet). `schedule [list]` shows them, with the next run and the last result (`--json` for scripts). `schedule rm <id>` removes one; a session it already started keeps running. Options: `--days <days>` (with `--at`: `daily`, `weekdays`, or day names like `mon,wed,fri`), `--prompt <text>` (typed into the session once the agent's status rules say it is idle: Claude Code, Codex, Gemini CLI and pi only), `--label <x>`, `--wt <branch>` (a git worktree for the branch, not with `--label`), `--max-runtime <N>m\|<N>h` (default 2h, at most the `--every` period; 5m to 24h). See *Scheduled sessions* |
1359
1390
  | `sh` | Open (or reattach) `hwip-<project>-sh`, a login shell the phone may type into — the one shell pane the listener does not refuse. `--label <x>` / `--detach` as for the agents; inside tmux it never attaches and prints a `switch-client` hint. Refuses a session of that name it did not open. Input must be addressed to this machine, is audited, and agents (`agent send`, `hwip_agent_send`) skip it — see [Defense](#defense) |
package/dist/args.js CHANGED
@@ -203,6 +203,7 @@ exports.COMMAND_FLAGS = {
203
203
  'install-service', 'uninstall-service', 'service-status',
204
204
  ],
205
205
  'remote-hook': [],
206
+ 'resume-hint': ['session', 'type', 'at'],
206
207
  };
207
208
  /**
208
209
  * Flags the command would ignore, in the order typed. A typo (`--titel`) used
@@ -0,0 +1,28 @@
1
+ import type { BackgroundTask } from './background-tasks.js';
2
+ /** How much of the end of an output the phone sees: a few screens of a log. */
3
+ export declare const TASK_OUTPUT_TAIL_BYTES: number;
4
+ /** Whether `path` names this task's own output file: `…/tasks/<id>.output`. */
5
+ export declare const isTaskOutputPath: (path: string, taskId: string) => boolean;
6
+ export type TaskOutputTail = {
7
+ readonly text: string;
8
+ readonly size: number;
9
+ readonly truncated: boolean;
10
+ } | {
11
+ readonly error: 'not_found' | 'path_refused' | 'read_failed';
12
+ };
13
+ export declare const readTaskOutputTail: (task: BackgroundTask, limit?: number) => Promise<TaskOutputTail>;
14
+ /**
15
+ * The sealed answer's body, with `text` cut from the top until the JSON fits
16
+ * `maxBytes`. JSON writes each ESC byte of a coloured log as `\\u001b`, six
17
+ * bytes, so 16 KB of output can come to far more than a frame holds.
18
+ */
19
+ export declare const fitTaskOutputBody: (head: {
20
+ readonly requestId: string;
21
+ readonly at: string;
22
+ readonly taskId: string;
23
+ }, tail: {
24
+ readonly text: string;
25
+ readonly size: number;
26
+ readonly truncated: boolean;
27
+ }, maxBytes: number) => string;
28
+ //# sourceMappingURL=background-tasks-output.d.ts.map
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.fitTaskOutputBody = exports.readTaskOutputTail = exports.isTaskOutputPath = exports.TASK_OUTPUT_TAIL_BYTES = void 0;
4
+ /**
5
+ * The end of a background command's output, for the phone
6
+ * (`agent.task.output.request`, handled in listener.ts).
7
+ *
8
+ * The phone names a task by id and never a path. The file is the one the
9
+ * transcript said the task writes, and is read only when its real path is a
10
+ * `tasks/<id>.output` file for that same id — a transcript line that names
11
+ * some other file, a symlink planted where the output should be, or a hard
12
+ * link to some other file, reads nothing.
13
+ */
14
+ const node_fs_1 = require("node:fs");
15
+ const promises_1 = require("node:fs/promises");
16
+ const node_path_1 = require("node:path");
17
+ /** How much of the end of an output the phone sees: a few screens of a log. */
18
+ exports.TASK_OUTPUT_TAIL_BYTES = 16 * 1024;
19
+ /** Whether `path` names this task's own output file: `…/tasks/<id>.output`. */
20
+ const isTaskOutputPath = (path, taskId) => (0, node_path_1.basename)(path) === `${taskId}.output` && (0, node_path_1.basename)((0, node_path_1.dirname)(path)) === 'tasks';
21
+ exports.isTaskOutputPath = isTaskOutputPath;
22
+ /** Where a cut from the end lands inside a UTF-8 character: skip its continuation bytes. */
23
+ const wholeCharacterStart = (bytes) => {
24
+ let start = 0;
25
+ while (start < bytes.length && start < 4 && (bytes[start] & 0xc0) === 0x80)
26
+ start++;
27
+ return start;
28
+ };
29
+ /** Where a read ends inside a UTF-8 character a running command has not finished writing: drop that character. */
30
+ const wholeCharacterEnd = (bytes) => {
31
+ for (let back = 1; back <= Math.min(4, bytes.length); back++) {
32
+ const byte = bytes[bytes.length - back];
33
+ if ((byte & 0xc0) === 0x80)
34
+ continue;
35
+ const width = byte >= 0xf0 ? 4 : byte >= 0xe0 ? 3 : byte >= 0xc0 ? 2 : 1;
36
+ return width > back ? bytes.length - back : bytes.length;
37
+ }
38
+ return bytes.length;
39
+ };
40
+ const readTaskOutputTail = async (task, limit = exports.TASK_OUTPUT_TAIL_BYTES) => {
41
+ let real;
42
+ try {
43
+ real = await (0, promises_1.realpath)(task.outputPath);
44
+ }
45
+ catch (err) {
46
+ return err.code === 'ENOENT' ? { error: 'not_found' } : { error: 'read_failed' };
47
+ }
48
+ if (!(0, exports.isTaskOutputPath)(real, task.id))
49
+ return { error: 'path_refused' };
50
+ let checked;
51
+ let handle;
52
+ try {
53
+ checked = await (0, promises_1.lstat)(real);
54
+ handle = await (0, promises_1.open)(real, node_fs_1.constants.O_RDONLY | node_fs_1.constants.O_NOFOLLOW);
55
+ }
56
+ catch (err) {
57
+ const code = err.code;
58
+ return code === 'ENOENT' ? { error: 'not_found' } : code === 'ELOOP' ? { error: 'path_refused' } : { error: 'read_failed' };
59
+ }
60
+ try {
61
+ const stat = await handle.stat();
62
+ // The file opened must be the one checked (a directory on the way swapped for a symlink opens another), a plain
63
+ // file, and have no other name: a hard link to a secret has the output's real path too.
64
+ if (!stat.isFile() || stat.nlink !== 1 || stat.dev !== checked.dev || stat.ino !== checked.ino)
65
+ return { error: 'path_refused' };
66
+ const size = stat.size;
67
+ const length = Math.min(size, limit);
68
+ const bytes = Buffer.alloc(length);
69
+ const { bytesRead } = await handle.read(bytes, 0, length, size - length);
70
+ const read = bytes.subarray(0, bytesRead);
71
+ const truncated = size > length;
72
+ const whole = read.subarray(0, wholeCharacterEnd(read));
73
+ const text = (truncated ? whole.subarray(wholeCharacterStart(whole)) : whole).toString('utf8');
74
+ return { text, size, truncated };
75
+ }
76
+ catch {
77
+ return { error: 'read_failed' };
78
+ }
79
+ finally {
80
+ await handle.close().catch(() => undefined);
81
+ }
82
+ };
83
+ exports.readTaskOutputTail = readTaskOutputTail;
84
+ /**
85
+ * The sealed answer's body, with `text` cut from the top until the JSON fits
86
+ * `maxBytes`. JSON writes each ESC byte of a coloured log as `\\u001b`, six
87
+ * bytes, so 16 KB of output can come to far more than a frame holds.
88
+ */
89
+ const fitTaskOutputBody = (head, tail, maxBytes) => {
90
+ let { text, truncated } = tail;
91
+ let body = JSON.stringify({ v: 1, ...head, text, size: tail.size, truncated });
92
+ let bytes = Buffer.byteLength(body);
93
+ while (bytes > maxBytes && text.length > 0) {
94
+ // Cut about the share that is over, at the next line break; one runaway line loses its first half.
95
+ const from = Math.floor(text.length * (1 - maxBytes / bytes));
96
+ const cut = text.indexOf('\n', from);
97
+ let start = cut === -1 ? Math.max(from, Math.ceil(text.length / 2)) : cut + 1;
98
+ if (/[\uDC00-\uDFFF]/.test(text.charAt(start)))
99
+ start += 1;
100
+ text = text.slice(start);
101
+ truncated = true;
102
+ body = JSON.stringify({ v: 1, ...head, text, size: tail.size, truncated });
103
+ bytes = Buffer.byteLength(body);
104
+ }
105
+ return body;
106
+ };
107
+ exports.fitTaskOutputBody = fitTaskOutputBody;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * What the phone is told about a Claude session's background commands when it
3
+ * asks (`agent.tasks.request`). The handler that admits the request and seals
4
+ * the answer is `handleBackgroundTasksRequest` in listener.ts, beside the
5
+ * other sealed requests; this is what it answers with.
6
+ */
7
+ import { type AsyncWriterProbe, type BackgroundTask } from './background-tasks.js';
8
+ /** One row as the phone sees it. The output path stays on this machine: the phone names a task by id. */
9
+ export interface TaskRow {
10
+ readonly id: string;
11
+ readonly description: string | null;
12
+ readonly startedAt: string | null;
13
+ }
14
+ /**
15
+ * The tasks whose output some process still writes, asked once for all of
16
+ * them. A probe that cannot answer keeps every task: not knowing is not dead.
17
+ * The sweep's own check runs at most every 15 s; this one runs on every
18
+ * request, so a phone opening the list never sees a task that died since.
19
+ * Asynchronous: it runs on the main thread, which serves the socket.
20
+ */
21
+ export declare const liveTaskRows: (tasks: readonly BackgroundTask[], probe?: AsyncWriterProbe) => Promise<TaskRow[]>;
22
+ //# sourceMappingURL=background-tasks-requests.d.ts.map
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.liveTaskRows = void 0;
4
+ /**
5
+ * What the phone is told about a Claude session's background commands when it
6
+ * asks (`agent.tasks.request`). The handler that admits the request and seals
7
+ * the answer is `handleBackgroundTasksRequest` in listener.ts, beside the
8
+ * other sealed requests; this is what it answers with.
9
+ */
10
+ const background_tasks_js_1 = require("./background-tasks.js");
11
+ /**
12
+ * The tasks whose output some process still writes, asked once for all of
13
+ * them. A probe that cannot answer keeps every task: not knowing is not dead.
14
+ * The sweep's own check runs at most every 15 s; this one runs on every
15
+ * request, so a phone opening the list never sees a task that died since.
16
+ * Asynchronous: it runs on the main thread, which serves the socket.
17
+ */
18
+ const liveTaskRows = async (tasks, probe = background_tasks_js_1.lsofWritersAsync) => {
19
+ if (tasks.length === 0)
20
+ return [];
21
+ const open = await probe(tasks.map((t) => t.outputPath));
22
+ return tasks
23
+ .filter((t) => !open || open.has(t.outputPath))
24
+ .map(({ id, description, startedAt }) => ({ id, description, startedAt }));
25
+ };
26
+ exports.liveTaskRows = liveTaskRows;
@@ -0,0 +1,43 @@
1
+ import { type BackgroundTask } from './background-tasks.js';
2
+ import { type ProcTable } from './remote-agents.js';
3
+ /** What a stop found. Only `group` is ever signalled. */
4
+ export type StopTarget = {
5
+ readonly kind: 'group';
6
+ readonly pgid: number;
7
+ }
8
+ /** Nobody writes the output, no writer leads a group under the pane, or `lsof`/`ps` could not say. */
9
+ | {
10
+ readonly kind: 'not_found';
11
+ }
12
+ /** More than one group qualifies: which one the task is cannot be told. */
13
+ | {
14
+ readonly kind: 'ambiguous';
15
+ }
16
+ /** The group is one a stop must never signal: this listener's, the pane shell's, claude's, or the system's. */
17
+ | {
18
+ readonly kind: 'refused';
19
+ };
20
+ export type StopOutcome = 'stopped' | 'not_found' | 'ambiguous' | 'refused' | 'kill_failed';
21
+ export interface StopProbes {
22
+ /** Pids with the file open (lsof does not tell a reader from a writer), or null when that cannot be told. */
23
+ readonly writerPids: (path: string) => Promise<readonly number[] | null>;
24
+ readonly procTable: () => Promise<ProcTable | null>;
25
+ /** This listener's pid, whose group is never signalled. */
26
+ readonly selfPid: number;
27
+ }
28
+ export declare const defaultStopProbes: StopProbes;
29
+ /**
30
+ * The process group that runs the task writing `outputPath` under the pane
31
+ * whose shell is `panePid`: the group of a writer whose LEADER is a writer too
32
+ * and descends from the pane. A group a stop must never end — this listener's,
33
+ * the pane's foreground group (claude's), or any on the way from the leader up
34
+ * to the pane — is refused even when it qualifies.
35
+ */
36
+ export declare const findTaskProcessGroup: (outputPath: string, panePid: number, probes?: StopProbes) => Promise<StopTarget>;
37
+ export interface StopDeps {
38
+ readonly probes?: StopProbes;
39
+ readonly kill?: (pid: number, signal: NodeJS.Signals) => void;
40
+ }
41
+ /** Find the task's process group and send it SIGTERM — the group, so the command's children end with it. */
42
+ export declare const stopBackgroundTask: (task: BackgroundTask, panePid: number, deps?: StopDeps) => Promise<StopOutcome>;
43
+ //# sourceMappingURL=background-tasks-stop.d.ts.map
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.stopBackgroundTask = exports.findTaskProcessGroup = exports.defaultStopProbes = void 0;
4
+ /**
5
+ * Stopping a background command a Claude session runs, for the phone's Stop
6
+ * (`agent.task.stop.request`, handled in listener.ts).
7
+ *
8
+ * The phone names a task by id; nothing it sends says which process to
9
+ * signal. The target is found here, from this machine's own answers, and only
10
+ * when every one agrees. Measured 2026-11-11 (Claude Code 2.x): Claude starts
11
+ * each background command under a wrapper shell that leads its own process
12
+ * group, as a child of `claude`; `lsof` on the task's output file names the
13
+ * wrapper and its children; `kill -TERM -<pgid>` ends the whole tree, and
14
+ * Claude then writes the task's `<task-notification>` with status `failed`.
15
+ *
16
+ * So the target is the one process group whose leader writes the output and
17
+ * descends from the session's pane. Anything less certain signals nothing.
18
+ */
19
+ const promises_1 = require("node:fs/promises");
20
+ const background_tasks_output_js_1 = require("./background-tasks-output.js");
21
+ const background_tasks_js_1 = require("./background-tasks.js");
22
+ const remote_agents_js_1 = require("./remote-agents.js");
23
+ /** Deepest ancestor chain walked from a group leader up to the pane. */
24
+ const MAX_ANCESTRY = 64;
25
+ /** `lsof -t -- <path>`: one pid per line. Exit 1 is "open nowhere", still an answer. */
26
+ const lsofPids = async (path) => {
27
+ const bin = (0, background_tasks_js_1.lsofBin)();
28
+ if (!bin)
29
+ return null;
30
+ const out = await (0, background_tasks_js_1.runProbe)(bin, ['-t', '--', path], [1]);
31
+ if (out === null)
32
+ return null;
33
+ return out.split('\n').map(Number).filter((pid) => Number.isInteger(pid) && pid > 0);
34
+ };
35
+ const psTable = async () => {
36
+ const out = await (0, background_tasks_js_1.runProbe)('ps', ['-axo', 'pid=,ppid=,pgid=,tpgid=,comm='], []);
37
+ return out === null ? null : (0, remote_agents_js_1.parseProcTable)(out);
38
+ };
39
+ exports.defaultStopProbes = { writerPids: lsofPids, procTable: psTable, selfPid: process.pid };
40
+ const parentsOf = (table) => {
41
+ const parentOf = new Map();
42
+ for (const [ppid, kids] of table.children)
43
+ for (const pid of kids)
44
+ parentOf.set(pid, ppid);
45
+ return parentOf;
46
+ };
47
+ /**
48
+ * The processes from `leader`'s parent up to and including `panePid`, or null
49
+ * when the pane is not an ancestor of the leader.
50
+ */
51
+ const chainToPane = (leader, panePid, parentOf) => {
52
+ const chain = [];
53
+ let pid = parentOf.get(leader);
54
+ for (let hop = 0; pid !== undefined && pid > 0 && hop < MAX_ANCESTRY; hop++) {
55
+ chain.push(pid);
56
+ if (pid === panePid)
57
+ return chain;
58
+ pid = parentOf.get(pid);
59
+ }
60
+ return null;
61
+ };
62
+ /**
63
+ * The process group that runs the task writing `outputPath` under the pane
64
+ * whose shell is `panePid`: the group of a writer whose LEADER is a writer too
65
+ * and descends from the pane. A group a stop must never end — this listener's,
66
+ * the pane's foreground group (claude's), or any on the way from the leader up
67
+ * to the pane — is refused even when it qualifies.
68
+ */
69
+ const findTaskProcessGroup = async (outputPath, panePid, probes = exports.defaultStopProbes) => {
70
+ if (!Number.isInteger(panePid) || panePid <= 1)
71
+ return { kind: 'not_found' };
72
+ const [writers, table] = await Promise.all([probes.writerPids(outputPath), probes.procTable()]);
73
+ if (!writers || writers.length === 0 || !table)
74
+ return { kind: 'not_found' };
75
+ const writing = new Set(writers);
76
+ const parentOf = parentsOf(table);
77
+ const forbidden = new Set();
78
+ // This listener's group, the pane shell's, and the pane's foreground group:
79
+ // the program the user sees in the pane (claude), which background
80
+ // commands never lead.
81
+ for (const group of [table.pgidOf.get(probes.selfPid), table.pgidOf.get(panePid), table.tpgidOf.get(panePid)]) {
82
+ if (group !== undefined)
83
+ forbidden.add(group);
84
+ }
85
+ const candidates = new Set();
86
+ for (const pid of writing) {
87
+ const pgid = table.pgidOf.get(pid);
88
+ if (pgid === undefined || !writing.has(pgid))
89
+ continue;
90
+ const chain = chainToPane(pgid, panePid, parentOf);
91
+ if (!chain)
92
+ continue;
93
+ for (const up of chain) {
94
+ const upGroup = table.pgidOf.get(up);
95
+ if (upGroup !== undefined)
96
+ forbidden.add(upGroup);
97
+ }
98
+ candidates.add(pgid);
99
+ }
100
+ if (candidates.size === 0)
101
+ return { kind: 'not_found' };
102
+ if (candidates.size > 1)
103
+ return { kind: 'ambiguous' };
104
+ const pgid = candidates.values().next().value;
105
+ if (pgid <= 1 || forbidden.has(pgid))
106
+ return { kind: 'refused' };
107
+ return { kind: 'group', pgid };
108
+ };
109
+ exports.findTaskProcessGroup = findTaskProcessGroup;
110
+ /** Find the task's process group and send it SIGTERM — the group, so the command's children end with it. */
111
+ const stopBackgroundTask = async (task, panePid, deps = {}) => {
112
+ // Only the task's own output file says who runs it: a transcript line naming another file points at whoever has that
113
+ // open. A file already gone keeps its name, and lsof then finds nobody.
114
+ const real = await (0, promises_1.realpath)(task.outputPath).catch(() => task.outputPath);
115
+ if (!(0, background_tasks_output_js_1.isTaskOutputPath)(real, task.id))
116
+ return 'refused';
117
+ const target = await (0, exports.findTaskProcessGroup)(task.outputPath, panePid, deps.probes ?? exports.defaultStopProbes);
118
+ if (target.kind !== 'group')
119
+ return target.kind;
120
+ try {
121
+ (deps.kill ?? process.kill)(-target.pgid, 'SIGTERM');
122
+ return 'stopped';
123
+ }
124
+ catch {
125
+ return 'kill_failed';
126
+ }
127
+ };
128
+ exports.stopBackgroundTask = stopBackgroundTask;
@@ -0,0 +1,65 @@
1
+ import { type TailState } from './transcript-tail.js';
2
+ /** How often a session's tasks have their output writers checked. */
3
+ export declare const BACKGROUND_TASK_PROBE_MS = 15000;
4
+ /** Most tasks one session keeps — a runaway loop of launches cannot grow the listener without bound. */
5
+ export declare const MAX_BACKGROUND_TASKS = 50;
6
+ export interface BackgroundTask {
7
+ readonly id: string;
8
+ readonly description: string | null;
9
+ /** When the launch result was written, or null when the line had no stamp. */
10
+ readonly startedAt: string | null;
11
+ /** Where Claude Code writes the task's output, exactly as the transcript said. */
12
+ readonly outputPath: string;
13
+ }
14
+ /**
15
+ * Which of `paths` some process still has open, or null when that cannot be
16
+ * told (no `lsof`, a timeout). Null keeps every task: not knowing is not dead.
17
+ */
18
+ export type WriterProbe = (paths: readonly string[]) => ReadonlySet<string> | null;
19
+ export interface BackgroundTaskState {
20
+ tail: TailState | null;
21
+ /** Background launches whose result has not been read yet: tool_use id → description. */
22
+ readonly launches: Map<string, string | null>;
23
+ /** Running tasks by id, in launch order. */
24
+ readonly running: Map<string, BackgroundTask>;
25
+ /** When writers were last checked; -Infinity before the first check. */
26
+ probedAt: number;
27
+ }
28
+ export declare const initialBackgroundTaskState: () => BackgroundTaskState;
29
+ /** The lsof binary, or null when this machine has none. */
30
+ export declare const lsofBin: () => string | null;
31
+ /**
32
+ * Run a probe (`lsof`, `ps`) without blocking the main thread, which serves
33
+ * the socket (inventory-offload.ts). Its stdout, or null on a spawn failure,
34
+ * a timeout or an exit status outside `okCodes` — lsof's exit 1 ("open
35
+ * nowhere") is still an answer.
36
+ */
37
+ export declare const runProbe: (bin: string, args: string[], okCodes: readonly number[]) => Promise<string | null>;
38
+ /**
39
+ * `lsof -F n -- <paths>`: one `n<name>` line per open file. lsof exits 1 when
40
+ * any one of the paths is open nowhere — still an answer, printed for the
41
+ * rest; a spawn failure, a timeout or another status is no answer.
42
+ */
43
+ export declare const lsofWriters: WriterProbe;
44
+ /** An answer that resolves later: what the phone's requests use on the main thread. */
45
+ export type AsyncWriterProbe = (paths: readonly string[]) => Promise<ReadonlySet<string> | null>;
46
+ /** `lsofWriters` without blocking: a synchronous spawn on the main thread delays frames and pongs. */
47
+ export declare const lsofWritersAsync: AsyncWriterProbe;
48
+ /**
49
+ * Fold what the transcript appended since the last call, then drop tasks
50
+ * whose output nobody writes any more (at most once per probe window).
51
+ * Returns the running tasks in launch order.
52
+ */
53
+ export declare const trackBackgroundTasks: (transcriptPath: string, state: BackgroundTaskState, opts?: {
54
+ probe?: WriterProbe;
55
+ now?: number;
56
+ }) => BackgroundTask[];
57
+ /** A Claude session's running background commands, with the pane they run under (what Stop checks ancestry against). */
58
+ export interface SessionBackgroundTasks {
59
+ readonly tasks: readonly BackgroundTask[];
60
+ readonly panePid: number | null;
61
+ }
62
+ export declare const recordBackgroundTasks: (bySession: Record<string, SessionBackgroundTasks> | null | undefined) => void;
63
+ /** The session's running background commands as of the last sweep; null when it has none. */
64
+ export declare const backgroundTasksFor: (sessionName: string) => SessionBackgroundTasks | null;
65
+ //# sourceMappingURL=background-tasks.d.ts.map