@hwip/cli 0.1.23 → 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 +25 -1
- package/dist/background-tasks-output.d.ts +28 -0
- package/dist/background-tasks-output.js +107 -0
- package/dist/background-tasks-requests.d.ts +22 -0
- package/dist/background-tasks-requests.js +26 -0
- package/dist/background-tasks-stop.d.ts +43 -0
- package/dist/background-tasks-stop.js +128 -0
- package/dist/background-tasks.d.ts +65 -0
- package/dist/background-tasks.js +225 -0
- package/dist/crypto.d.ts +1 -1
- package/dist/listener.d.ts +65 -0
- package/dist/listener.js +258 -8
- package/dist/subagent-transcripts.d.ts +5 -0
- package/dist/subagent-transcripts.js +5 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -877,6 +877,19 @@ The phone also reads this machine's schedules (see [Scheduled sessions](#schedul
|
|
|
877
877
|
|
|
878
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.
|
|
879
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
|
+
|
|
880
893
|
### Your API key and redirects
|
|
881
894
|
|
|
882
895
|
Every request that carries your API key refuses to follow a redirect: a host
|
|
@@ -1012,7 +1025,8 @@ handing the listener a key:
|
|
|
1012
1025
|
inside envelope v2: ECDH P-256 between the two device keys, HKDF-SHA256
|
|
1013
1026
|
with a fresh salt and the message's purpose (`stream.frame`,
|
|
1014
1027
|
`stream.input`, `stream.alive`, `stream.started`, `screen.snapshot`,
|
|
1015
|
-
`screen.history`, `session.diff`, `schedule.list`, `
|
|
1028
|
+
`screen.history`, `session.diff`, `schedule.list`, `agent.tasks`,
|
|
1029
|
+
`agent.task.output`, `agent.task.stop`, `turn.batch`) as the key's info, and
|
|
1016
1030
|
AES-256-GCM over data that names the purpose, both keys in send →
|
|
1017
1031
|
receive order and the session. The sealed frame also holds the
|
|
1018
1032
|
session name, its `seq`/`epoch` and that nonce, so a relay that cannot
|
|
@@ -1083,6 +1097,16 @@ request without a key while this listener pins no device, and otherwise an
|
|
|
1083
1097
|
error code (`e2ee_required`, `subscriber_unverified`, …) and no schedule. Prompts are in
|
|
1084
1098
|
it, so a request that asked for a seal is never answered in the clear.
|
|
1085
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
|
+
|
|
1086
1110
|
**Reading a session's changes** (`agent.diff.files.request` and
|
|
1087
1111
|
`agent.diff.file.request`) is sealed both ways and never answered in the
|
|
1088
1112
|
clear: a request without a key gets `e2ee_required` whether or not this
|
|
@@ -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
|
|
@@ -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. */
|
package/dist/listener.d.ts
CHANGED
|
@@ -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
|
package/dist/listener.js
CHANGED
|
@@ -26,8 +26,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
26
26
|
};
|
|
27
27
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
28
|
exports.collectWatchHits = exports.resetPatternWatches = exports.setPatternWatches = exports.withSettledPrompts = exports.PROMPT_SEAL_WAIT_MS = exports.withBlockedPrompts = exports.setPromptSealer = exports.deriveSessionState = exports.resetSessionStates = exports.continueRuns = exports.runIdOfEpoch = exports.__checkoutCacheSize = exports.parseSessionName = exports.resetPaneFitClaims = exports.tmuxTargetFor = exports.subagentTranscriptFor = exports.recordTargets = exports.recordSubagentTranscripts = exports.isSubagentSessionName = exports.detectRemoteAgent = exports.sessionLiveness = exports.sessionExists = exports.liveSessionNames = exports.tmuxArgs = exports.silenceListenerLog = exports.invalidateTmuxSocketCache = exports.wheelInput = exports.wheelEvents = exports.WHEEL_STATE_FORMAT = exports.resolveWheel = exports.resolveKeys = exports.probePane = exports.checkRateLimit = exports.AUTH_FAILURE_CODES = exports.commandsReportDue = exports.commandsFingerprint = exports.COMMAND_SCAN_INTERVAL_MS = exports.sessionsReportDue = exports.sessionsFingerprint = exports.withRunSeqs = exports.staleConnectionAction = exports.askedToReconnect = exports.MAX_STALE_CONNECTION_DROPS = exports.createReportAckWatch = exports.REPORT_ACK_TIMEOUT_MS = exports.SESSION_REPORT_HEARTBEAT_MS = exports.SESSION_REPORT_INTERVAL_MS = exports.heartbeatVerdict = exports.pingDue = exports.PING_IDLE_MS = void 0;
|
|
29
|
-
exports.
|
|
30
|
-
exports.handleListener = exports.liveScheduleRunnerDeps = exports.listenerSettings = exports.resolveWsUrl = exports.streamSession = exports.computeListenerDeviceId = exports.computeBackoff = exports.handlePush = exports.gcAttachments = exports.displayFolder = exports.claimLanFile = exports.saveFileFromS3 = exports.downloadSealedEach = exports.answerFileBound = exports.downloadAttachments = exports.setAttachmentContext = exports.shellTextRefusal = exports.writeRemoteMarker = exports.isInventoried = exports.syncTmuxAgentNames = exports.recordInventory = void 0;
|
|
29
|
+
exports.MAX_PENDING_DECRYPTS = exports.MAX_INPUT_LANES = exports.validateInputMessage = exports.MAX_INPUT_BODY_CHARS = exports.MAX_INPUT_KEYS = exports.handleStreamControl = exports.buildStreamFrame = exports.isStreamCapReached = exports.fitSealedFrame = exports.SEALED_FRAME_MAX_BYTES = exports.stopAllStreams = exports.stopStream = exports.stopStreamOf = exports.handleTurnWatch = exports.sealTurnBatch = exports.resolveWatchTranscript = exports.MAX_CONCURRENT_STREAMS = exports.STREAM_RENEW_FRESH_MS = exports.STREAM_LEASE_MS = exports.claimFrameSend = exports.hasFrameBudget = exports.streamCadence = exports.MAX_FRAMES_PER_SEC = exports.BURST_WINDOW_MS = exports.BURST_INTERVAL_MS = exports.STREAM_INTERVAL_MS = exports.STREAM_CAPTURE_LINES = exports.scheduleInjectSnapshots = exports.historyLinesIn = exports.cursorLineFor = exports.parsePaneGeometry = exports.STREAM_GEOMETRY_FORMAT = exports.handleScreenHistoryRequest = exports.SCREEN_HISTORY_MAX_LINES = exports.handleSessionForgetRequest = exports.handleSessionExitRequest = exports.SESSION_EXIT_STEP_MS = exports.handleSessionResumeRequest = exports.SHELL_START_KIND = exports.handleBackgroundTaskOutputRequest = exports.handleBackgroundTaskStopRequest = exports.handleBackgroundTasksRequest = exports.handleScheduleListRequest = exports.handleFileBlobRequest = exports.handleFileTreeRequest = exports.handleDiffFileRequest = exports.handleDiffFilesRequest = exports.fileViewBytes = exports.handleScreenRequest = exports.setSubscriberTrust = void 0;
|
|
30
|
+
exports.handleListener = exports.liveScheduleRunnerDeps = exports.listenerSettings = exports.resolveWsUrl = exports.streamSession = exports.computeListenerDeviceId = exports.computeBackoff = exports.handlePush = exports.gcAttachments = exports.displayFolder = exports.claimLanFile = exports.saveFileFromS3 = exports.downloadSealedEach = exports.answerFileBound = exports.downloadAttachments = exports.setAttachmentContext = exports.shellTextRefusal = exports.writeRemoteMarker = exports.isInventoried = exports.syncTmuxAgentNames = exports.recordInventory = exports.collectSessionsVerbose = exports.handleCommandInput = exports.pendingInputDecrypts = void 0;
|
|
31
31
|
const child_process_1 = require("child_process");
|
|
32
32
|
const crypto_1 = require("crypto");
|
|
33
33
|
const fs_1 = require("fs");
|
|
@@ -51,6 +51,10 @@ const gate_js_1 = require("./gate.js");
|
|
|
51
51
|
const session_registry_js_1 = require("./session-registry.js");
|
|
52
52
|
const remote_agents_js_1 = require("./remote-agents.js");
|
|
53
53
|
const subagent_transcripts_js_1 = require("./subagent-transcripts.js");
|
|
54
|
+
const background_tasks_js_1 = require("./background-tasks.js");
|
|
55
|
+
const background_tasks_requests_js_1 = require("./background-tasks-requests.js");
|
|
56
|
+
const background_tasks_output_js_1 = require("./background-tasks-output.js");
|
|
57
|
+
const background_tasks_stop_js_1 = require("./background-tasks-stop.js");
|
|
54
58
|
const transcript_tail_js_1 = require("./transcript-tail.js");
|
|
55
59
|
const listener_service_js_1 = require("./listener-service.js");
|
|
56
60
|
const agent_state_js_1 = require("./agent-state.js");
|
|
@@ -1429,6 +1433,232 @@ const handleScheduleListRequest = (req, send) => {
|
|
|
1429
1433
|
return true;
|
|
1430
1434
|
};
|
|
1431
1435
|
exports.handleScheduleListRequest = handleScheduleListRequest;
|
|
1436
|
+
/**
|
|
1437
|
+
* Answer a phone's request for the background commands a Claude session runs
|
|
1438
|
+
* (`Bash` with `run_in_background`). The rows are Claude's descriptions of
|
|
1439
|
+
* them, which can say what a command is for, so the answer is sealed for a key
|
|
1440
|
+
* that checked out and bound to the session — never in the clear, whatever
|
|
1441
|
+
* this listener's seal history. Each task is checked for a live writer on its
|
|
1442
|
+
* output now, not only at the last sweep, so a dead one is not listed.
|
|
1443
|
+
*/
|
|
1444
|
+
const handleBackgroundTasksRequest = (req, send) => {
|
|
1445
|
+
if (req.subtype !== 'agent.tasks.request')
|
|
1446
|
+
return false;
|
|
1447
|
+
if (typeof req.requestId !== 'string' || !req.requestId || req.requestId.length > MAX_REQUEST_ID_LENGTH)
|
|
1448
|
+
return false;
|
|
1449
|
+
if (typeof req.sessionName !== 'string' || !req.sessionName || req.sessionName.length > MAX_REQUEST_ID_LENGTH)
|
|
1450
|
+
return false;
|
|
1451
|
+
if (req.targetDeviceId !== (0, exports.computeListenerDeviceId)())
|
|
1452
|
+
return false;
|
|
1453
|
+
const { requestId, sessionName } = req;
|
|
1454
|
+
const frame = {
|
|
1455
|
+
subtype: 'agent.tasks.result',
|
|
1456
|
+
requestId,
|
|
1457
|
+
sessionName,
|
|
1458
|
+
...(typeof req.deviceId === 'string' && req.deviceId ? { targetDeviceId: req.deviceId } : {}),
|
|
1459
|
+
at: new Date().toISOString(),
|
|
1460
|
+
};
|
|
1461
|
+
const reply = (fields) => { send({ ...frame, ...fields }); };
|
|
1462
|
+
const asker = typeof req.deviceId === 'string' && /^[\w-]{1,64}$/.test(req.deviceId) ? req.deviceId : '';
|
|
1463
|
+
if (!(0, exports.checkRateLimit)(`tasks:list:${asker}`)) {
|
|
1464
|
+
reply({ error: 'rate_limited' });
|
|
1465
|
+
return true;
|
|
1466
|
+
}
|
|
1467
|
+
// No key, no list: a plaintext request is refused even where screens still go out in the clear.
|
|
1468
|
+
if (!asksForSeal(req)) {
|
|
1469
|
+
reply({ error: 'e2ee_required' });
|
|
1470
|
+
return true;
|
|
1471
|
+
}
|
|
1472
|
+
whenSealAdmitted(req, (phoneKey) => {
|
|
1473
|
+
(0, background_tasks_requests_js_1.liveTaskRows)((0, background_tasks_js_1.backgroundTasksFor)(sessionName)?.tasks ?? [])
|
|
1474
|
+
.then(async (tasks) => {
|
|
1475
|
+
await (0, crypto_js_1.initDeviceCrypto)();
|
|
1476
|
+
return (0, crypto_js_1.sealV2)('agent.tasks', JSON.stringify({ v: 1, requestId, at: frame.at, tasks }), phoneKey, sessionName);
|
|
1477
|
+
})
|
|
1478
|
+
.then((encrypted) => reply({ encrypted }))
|
|
1479
|
+
.catch((err) => {
|
|
1480
|
+
// A seal that fails is an error, never a plaintext list.
|
|
1481
|
+
(0, listener_log_js_1.log)(`⧉ tasks ${sessionName}: seal failed (${err instanceof Error ? err.message : err})`);
|
|
1482
|
+
reply({ error: 'encrypt_failed' });
|
|
1483
|
+
});
|
|
1484
|
+
}, (reason) => { reply({ error: reason }); });
|
|
1485
|
+
return true;
|
|
1486
|
+
};
|
|
1487
|
+
exports.handleBackgroundTasksRequest = handleBackgroundTasksRequest;
|
|
1488
|
+
/** Task ids as Claude Code writes them (`STARTED` in background-tasks.ts). */
|
|
1489
|
+
const TASK_ID_PATTERN = /^[A-Za-z0-9]{1,32}$/;
|
|
1490
|
+
/** How far a stop request's own clock may sit from this machine's. */
|
|
1491
|
+
const STOP_CLOCK_SKEW_MS = 5 * 60_000;
|
|
1492
|
+
/**
|
|
1493
|
+
* Stop requests already acted on, by phone key and request id → when. Kept for
|
|
1494
|
+
* twice the skew: an envelope is taken while its own `at` is within the skew
|
|
1495
|
+
* of now, and a phone clock running ahead keeps it takeable past one window
|
|
1496
|
+
* from when it was acted on. In memory only: a listener restarted inside the
|
|
1497
|
+
* window would take a replayed stop again, which can end only a command the
|
|
1498
|
+
* session still runs, found again from this machine's own probes.
|
|
1499
|
+
*/
|
|
1500
|
+
const actedStops = new Map();
|
|
1501
|
+
const MAX_ACTED_STOPS = 512;
|
|
1502
|
+
/** True the first time a phone's request id is seen. */
|
|
1503
|
+
const firstStop = (key, now) => {
|
|
1504
|
+
for (const [seen, at] of actedStops)
|
|
1505
|
+
if (now - at > 2 * STOP_CLOCK_SKEW_MS)
|
|
1506
|
+
actedStops.delete(seen);
|
|
1507
|
+
if (actedStops.has(key))
|
|
1508
|
+
return false;
|
|
1509
|
+
if (actedStops.size >= MAX_ACTED_STOPS)
|
|
1510
|
+
actedStops.delete(actedStops.keys().next().value);
|
|
1511
|
+
actedStops.set(key, now);
|
|
1512
|
+
return true;
|
|
1513
|
+
};
|
|
1514
|
+
/**
|
|
1515
|
+
* Stop a background command a Claude session runs, for a trusted phone. The
|
|
1516
|
+
* one remote path here that ends a process, so the request has to prove who
|
|
1517
|
+
* sent it: it is sealed by the phone's key, which must be the asking device's
|
|
1518
|
+
* trusted key, and names the request, the task and its time inside the seal.
|
|
1519
|
+
* A key alone is public and proves nothing. The phone names a task by id;
|
|
1520
|
+
* which process group that is, and whether it may be signalled at all, is
|
|
1521
|
+
* decided on this machine (`background-tasks-stop.ts`). The answer is a status
|
|
1522
|
+
* in the clear: it says nothing about the task beyond whether it was stopped.
|
|
1523
|
+
*/
|
|
1524
|
+
const handleBackgroundTaskStopRequest = (req, send) => {
|
|
1525
|
+
if (req.subtype !== 'agent.task.stop.request')
|
|
1526
|
+
return false;
|
|
1527
|
+
if (typeof req.requestId !== 'string' || !req.requestId || req.requestId.length > MAX_REQUEST_ID_LENGTH)
|
|
1528
|
+
return false;
|
|
1529
|
+
if (typeof req.sessionName !== 'string' || !req.sessionName || req.sessionName.length > MAX_REQUEST_ID_LENGTH)
|
|
1530
|
+
return false;
|
|
1531
|
+
if (req.targetDeviceId !== (0, exports.computeListenerDeviceId)())
|
|
1532
|
+
return false;
|
|
1533
|
+
const { requestId, sessionName } = req;
|
|
1534
|
+
const frame = {
|
|
1535
|
+
subtype: 'agent.task.stop.result',
|
|
1536
|
+
requestId,
|
|
1537
|
+
sessionName,
|
|
1538
|
+
...(typeof req.deviceId === 'string' && req.deviceId ? { targetDeviceId: req.deviceId } : {}),
|
|
1539
|
+
at: new Date().toISOString(),
|
|
1540
|
+
};
|
|
1541
|
+
const reply = (fields) => { send({ ...frame, ...fields }); };
|
|
1542
|
+
const asker = typeof req.deviceId === 'string' && /^[\w-]{1,64}$/.test(req.deviceId) ? req.deviceId : '';
|
|
1543
|
+
if (!(0, exports.checkRateLimit)(`tasks:stop:${asker}`)) {
|
|
1544
|
+
reply({ error: 'rate_limited' });
|
|
1545
|
+
return true;
|
|
1546
|
+
}
|
|
1547
|
+
if (!asksForSeal(req)) {
|
|
1548
|
+
reply({ error: 'e2ee_required' });
|
|
1549
|
+
return true;
|
|
1550
|
+
}
|
|
1551
|
+
const sealedRequest = req.encrypted;
|
|
1552
|
+
if (!(0, crypto_js_1.isSealedV2)(sealedRequest)) {
|
|
1553
|
+
reply({ error: 'bad_request' });
|
|
1554
|
+
return true;
|
|
1555
|
+
}
|
|
1556
|
+
const answer = async (phoneKey) => {
|
|
1557
|
+
let asked;
|
|
1558
|
+
try {
|
|
1559
|
+
// Untrusted until the checks below: every field is `unknown`.
|
|
1560
|
+
asked = JSON.parse(await (0, crypto_js_1.openV2)('agent.task.stop', sealedRequest, phoneKey, sessionName));
|
|
1561
|
+
}
|
|
1562
|
+
catch {
|
|
1563
|
+
return { error: 'bad_request' };
|
|
1564
|
+
}
|
|
1565
|
+
if (typeof asked !== 'object' || asked === null || asked.v !== 1 || asked.requestId !== requestId)
|
|
1566
|
+
return { error: 'bad_request' };
|
|
1567
|
+
if (typeof asked.taskId !== 'string' || !TASK_ID_PATTERN.test(asked.taskId))
|
|
1568
|
+
return { error: 'bad_request' };
|
|
1569
|
+
const now = Date.now();
|
|
1570
|
+
const at = typeof asked.at === 'string' ? Date.parse(asked.at) : Number.NaN;
|
|
1571
|
+
if (!Number.isFinite(at) || Math.abs(now - at) > STOP_CLOCK_SKEW_MS)
|
|
1572
|
+
return { error: 'bad_request' };
|
|
1573
|
+
// A relay that kept a sealed stop can hand it back; the seal names the request, so the second one is refused.
|
|
1574
|
+
if (!firstStop(`${phoneKey}\n${requestId}`, now))
|
|
1575
|
+
return { error: 'bad_request' };
|
|
1576
|
+
const { taskId } = asked;
|
|
1577
|
+
const running = (0, background_tasks_js_1.backgroundTasksFor)(sessionName);
|
|
1578
|
+
const task = running?.tasks.find((t) => t.id === taskId);
|
|
1579
|
+
if (!task || running?.panePid == null)
|
|
1580
|
+
return { result: 'not_found' };
|
|
1581
|
+
const result = await (0, background_tasks_stop_js_1.stopBackgroundTask)(task, running.panePid);
|
|
1582
|
+
(0, listener_log_js_1.log)(`⧉ tasks ${sessionName}: stop ${taskId} → ${result}`);
|
|
1583
|
+
return { result };
|
|
1584
|
+
};
|
|
1585
|
+
whenSealAdmitted(req, (phoneKey) => {
|
|
1586
|
+
answer(phoneKey)
|
|
1587
|
+
.then(reply)
|
|
1588
|
+
.catch((err) => {
|
|
1589
|
+
(0, listener_log_js_1.log)(`⧉ tasks ${sessionName}: stop failed (${err instanceof Error ? err.message : err})`);
|
|
1590
|
+
reply({ result: 'kill_failed' });
|
|
1591
|
+
});
|
|
1592
|
+
}, (reason) => { reply({ error: reason }); });
|
|
1593
|
+
return true;
|
|
1594
|
+
};
|
|
1595
|
+
exports.handleBackgroundTaskStopRequest = handleBackgroundTaskStopRequest;
|
|
1596
|
+
/**
|
|
1597
|
+
* Answer a phone's request for the end of a background command's output
|
|
1598
|
+
* (`readTaskOutputTail`: the last 16 KB of the file the transcript named, if
|
|
1599
|
+
* it is that task's `tasks/<id>.output`). A log can hold anything a command
|
|
1600
|
+
* printed, so the answer is sealed for a key that checked out and bound to
|
|
1601
|
+
* the session, the request and the task — never in the clear.
|
|
1602
|
+
*/
|
|
1603
|
+
const handleBackgroundTaskOutputRequest = (req, send) => {
|
|
1604
|
+
if (req.subtype !== 'agent.task.output.request')
|
|
1605
|
+
return false;
|
|
1606
|
+
if (typeof req.requestId !== 'string' || !req.requestId || req.requestId.length > MAX_REQUEST_ID_LENGTH)
|
|
1607
|
+
return false;
|
|
1608
|
+
if (typeof req.sessionName !== 'string' || !req.sessionName || req.sessionName.length > MAX_REQUEST_ID_LENGTH)
|
|
1609
|
+
return false;
|
|
1610
|
+
if (req.targetDeviceId !== (0, exports.computeListenerDeviceId)())
|
|
1611
|
+
return false;
|
|
1612
|
+
const { requestId, sessionName } = req;
|
|
1613
|
+
const frame = {
|
|
1614
|
+
subtype: 'agent.task.output.result',
|
|
1615
|
+
requestId,
|
|
1616
|
+
sessionName,
|
|
1617
|
+
...(typeof req.deviceId === 'string' && req.deviceId ? { targetDeviceId: req.deviceId } : {}),
|
|
1618
|
+
at: new Date().toISOString(),
|
|
1619
|
+
};
|
|
1620
|
+
const reply = (fields) => { send({ ...frame, ...fields }); };
|
|
1621
|
+
const asker = typeof req.deviceId === 'string' && /^[\w-]{1,64}$/.test(req.deviceId) ? req.deviceId : '';
|
|
1622
|
+
if (!(0, exports.checkRateLimit)(`tasks:output:${asker}`)) {
|
|
1623
|
+
reply({ error: 'rate_limited' });
|
|
1624
|
+
return true;
|
|
1625
|
+
}
|
|
1626
|
+
if (!asksForSeal(req)) {
|
|
1627
|
+
reply({ error: 'e2ee_required' });
|
|
1628
|
+
return true;
|
|
1629
|
+
}
|
|
1630
|
+
const { taskId } = req;
|
|
1631
|
+
if (typeof taskId !== 'string' || !TASK_ID_PATTERN.test(taskId)) {
|
|
1632
|
+
reply({ error: 'bad_request' });
|
|
1633
|
+
return true;
|
|
1634
|
+
}
|
|
1635
|
+
whenSealAdmitted(req, (phoneKey) => {
|
|
1636
|
+
const task = (0, background_tasks_js_1.backgroundTasksFor)(sessionName)?.tasks.find((t) => t.id === taskId);
|
|
1637
|
+
if (!task)
|
|
1638
|
+
return reply({ error: 'not_found' });
|
|
1639
|
+
const answer = async () => {
|
|
1640
|
+
const tail = await (0, background_tasks_output_js_1.readTaskOutputTail)(task);
|
|
1641
|
+
if ('error' in tail)
|
|
1642
|
+
return { error: tail.error };
|
|
1643
|
+
const body = (0, background_tasks_output_js_1.fitTaskOutputBody)({ requestId, at: frame.at, taskId }, tail, exports.SEALED_FRAME_MAX_BYTES);
|
|
1644
|
+
try {
|
|
1645
|
+
await (0, crypto_js_1.initDeviceCrypto)();
|
|
1646
|
+
return { encrypted: await (0, crypto_js_1.sealV2)('agent.task.output', body, phoneKey, sessionName) };
|
|
1647
|
+
}
|
|
1648
|
+
catch (err) {
|
|
1649
|
+
// A seal that fails is an error, never the output in the clear.
|
|
1650
|
+
(0, listener_log_js_1.log)(`⧉ tasks ${sessionName}: output seal failed (${err instanceof Error ? err.message : err})`);
|
|
1651
|
+
return { error: 'encrypt_failed' };
|
|
1652
|
+
}
|
|
1653
|
+
};
|
|
1654
|
+
answer().then(reply, (err) => {
|
|
1655
|
+
(0, listener_log_js_1.log)(`⧉ tasks ${sessionName}: output read failed (${err instanceof Error ? err.message : err})`);
|
|
1656
|
+
reply({ error: 'read_failed' });
|
|
1657
|
+
});
|
|
1658
|
+
}, (reason) => { reply({ error: reason }); });
|
|
1659
|
+
return true;
|
|
1660
|
+
};
|
|
1661
|
+
exports.handleBackgroundTaskOutputRequest = handleBackgroundTaskOutputRequest;
|
|
1432
1662
|
/** A run id as the registry writes it: tmux `session_created`, epoch seconds. */
|
|
1433
1663
|
const RUN_ID = /^\d{1,12}$/;
|
|
1434
1664
|
/** The name a wrapper family counts from: `hwip-x-3` → `hwip-x`. */
|
|
@@ -3497,6 +3727,8 @@ exports.handleCommandInput = handleCommandInput;
|
|
|
3497
3727
|
* viewer nothing but a re-render.
|
|
3498
3728
|
*/
|
|
3499
3729
|
const subagentScans = new Map();
|
|
3730
|
+
/** Per Claude session: what its transcript says is running in the background. */
|
|
3731
|
+
const backgroundTaskStates = new Map();
|
|
3500
3732
|
/**
|
|
3501
3733
|
* Inventory pass that also records *why* each `hwip-*` session was
|
|
3502
3734
|
* skipped. The verbose log uses the rejection notes to explain empty
|
|
@@ -3524,7 +3756,7 @@ const collectSessionsVerbose = () => {
|
|
|
3524
3756
|
// Invalidate so the next cycle re-runs full discovery instead of
|
|
3525
3757
|
// wedging the listener at "reported 0 session(s)" forever.
|
|
3526
3758
|
(0, tmux_socket_js_1.invalidateTmuxSocketCache)();
|
|
3527
|
-
return { sessions: [], rejected: [], targets: {}, subagentTranscripts: {}, recordable: {} };
|
|
3759
|
+
return { sessions: [], rejected: [], targets: {}, subagentTranscripts: {}, recordable: {}, backgroundTasks: {} };
|
|
3528
3760
|
}
|
|
3529
3761
|
const rawLines = (list.stdout ?? '').split('\n').filter(Boolean);
|
|
3530
3762
|
// Sanity-check that the format separator actually survived. tmux is
|
|
@@ -3587,6 +3819,7 @@ const collectSessionsVerbose = () => {
|
|
|
3587
3819
|
const resumeIdOf = new Map();
|
|
3588
3820
|
const targets = {};
|
|
3589
3821
|
const subagentTranscripts = {};
|
|
3822
|
+
const backgroundTasks = {};
|
|
3590
3823
|
const recordable = {};
|
|
3591
3824
|
for (const [name, group] of groups) {
|
|
3592
3825
|
// Main pane = the lowest (window, pane) index that runs an agent.
|
|
@@ -3664,7 +3897,7 @@ const collectSessionsVerbose = () => {
|
|
|
3664
3897
|
if (resumeId)
|
|
3665
3898
|
resumeIdOf.set(name, resumeId);
|
|
3666
3899
|
targets[name] = main.row.paneId;
|
|
3667
|
-
|
|
3900
|
+
const mainSession = {
|
|
3668
3901
|
name,
|
|
3669
3902
|
attached: group.attached,
|
|
3670
3903
|
agentKind: main.agent.kind,
|
|
@@ -3678,7 +3911,8 @@ const collectSessionsVerbose = () => {
|
|
|
3678
3911
|
runId: (0, exports.runIdOfEpoch)(group.created),
|
|
3679
3912
|
lastActivityAt: epochToIso(group.activity),
|
|
3680
3913
|
...(0, exports.deriveSessionState)(name, main.agent.kind, capturePaneText(name, main.row.paneId)),
|
|
3681
|
-
}
|
|
3914
|
+
};
|
|
3915
|
+
sessions.push(mainSession);
|
|
3682
3916
|
// Subagent panes: every OTHER pane of this session whose tty's
|
|
3683
3917
|
// foreground process group runs a registered agent (measured:
|
|
3684
3918
|
// pane_current_command alone says `bash` for a pi subagent — the
|
|
@@ -3740,6 +3974,15 @@ const collectSessionsVerbose = () => {
|
|
|
3740
3974
|
}
|
|
3741
3975
|
const transcriptPath = main.agent.kind === 'claude' ? mainTranscript : null;
|
|
3742
3976
|
if (transcriptPath) {
|
|
3977
|
+
// Background commands (`Bash` with run_in_background): the phone
|
|
3978
|
+
// shows how many, and asks for the rows themselves sealed.
|
|
3979
|
+
const tracked = backgroundTaskStates.get(name) ?? (0, background_tasks_js_1.initialBackgroundTaskState)();
|
|
3980
|
+
backgroundTaskStates.set(name, tracked);
|
|
3981
|
+
const tasks = (0, background_tasks_js_1.trackBackgroundTasks)(transcriptPath, tracked);
|
|
3982
|
+
if (tasks.length > 0)
|
|
3983
|
+
backgroundTasks[name] = { tasks, panePid: info.panePid ?? null };
|
|
3984
|
+
if (tasks.length > 0)
|
|
3985
|
+
mainSession.backgroundTaskCount = tasks.length;
|
|
3743
3986
|
const scan = subagentScans.get(name) ?? (0, subagent_transcripts_js_1.initialSubagentScanState)();
|
|
3744
3987
|
subagentScans.set(name, scan);
|
|
3745
3988
|
const reserved = new Set(extras.map(paneNum));
|
|
@@ -3781,6 +4024,7 @@ const collectSessionsVerbose = () => {
|
|
|
3781
4024
|
}
|
|
3782
4025
|
}
|
|
3783
4026
|
pruneToLive(subagentScans, new Set(groups.keys()));
|
|
4027
|
+
pruneToLive(backgroundTaskStates, new Set(groups.keys()));
|
|
3784
4028
|
pruneToLive(sessionStates, new Set(sessions.map((s) => s.name)));
|
|
3785
4029
|
// An id two panes both answer to belongs to at most one of them, and the
|
|
3786
4030
|
// sweep cannot tell which — opencode names the busiest session in the
|
|
@@ -3821,7 +4065,7 @@ const collectSessionsVerbose = () => {
|
|
|
3821
4065
|
providerSessionName: s.providerSessionName,
|
|
3822
4066
|
providerSessionNameSource: s.providerSessionNameSource,
|
|
3823
4067
|
})));
|
|
3824
|
-
return { sessions, rejected, targets, subagentTranscripts, recordable };
|
|
4068
|
+
return { sessions, rejected, targets, subagentTranscripts, recordable, backgroundTasks };
|
|
3825
4069
|
};
|
|
3826
4070
|
exports.collectSessionsVerbose = collectSessionsVerbose;
|
|
3827
4071
|
/**
|
|
@@ -3841,6 +4085,7 @@ const collectSessions = () => {
|
|
|
3841
4085
|
// back to the (focus-following) bare session name.
|
|
3842
4086
|
(0, tmux_pane_js_1.recordTargets)(result.targets);
|
|
3843
4087
|
(0, tmux_pane_js_1.recordSubagentTranscripts)(result.subagentTranscripts);
|
|
4088
|
+
(0, background_tasks_js_1.recordBackgroundTasks)(result.backgroundTasks);
|
|
3844
4089
|
return result.sessions;
|
|
3845
4090
|
};
|
|
3846
4091
|
/** Names from the most recent inventory sweep; null until the first one lands —
|
|
@@ -5295,11 +5540,12 @@ const streamSession = (wsUrl, apiKey) => {
|
|
|
5295
5540
|
}
|
|
5296
5541
|
};
|
|
5297
5542
|
const publishSessions = (inventory, sessions) => {
|
|
5298
|
-
const { rejected, targets, subagentTranscripts, recordable } = inventory;
|
|
5543
|
+
const { rejected, targets, subagentTranscripts, recordable, backgroundTasks } = inventory;
|
|
5299
5544
|
(0, exports.recordInventory)(sessions);
|
|
5300
5545
|
worktreeCleanup.observe(sessions.map((s) => s.name));
|
|
5301
5546
|
(0, tmux_pane_js_1.recordTargets)(targets);
|
|
5302
5547
|
(0, tmux_pane_js_1.recordSubagentTranscripts)(subagentTranscripts);
|
|
5548
|
+
(0, background_tasks_js_1.recordBackgroundTasks)(backgroundTasks);
|
|
5303
5549
|
turnRecorder?.observe(recordable);
|
|
5304
5550
|
// Let the user's own status bar say what the phone says. Change-gated
|
|
5305
5551
|
// internally, so an unchanged sweep spawns nothing.
|
|
@@ -5532,7 +5778,11 @@ const streamSession = (wsUrl, apiKey) => {
|
|
|
5532
5778
|
&& !(0, exports.handleFileTreeRequest)(m.data, sendEphemeral)
|
|
5533
5779
|
&& !(0, exports.handleFileBlobRequest)(m.data, sendEphemeral)
|
|
5534
5780
|
// Reading the schedules: files this machine wrote itself, sealed for the asker.
|
|
5535
|
-
&& !(0, exports.handleScheduleListRequest)(m.data, sendEphemeral)
|
|
5781
|
+
&& !(0, exports.handleScheduleListRequest)(m.data, sendEphemeral)
|
|
5782
|
+
// A session's background commands: what Claude said they are, sealed for the asker.
|
|
5783
|
+
&& !(0, exports.handleBackgroundTasksRequest)(m.data, sendEphemeral)
|
|
5784
|
+
&& !(0, exports.handleBackgroundTaskStopRequest)(m.data, sendEphemeral)
|
|
5785
|
+
&& !(0, exports.handleBackgroundTaskOutputRequest)(m.data, sendEphemeral)) {
|
|
5536
5786
|
void (0, exports.handleScreenRequest)(m.data)
|
|
5537
5787
|
.then((reply) => { if (reply)
|
|
5538
5788
|
sendEphemeral(reply); })
|
|
@@ -150,6 +150,11 @@ export declare const subagentDirFor: (parentTranscriptPath: string) => string;
|
|
|
150
150
|
* cares about the second.
|
|
151
151
|
*/
|
|
152
152
|
export declare const isSubagentTranscriptPath: (transcriptPath: string) => boolean;
|
|
153
|
+
/** The four ways Claude Code reports a background subagent stopping — measured
|
|
154
|
+
* 2026-09-14 over every parent transcript on this machine (787 completed, 12
|
|
155
|
+
* failed, 12 killed, 2 stopped, nothing else). */
|
|
156
|
+
export declare const TASK_END: RegExp;
|
|
157
|
+
export declare const TASK_ID: RegExp;
|
|
153
158
|
/**
|
|
154
159
|
* The live subagents of one Claude Code session, in chip order (by number).
|
|
155
160
|
*
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* the alternative failure is this feature vanishing in silence.
|
|
24
24
|
*/
|
|
25
25
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
-
exports.scanSubagents = exports.isSubagentTranscriptPath = exports.subagentDirFor = exports.initialSubagentScanState = exports.MAX_SUBAGENT_ROWS = exports.SUBAGENT_STALE_RECHECK_MS = exports.SUBAGENT_CATCH_UP_BYTES = exports.SUBAGENT_END_SLACK_MS = exports.SUBAGENT_ADDRESSABLE_MS = exports.SUBAGENT_LIVE_MS = exports.SUBAGENT_WORKING_MS = void 0;
|
|
26
|
+
exports.scanSubagents = exports.TASK_ID = exports.TASK_END = exports.isSubagentTranscriptPath = exports.subagentDirFor = exports.initialSubagentScanState = exports.MAX_SUBAGENT_ROWS = exports.SUBAGENT_STALE_RECHECK_MS = exports.SUBAGENT_CATCH_UP_BYTES = exports.SUBAGENT_END_SLACK_MS = exports.SUBAGENT_ADDRESSABLE_MS = exports.SUBAGENT_LIVE_MS = exports.SUBAGENT_WORKING_MS = void 0;
|
|
27
27
|
const node_fs_1 = require("node:fs");
|
|
28
28
|
const node_path_1 = require("node:path");
|
|
29
29
|
const transcript_tail_js_1 = require("./transcript-tail.js");
|
|
@@ -147,8 +147,8 @@ const isAgentToolUse = (line) => {
|
|
|
147
147
|
/** The four ways Claude Code reports a background subagent stopping — measured
|
|
148
148
|
* 2026-09-14 over every parent transcript on this machine (787 completed, 12
|
|
149
149
|
* failed, 12 killed, 2 stopped, nothing else). */
|
|
150
|
-
|
|
151
|
-
|
|
150
|
+
exports.TASK_END = /<status>(?:completed|failed|killed|stopped)<\/status>/;
|
|
151
|
+
exports.TASK_ID = /<task-id>([^<]{1,200})<\/task-id>/;
|
|
152
152
|
/**
|
|
153
153
|
* The subagent a parent line says has ended, and when — or null.
|
|
154
154
|
*
|
|
@@ -182,9 +182,9 @@ const subagentEndOf = (line) => {
|
|
|
182
182
|
if (result?.status === 'completed' && typeof result.agentId === 'string')
|
|
183
183
|
return { agentId: result.agentId, at };
|
|
184
184
|
const content = entry.message?.content;
|
|
185
|
-
if (entry.origin?.kind !== 'task-notification' || typeof content !== 'string' || !TASK_END.test(content))
|
|
185
|
+
if (entry.origin?.kind !== 'task-notification' || typeof content !== 'string' || !exports.TASK_END.test(content))
|
|
186
186
|
return null;
|
|
187
|
-
const agentId = TASK_ID.exec(content)?.[1];
|
|
187
|
+
const agentId = exports.TASK_ID.exec(content)?.[1];
|
|
188
188
|
return agentId ? { agentId, at } : null;
|
|
189
189
|
};
|
|
190
190
|
/**
|