@hwip/cli 0.1.1 → 0.1.2
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 +21 -1
- package/dist/answer-files.d.ts +39 -0
- package/dist/answer-files.js +35 -0
- package/dist/ask-seal.d.ts +5 -1
- package/dist/ask-seal.js +4 -1
- package/dist/ask.d.ts +13 -0
- package/dist/ask.js +37 -6
- package/dist/config.d.ts +14 -0
- package/dist/config.js +23 -1
- package/dist/listener.d.ts +50 -2
- package/dist/listener.js +159 -30
- package/dist/remote-agents.d.ts +22 -0
- package/dist/remote-agents.js +22 -5
- package/dist/run-seqs.d.ts +28 -0
- package/dist/run-seqs.js +79 -0
- package/dist/session-registry.d.ts +34 -4
- package/dist/session-registry.js +50 -14
- package/dist/templates.js +16 -5
- package/dist/tmux-liveness.d.ts +7 -0
- package/dist/tmux-liveness.js +22 -1
- package/dist/turn-recorder-wire.js +1 -1
- package/dist/turn-recorder.d.ts +8 -0
- package/dist/turn-recorder.js +10 -2
- package/dist/wrapper.d.ts +12 -4
- package/dist/wrapper.js +47 -6
- package/package.json +16 -2
package/README.md
CHANGED
|
@@ -535,6 +535,11 @@ offers it during setup and `hwip uninstall` takes it away again.
|
|
|
535
535
|
What you get back after a reboot is your **past sessions**, ready to
|
|
536
536
|
resume from the phone — not live agents. tmux does not survive a reboot
|
|
537
537
|
either, and this doesn't try to bring those sessions back to life.
|
|
538
|
+
A resume starts the agent in the directory it last ran in; for Claude Code
|
|
539
|
+
and OpenCode it also picks up the conversation that session last had
|
|
540
|
+
(`--resume <id>` / `--session <id>`, the id this machine read off the live
|
|
541
|
+
pane's own process — an id two panes both answered to is not kept). Claude Code starts fresh when that conversation is no longer on disk,
|
|
542
|
+
and the other agents always start a new one.
|
|
538
543
|
|
|
539
544
|
Two details worth knowing:
|
|
540
545
|
|
|
@@ -646,6 +651,21 @@ transcript, then forgotten. A session that has been quiet for a long time keeps
|
|
|
646
651
|
state while its transcript exists, so nothing already sent is sent again (an OpenCode
|
|
647
652
|
session, which has no file to check, for 30 days).
|
|
648
653
|
|
|
654
|
+
A tmux name is used again when an agent starts in the same folder, so each
|
|
655
|
+
chunk also names its **run** — the tmux session it was recorded in (`runId`,
|
|
656
|
+
tmux's `session_created` in epoch seconds). The app uses it to keep one run's
|
|
657
|
+
conversation apart from the one before it under the same name; a chunk read
|
|
658
|
+
after a restart keeps the run it was recorded in. The listener's record of past
|
|
659
|
+
sessions keeps runs apart the same way: closing `hwip cc` and starting it again
|
|
660
|
+
in the same folder leaves the first run as a past session of its own (the
|
|
661
|
+
newest ten runs of a name are kept, 100 in all, and up to 50 are reported to
|
|
662
|
+
the phone). Each run is reported with the block of chunk numbers it took — the
|
|
663
|
+
first and, once it has ended, the last — kept in `run-seqs.json` next to the
|
|
664
|
+
registry, so the server reads one run's conversation from that range instead of
|
|
665
|
+
every chunk the name ever had. Deleting a past run from the phone forgets that
|
|
666
|
+
run on this machine too, even while a later run of the same name is going; the
|
|
667
|
+
run that is going is refused (`still_running`).
|
|
668
|
+
|
|
649
669
|
When a session waits for your approval, the listener also sends what it is waiting
|
|
650
670
|
for — the tool, the command or the diff, and the answers — so the app can show it
|
|
651
671
|
and answer. That covers Claude Code (a Bash command, a file edit or a new file),
|
|
@@ -1249,7 +1269,7 @@ hwip notify --title "Hello" --json
|
|
|
1249
1269
|
| `verify` | Check installation health across detected agents (`--ping` for a live API call, with the key and API base every command resolves: `--key`, `--base-url`, then env, then config; `--json` prints the checks as one JSON object). Reports whether tmux is on PATH and whether the listener is running — both warnings, since notifications alone need neither. A settings file it cannot parse is a `!` line, not "not registered". Shows the last failed push, if any (see *Last push failure*). Also reports the resolved WebSocket URL **and where it came from** — `--ws-url`, config, the deprecated `HWIP_WS_URL`, or the built-in default — warns when the socket and the API base are on different stages, and warns when the running listener is on a different URL than the one that resolves now (it reads the config once, at startup) |
|
|
1250
1270
|
| `check-update` | Check whether a newer Hwip version is on npm |
|
|
1251
1271
|
| `notify` | Send a push notification |
|
|
1252
|
-
| `ask` | Ask the phone a question and wait for the answer — for hooks, which cannot call the MCP `hwip_ask` tool. `--title` (required), `--body`, `--actions id:Label,…`, `--timeout <s>` (default 60), `--accepts-exit` for an agent's own ask (the phone may "send and exit", and the answer settles REMOTE like `hwip_ask`). Key and API base resolve like every command. Prints one JSON line — `{"answered":true,"actionId":…}` or `{"value":…}`, else `{"answered":false,"reason":"cancelled"\|"timeout"\|"error"}`; `"resumed":true` is added when the outcome is of a question an earlier run of the same command put up (see "A question cut short"). Exit 0 answered, 1 not. The question and the answer travel sealed, always (see Encryption); an answer that cannot be verified is an error, never an approval. With no app signed in to the account it prints `no app is signed in to this account` and exits 1 |
|
|
1272
|
+
| `ask` | Ask the phone a question and wait for the answer — for hooks, which cannot call the MCP `hwip_ask` tool. `--title` (required), `--body`, `--actions id:Label,…`, `--timeout <s>` (default 60), `--accepts-exit` for an agent's own ask (the phone may "send and exit" and attach files, and the answer settles REMOTE like `hwip_ask`; attached files are saved sealed-and-opened to `~/.hwip/attachments/<eventId>/`, their paths follow the text in `value` and are listed in `files`, and `missingFiles` names any not saved). Key and API base resolve like every command. Prints one JSON line — `{"answered":true,"actionId":…}` or `{"value":…}`, else `{"answered":false,"reason":"cancelled"\|"timeout"\|"error"}`; `"resumed":true` is added when the outcome is of a question an earlier run of the same command put up (see "A question cut short"). Exit 0 answered, 1 not. The question and the answer travel sealed, always (see Encryption); an answer that cannot be verified is an error, never an approval. With no app signed in to the account it prints `no app is signed in to this account` and exits 1 |
|
|
1253
1273
|
| `ask-context [status\|on\|off\|show] [--global]` | The switch for the context a sealed question carries (a git diff against HEAD and the terminal's last lines, masked, sealed on every send — see Encryption). On unless turned off: `off` for the project the current directory is in (its repository root), `off --global` for every project, `on` to undo either; `status` says which applies. It is a state file under `$XDG_STATE_HOME/hwip` (`ask-context-off-<project hash>`, `ask-context-off-default`) and never leaves this machine; `HWIP_ASK_CONTEXT=0` switches it off for one run. A switch file that cannot be read counts as off. `show` prints one JSON line, `{"context": …}` — the summary and the masked original a question asked from the current directory would carry, `null` when there is nothing, with `"off"` naming why when the switch is off — and sends nothing (the MCP `hwip_ask` tool reads it) |
|
|
1254
1274
|
| `file-view [status\|on\|off\|log] [--global]` | The switch for the phone browsing this computer's files (a session's repository — see Defense). On unless turned off: `off` for the project the current directory is in (its repository root), `off --global` for every project, `on` to undo either; `status` says which applies. A state file under `$XDG_STATE_HOME/hwip` (`file-view-off-<project hash>`, `file-view-off-default`) that never leaves this machine; a switch file that cannot be read counts as off. `log` prints the last 50 reads the phone made, from `~/.hwip/listener.log` (time, device, session, listing or file, path, bytes, secrets masked, result) |
|
|
1255
1275
|
| `list` | List recent push notifications. An end-to-end encrypted push reads `[encrypted]` — this host cannot open it |
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { SealedFileMeta } from './crypto.js';
|
|
2
|
+
/**
|
|
3
|
+
* Files attached to a sealed answer of `hwip ask`. The answer's seal names each file (`SealedFileMeta`:
|
|
4
|
+
* its real name, type and size, matched by `iv`); the response's wire `files` carry the sealed bytes'
|
|
5
|
+
* `fileKey` and one wrapped key per device. The bytes are fetched and opened by the listener's
|
|
6
|
+
* `downloadSealedEach` — the same fetch and open as a sealed command's attachments, one file at a time — into
|
|
7
|
+
* `~/.hwip/attachments/<eventId>/`.
|
|
8
|
+
*/
|
|
9
|
+
/** One wire file as the server keeps it for a sealed answer (hwip `server/src/functions/hooks.ts`). */
|
|
10
|
+
export interface AnswerWireFile {
|
|
11
|
+
fileKey: string;
|
|
12
|
+
fileName: string;
|
|
13
|
+
fileType?: string;
|
|
14
|
+
fileSize?: number;
|
|
15
|
+
iv: string;
|
|
16
|
+
deviceKeyMap: Record<string, string>;
|
|
17
|
+
}
|
|
18
|
+
export interface SavedAnswerFiles {
|
|
19
|
+
/** Absolute paths of the files saved. */
|
|
20
|
+
paths: string[];
|
|
21
|
+
/** One `<name>: <why>` per file the seal names that was not saved. */
|
|
22
|
+
missing: string[];
|
|
23
|
+
}
|
|
24
|
+
/** Who opens the files: this machine's slot, and the phone key the answer's seal was checked against. */
|
|
25
|
+
export interface AnswerFileOpener {
|
|
26
|
+
deviceId: string;
|
|
27
|
+
senderPublicKey: string;
|
|
28
|
+
}
|
|
29
|
+
/** The listener's per-file sealed download (`listener.ts` `downloadSealedEach`): each file saved or named with why. */
|
|
30
|
+
export type DownloadSealed = (dirId: string, files: AnswerWireFile[], sealed: AnswerFileOpener & {
|
|
31
|
+
files: SealedFileMeta[];
|
|
32
|
+
}) => Promise<SavedAnswerFiles>;
|
|
33
|
+
/**
|
|
34
|
+
* Save what the seal names and the wire delivered for this machine. A file the seal names with no wire
|
|
35
|
+
* file, or no slot here, is reported missing; the rest go to the listener's download in the seal's
|
|
36
|
+
* order, each saved or reported on its own. A download that throws as a whole reports each file with why.
|
|
37
|
+
*/
|
|
38
|
+
export declare const saveAnswerFiles: (eventId: string, wire: unknown, metas: SealedFileMeta[], opener: AnswerFileOpener, download: DownloadSealed) => Promise<SavedAnswerFiles>;
|
|
39
|
+
//# sourceMappingURL=answer-files.d.ts.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.saveAnswerFiles = void 0;
|
|
4
|
+
/** A relay's wire file, parsed: an object with a fileKey, an iv and a key map. */
|
|
5
|
+
const isWireFile = (f) => {
|
|
6
|
+
if (typeof f !== 'object' || f === null)
|
|
7
|
+
return false;
|
|
8
|
+
const { fileKey, iv, deviceKeyMap } = f; // reading an unknown object's fields, each checked here
|
|
9
|
+
return typeof fileKey === 'string' && fileKey !== '' && typeof iv === 'string' && typeof deviceKeyMap === 'object' && deviceKeyMap !== null;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Save what the seal names and the wire delivered for this machine. A file the seal names with no wire
|
|
13
|
+
* file, or no slot here, is reported missing; the rest go to the listener's download in the seal's
|
|
14
|
+
* order, each saved or reported on its own. A download that throws as a whole reports each file with why.
|
|
15
|
+
*/
|
|
16
|
+
const saveAnswerFiles = async (eventId, wire, metas, opener, download) => {
|
|
17
|
+
const given = Array.isArray(wire) ? wire.filter(isWireFile) : [];
|
|
18
|
+
const pairs = metas.map((meta) => ({
|
|
19
|
+
meta,
|
|
20
|
+
file: given.find((f) => f.iv === meta.iv && typeof f.deviceKeyMap[opener.deviceId] === 'string'),
|
|
21
|
+
}));
|
|
22
|
+
const missing = pairs.filter((p) => !p.file).map((p) => `${p.meta.name}: not delivered`);
|
|
23
|
+
const delivered = pairs.flatMap((p) => (p.file ? [{ meta: p.meta, file: p.file }] : []));
|
|
24
|
+
if (delivered.length === 0)
|
|
25
|
+
return { paths: [], missing };
|
|
26
|
+
try {
|
|
27
|
+
const saved = await download(eventId, delivered.map((d) => d.file), { ...opener, files: delivered.map((d) => d.meta) });
|
|
28
|
+
return { paths: saved.paths, missing: [...missing, ...saved.missing] };
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
32
|
+
return { paths: [], missing: [...missing, ...delivered.map((d) => `${d.meta.name}: ${why}`)] };
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
exports.saveAnswerFiles = saveAnswerFiles;
|
package/dist/ask-seal.d.ts
CHANGED
|
@@ -94,11 +94,15 @@ export type PreparedAsk = {
|
|
|
94
94
|
why?: RegisterWhy;
|
|
95
95
|
detail?: string;
|
|
96
96
|
};
|
|
97
|
-
export type OpenedAnswer =
|
|
97
|
+
export type OpenedAnswer =
|
|
98
|
+
/** `files` comes with the key that sealed them, which opens each file's slot (answer-files.ts). */
|
|
99
|
+
{
|
|
98
100
|
ok: true;
|
|
99
101
|
actionId?: string;
|
|
100
102
|
value?: string;
|
|
101
103
|
exit?: true;
|
|
104
|
+
files?: SealedFileMeta[];
|
|
105
|
+
senderPublicKey?: string;
|
|
102
106
|
} | {
|
|
103
107
|
ok: false;
|
|
104
108
|
cause: AskRefusal;
|
package/dist/ask-seal.js
CHANGED
|
@@ -163,13 +163,16 @@ const openAnswer = async (response, askId, offered, deps) => {
|
|
|
163
163
|
const { actionId, exit } = opened.answer;
|
|
164
164
|
if (actionId !== undefined && (offered === undefined || !offered.includes(actionId)))
|
|
165
165
|
return { ok: false, cause: 'unknown-action' };
|
|
166
|
-
|
|
166
|
+
// What the seal says each attached file is; the bytes come with the response's wire `files` (answer-files.ts).
|
|
167
|
+
const files = opened.files?.length ? opened.files : undefined;
|
|
168
|
+
if (actionId === undefined && !opened.body && exit !== true && !files)
|
|
167
169
|
return { ok: false, cause: 'empty-answer' };
|
|
168
170
|
return {
|
|
169
171
|
ok: true,
|
|
170
172
|
...(actionId === undefined ? {} : { actionId }),
|
|
171
173
|
...(opened.body ? { value: opened.body } : {}),
|
|
172
174
|
...(exit === true ? { exit } : {}),
|
|
175
|
+
...(files ? { files, senderPublicKey: sealed.senderPublicKey } : {}),
|
|
173
176
|
};
|
|
174
177
|
};
|
|
175
178
|
exports.openAnswer = openAnswer;
|
package/dist/ask.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type SealedFileMeta } from './crypto.js';
|
|
2
|
+
import { type AnswerFileOpener, type SavedAnswerFiles } from './answer-files.js';
|
|
1
3
|
import { type AskSealDeps } from './ask-seal.js';
|
|
2
4
|
import { type PendingAsks } from './ask-pending.js';
|
|
3
5
|
/** Poll cadence: every second for the first 30 s, when a tap is most likely
|
|
@@ -33,6 +35,11 @@ export interface AskOptions {
|
|
|
33
35
|
* stray `exitRemote` on its answer is dropped.
|
|
34
36
|
*/
|
|
35
37
|
acceptsExit?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Lets the phone attach files to a typed answer; they are saved to `~/.hwip/attachments/<eventId>/`
|
|
40
|
+
* and their paths follow the text. Set with `acceptsExit`: only an agent's own ask reads a reply.
|
|
41
|
+
*/
|
|
42
|
+
acceptsFiles?: boolean;
|
|
36
43
|
}
|
|
37
44
|
/** Injected so the poll loop is testable without a clock or a network. */
|
|
38
45
|
export interface AskDeps {
|
|
@@ -43,6 +50,8 @@ export interface AskDeps {
|
|
|
43
50
|
seal: AskSealDeps;
|
|
44
51
|
/** Where a question that went up is kept, so one cut short can be taken up again. Without it, every ask is a new question. */
|
|
45
52
|
pending?: PendingAsks;
|
|
53
|
+
/** Saves the files a sealed answer names (answer-files.ts). Without it they are left on the server. */
|
|
54
|
+
saveFiles?: (eventId: string, wire: unknown, metas: SealedFileMeta[], opener: AnswerFileOpener) => Promise<SavedAnswerFiles>;
|
|
46
55
|
}
|
|
47
56
|
/** Why an ask ended unanswered: the user cancelled it on the phone, nobody
|
|
48
57
|
* answered in time, or the server could not be reached or refused. */
|
|
@@ -54,6 +63,10 @@ export type AskOutcome = ({
|
|
|
54
63
|
readonly answered: true;
|
|
55
64
|
readonly value: string;
|
|
56
65
|
readonly exitRemote?: true;
|
|
66
|
+
/** Where the answer's files were saved; their paths also follow the text in `value`. */
|
|
67
|
+
readonly files?: readonly string[];
|
|
68
|
+
/** `<name>: <why>` for each attached file that was not saved. */
|
|
69
|
+
readonly missingFiles?: readonly string[];
|
|
57
70
|
} | {
|
|
58
71
|
readonly answered: false;
|
|
59
72
|
readonly reason: UnansweredReason;
|
package/dist/ask.js
CHANGED
|
@@ -29,6 +29,7 @@ const insecure_url_js_1 = require("./insecure-url.js");
|
|
|
29
29
|
const args_js_1 = require("./args.js");
|
|
30
30
|
const api_js_1 = require("./api.js");
|
|
31
31
|
const crypto_js_1 = require("./crypto.js");
|
|
32
|
+
const answer_files_js_1 = require("./answer-files.js");
|
|
32
33
|
const listener_device_id_js_1 = require("./listener-device-id.js");
|
|
33
34
|
const config_js_2 = require("./config.js");
|
|
34
35
|
const self_register_js_1 = require("./self-register.js");
|
|
@@ -211,6 +212,7 @@ const trigger = async (deps, opts, deadline, prepared) => {
|
|
|
211
212
|
agentDeviceId: opts.agentDeviceId,
|
|
212
213
|
agentSessionName: opts.agentSessionName,
|
|
213
214
|
...(opts.acceptsExit ? { acceptsExit: true } : {}),
|
|
215
|
+
...(opts.acceptsFiles ? { acceptsFiles: true } : {}),
|
|
214
216
|
});
|
|
215
217
|
const payload = { body, headers: { 'Idempotency-Key': (0, node_crypto_1.randomUUID)() } };
|
|
216
218
|
let backoffs = 0;
|
|
@@ -238,18 +240,38 @@ const trigger = async (deps, opts, deadline, prepared) => {
|
|
|
238
240
|
}
|
|
239
241
|
}
|
|
240
242
|
};
|
|
241
|
-
/**
|
|
242
|
-
|
|
243
|
+
/**
|
|
244
|
+
* What an opened answer means to the caller: the choice, or the text (with the exit when this ask offers
|
|
245
|
+
* it). Saved files' paths follow the text, so a reader of `value` alone still finds them.
|
|
246
|
+
*/
|
|
247
|
+
const sealedOutcome = (opened, acceptsExit, saved) => {
|
|
243
248
|
if (!opened.ok)
|
|
244
249
|
return { answered: false, reason: 'error', error: (0, ask_seal_js_1.refusalMessage)(opened.cause) };
|
|
245
250
|
if (opened.actionId !== undefined)
|
|
246
251
|
return { answered: true, actionId: opened.actionId };
|
|
252
|
+
const value = [opened.value ?? '', ...(saved?.paths ?? [])].filter(Boolean).join('\n');
|
|
253
|
+
const files = {
|
|
254
|
+
...(saved?.paths.length ? { files: saved.paths } : {}),
|
|
255
|
+
...(saved?.missing.length ? { missingFiles: saved.missing } : {}),
|
|
256
|
+
};
|
|
247
257
|
if (opened.exit === true && acceptsExit)
|
|
248
|
-
return { answered: true, value
|
|
249
|
-
if (
|
|
250
|
-
return { answered: true, value
|
|
258
|
+
return { answered: true, value, exitRemote: true, ...files };
|
|
259
|
+
if (value || saved)
|
|
260
|
+
return { answered: true, value, ...files };
|
|
251
261
|
return { answered: false, reason: 'error', error: (0, ask_seal_js_1.refusalMessage)('empty-answer') };
|
|
252
262
|
};
|
|
263
|
+
/** The answer's files, saved — when this ask took files, the answer names some, and this machine has its key. */
|
|
264
|
+
const saveFilesOf = async (deps, opts, eventId, response, opened) => {
|
|
265
|
+
if (!opened.ok || !opened.files || !opened.senderPublicKey)
|
|
266
|
+
return undefined;
|
|
267
|
+
// Not offered on this ask: said, not dropped.
|
|
268
|
+
if (!opts.acceptsFiles || !deps.saveFiles)
|
|
269
|
+
return { paths: [], missing: opened.files.map((m) => `${m.name}: this ask does not take files`) };
|
|
270
|
+
const deviceId = deps.seal.self()?.deviceId;
|
|
271
|
+
if (!deviceId)
|
|
272
|
+
return { paths: [], missing: opened.files.map((m) => `${m.name}: this machine has no key`) };
|
|
273
|
+
return deps.saveFiles(eventId, response?.files, opened.files, { deviceId, senderPublicKey: opened.senderPublicKey });
|
|
274
|
+
};
|
|
253
275
|
/**
|
|
254
276
|
* Read the question's answer until there is one, the question ends without, or `until` passes — once
|
|
255
277
|
* at least, whatever the clock says: the answer may be there already.
|
|
@@ -277,7 +299,8 @@ const awaitAnswer = async (deps, opts, asked, until, unconfirmed) => {
|
|
|
277
299
|
lastError = new Error((0, ask_seal_js_1.refusalMessage)(opened.cause));
|
|
278
300
|
}
|
|
279
301
|
else {
|
|
280
|
-
|
|
302
|
+
const saved = await saveFilesOf(deps, opts, asked.eventId, event.data.response, opened);
|
|
303
|
+
return { outcome: sealedOutcome(opened, opts.acceptsExit === true, saved), end: mayPass ? 'open' : 'over' };
|
|
281
304
|
}
|
|
282
305
|
}
|
|
283
306
|
if (event.data?.status === 'cancelled')
|
|
@@ -478,6 +501,13 @@ const liveDeps = (account) => ({
|
|
|
478
501
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
479
502
|
seal: (0, exports.liveSealDeps)(account.apiKey, account.baseUrl, account.deadline),
|
|
480
503
|
pending: (0, ask_pending_js_1.pendingAsks)((0, gate_js_1.stateDir)()),
|
|
504
|
+
saveFiles: async (eventId, wire, metas, opener) => {
|
|
505
|
+
// The listener's download, borrowed: its log is the daemon's stdout, and this command's stdout is one JSON line.
|
|
506
|
+
const { downloadSealedEach } = await import('./listener.js');
|
|
507
|
+
const { silenceListenerLog } = await import('./listener-log.js');
|
|
508
|
+
silenceListenerLog();
|
|
509
|
+
return (0, answer_files_js_1.saveAnswerFiles)(eventId, wire, metas, opener, (dirId, files, sealed) => downloadSealedEach(dirId, files, { apiKey: account.apiKey, baseUrl: account.baseUrl }, sealed));
|
|
510
|
+
},
|
|
481
511
|
});
|
|
482
512
|
exports.liveDeps = liveDeps;
|
|
483
513
|
/**
|
|
@@ -536,6 +566,7 @@ const handleAsk = async (args) => {
|
|
|
536
566
|
timeoutSeconds: windowSeconds,
|
|
537
567
|
...((0, agent_session_js_1.agentSessionContext)() ?? {}),
|
|
538
568
|
acceptsExit,
|
|
569
|
+
acceptsFiles: acceptsExit,
|
|
539
570
|
}, (0, exports.liveDeps)({ apiKey, baseUrl: baseUrl.replace(/\/+$/, ''), deadline: Date.now() + windowSeconds * 1000 }));
|
|
540
571
|
// The agent's own ask settles REMOTE as mcp-server's hwip_ask does and
|
|
541
572
|
// reports it in the same `hwipState` field; an approval gate leaves the
|
package/dist/config.d.ts
CHANGED
|
@@ -51,6 +51,20 @@ export declare const HERDR_SESSION_OPTIONS: {
|
|
|
51
51
|
readonly HERDR_SOCKET_PATH: "@hwip_herdr_socket";
|
|
52
52
|
readonly HERDR_BIN_PATH: "@hwip_herdr_bin";
|
|
53
53
|
};
|
|
54
|
+
/**
|
|
55
|
+
* The env herdr gives the processes of its panes, and its native agent integrations
|
|
56
|
+
* (`herdr integration install <agent>`) read to find their pane. Under tmux an agent
|
|
57
|
+
* inherits the tmux server's global env, copied from the terminal that started the
|
|
58
|
+
* server: a herdr pane long closed, never the pane attaching now. The wrapper sets
|
|
59
|
+
* them on every session it creates (wrapper `herdrEnvArgs`).
|
|
60
|
+
*/
|
|
61
|
+
export declare const HERDR_ENV_VARS: readonly ["HERDR_ENV", "HERDR_PANE_ID", "HERDR_SOCKET_PATH", "HERDR_BIN_PATH", "HERDR_TAB_ID", "HERDR_WORKSPACE_ID", "HERDR_STARTUP_CWD"];
|
|
62
|
+
/**
|
|
63
|
+
* Agent kinds hwip reports to herdr itself (`HERDR_SESSION_OPTIONS`). herdr's native
|
|
64
|
+
* integration must stay silent for them: a second report on the pane replaces hwip's
|
|
65
|
+
* label, and its release drops both (measured on herdr 0.9.3).
|
|
66
|
+
*/
|
|
67
|
+
export declare const HERDR_SELF_REPORTING_KINDS: ReadonlySet<string>;
|
|
54
68
|
/**
|
|
55
69
|
* Set by `hwip sh` on the session it opens, to that session's own name. The
|
|
56
70
|
* one exception to the listener's shell-pane refusal. Only a process on this
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.VERSION = exports.saveConfig = exports.configWriteProblem = exports.resolveBaseUrl = exports.checkBaseUrl = exports.fileKeyBaseRefusal = exports.keyIsFromFile = exports.configIgnoredBecause = exports.loadConfig = exports.configModeProblem = exports.detectProjectDir = exports.PROJECT_DIR_ENV_VARS = exports.groupOf = exports.checkoutOf = exports.tmuxName = exports.SESSION_SHELL_OPTION = exports.HERDR_SESSION_OPTIONS = exports.SESSION_LABEL_OPTION = exports.SESSION_PROJECT_OPTION = exports.resolveHookId = exports.resolvedEnv = exports.unreadableConfigDefaults = exports.TRUST_FILE = exports.CONFIG_FILE = exports.CONFIG_DIR = void 0;
|
|
3
|
+
exports.VERSION = exports.saveConfig = exports.configWriteProblem = exports.resolveBaseUrl = exports.checkBaseUrl = exports.fileKeyBaseRefusal = exports.keyIsFromFile = exports.configIgnoredBecause = exports.loadConfig = exports.configModeProblem = exports.detectProjectDir = exports.PROJECT_DIR_ENV_VARS = exports.groupOf = exports.checkoutOf = exports.tmuxName = exports.SESSION_SHELL_OPTION = exports.HERDR_SELF_REPORTING_KINDS = exports.HERDR_ENV_VARS = exports.HERDR_SESSION_OPTIONS = exports.SESSION_LABEL_OPTION = exports.SESSION_PROJECT_OPTION = exports.resolveHookId = exports.resolvedEnv = exports.unreadableConfigDefaults = exports.TRUST_FILE = exports.CONFIG_FILE = exports.CONFIG_DIR = void 0;
|
|
4
4
|
const child_process_1 = require("child_process");
|
|
5
5
|
const fs_1 = require("fs");
|
|
6
6
|
const os_1 = require("os");
|
|
@@ -42,6 +42,28 @@ exports.HERDR_SESSION_OPTIONS = {
|
|
|
42
42
|
HERDR_SOCKET_PATH: '@hwip_herdr_socket',
|
|
43
43
|
HERDR_BIN_PATH: '@hwip_herdr_bin',
|
|
44
44
|
};
|
|
45
|
+
/**
|
|
46
|
+
* The env herdr gives the processes of its panes, and its native agent integrations
|
|
47
|
+
* (`herdr integration install <agent>`) read to find their pane. Under tmux an agent
|
|
48
|
+
* inherits the tmux server's global env, copied from the terminal that started the
|
|
49
|
+
* server: a herdr pane long closed, never the pane attaching now. The wrapper sets
|
|
50
|
+
* them on every session it creates (wrapper `herdrEnvArgs`).
|
|
51
|
+
*/
|
|
52
|
+
exports.HERDR_ENV_VARS = [
|
|
53
|
+
'HERDR_ENV',
|
|
54
|
+
'HERDR_PANE_ID',
|
|
55
|
+
'HERDR_SOCKET_PATH',
|
|
56
|
+
'HERDR_BIN_PATH',
|
|
57
|
+
'HERDR_TAB_ID',
|
|
58
|
+
'HERDR_WORKSPACE_ID',
|
|
59
|
+
'HERDR_STARTUP_CWD',
|
|
60
|
+
];
|
|
61
|
+
/**
|
|
62
|
+
* Agent kinds hwip reports to herdr itself (`HERDR_SESSION_OPTIONS`). herdr's native
|
|
63
|
+
* integration must stay silent for them: a second report on the pane replaces hwip's
|
|
64
|
+
* label, and its release drops both (measured on herdr 0.9.3).
|
|
65
|
+
*/
|
|
66
|
+
exports.HERDR_SELF_REPORTING_KINDS = new Set(['claude', 'pi']);
|
|
45
67
|
/**
|
|
46
68
|
* Set by `hwip sh` on the session it opens, to that session's own name. The
|
|
47
69
|
* one exception to the listener's shell-pane refusal. Only a process on this
|
package/dist/listener.d.ts
CHANGED
|
@@ -39,6 +39,7 @@ import { silenceListenerLog } from './listener-log.js';
|
|
|
39
39
|
import { invalidateTmuxSocketCache, tmuxArgs } from './tmux-socket.js';
|
|
40
40
|
import { detectRemoteAgent, isSubagentSessionName, recordSubagentTranscripts, recordTargets, subagentTranscriptFor, tmuxTargetFor } from './tmux-pane.js';
|
|
41
41
|
import { liveSessionNames, sessionExists, sessionLiveness } from './tmux-liveness.js';
|
|
42
|
+
import { type RunSeqs } from './run-seqs.js';
|
|
42
43
|
import { type PhoneScheduleDeps } from './schedule-remote.js';
|
|
43
44
|
import { type ScheduleRunnerDeps } from './schedule-runner.js';
|
|
44
45
|
export declare const PING_IDLE_MS = 240000;
|
|
@@ -120,6 +121,16 @@ interface AgentSession {
|
|
|
120
121
|
*/
|
|
121
122
|
providerSessionName?: string | null;
|
|
122
123
|
createdAt?: string;
|
|
124
|
+
/**
|
|
125
|
+
* Which run of the name this is (hwip docs/session-runs.md): tmux
|
|
126
|
+
* `session_created` in epoch seconds, as a decimal string — the same
|
|
127
|
+
* instant as `createdAt`. A name is handed out again when an agent starts
|
|
128
|
+
* in the same folder; this is what tells the two apart. Absent on subagent
|
|
129
|
+
* rows, which are panes of their parent's run.
|
|
130
|
+
*/
|
|
131
|
+
runId?: string;
|
|
132
|
+
/** The smallest seq this run's chunks took (hwip docs/session-runs.md Q24): the server reads its conversation from there up. */
|
|
133
|
+
firstSeq?: number;
|
|
123
134
|
lastActivityAt?: string;
|
|
124
135
|
/**
|
|
125
136
|
* Detected agent state (wire contract — flows to the server and the
|
|
@@ -196,15 +207,23 @@ export type StaleConnectionAction =
|
|
|
196
207
|
* tested as what it is: two guards around one lever.
|
|
197
208
|
*/
|
|
198
209
|
export declare const staleConnectionAction: (message: unknown, drops: number) => StaleConnectionAction;
|
|
199
|
-
export declare const KNOWN_SESSIONS_REPORTED =
|
|
210
|
+
export declare const KNOWN_SESSIONS_REPORTED = 50;
|
|
200
211
|
export interface ReportedKnownSession {
|
|
201
212
|
name: string;
|
|
202
213
|
agentKind: string;
|
|
203
214
|
project?: string;
|
|
204
215
|
label?: string;
|
|
216
|
+
/** The run's id and start (hwip docs/session-runs.md); absent for entries recorded before runs. */
|
|
217
|
+
runId?: string;
|
|
218
|
+
startedAt?: string;
|
|
219
|
+
/** The seq block the run's chunks took (Q24): the server reads its conversation from that key range alone. */
|
|
220
|
+
firstSeq?: number;
|
|
221
|
+
lastSeq?: number;
|
|
205
222
|
lastSeenAt: string;
|
|
206
223
|
}
|
|
207
|
-
|
|
224
|
+
/** Each live run's first seq, for the report (Q24). A run that has recorded nothing yet has none. */
|
|
225
|
+
export declare const withRunSeqs: (sessions: AgentSession[], seqs?: Map<string, RunSeqs>) => AgentSession[];
|
|
226
|
+
export declare const knownSessionsToReport: (live: readonly AgentSession[], now?: number, seqs?: Map<string, RunSeqs>) => ReportedKnownSession[];
|
|
208
227
|
export declare const knownSessionsFingerprint: (known: ReportedKnownSession[]) => string;
|
|
209
228
|
export declare const sessionsFingerprint: (sessions: AgentSession[]) => string;
|
|
210
229
|
/** True when this cycle's inventory must be sent: it changed, or the
|
|
@@ -270,6 +289,13 @@ export declare const parseSessionName: (name: string, sidecar?: {
|
|
|
270
289
|
} | null;
|
|
271
290
|
/** Test seam: how many directories have been looked up. */
|
|
272
291
|
export declare const __checkoutCacheSize: () => number;
|
|
292
|
+
/**
|
|
293
|
+
* A run's id from tmux's `session_created` (hwip docs/session-runs.md): the
|
|
294
|
+
* whole epoch seconds as a decimal string. The server derives the same string
|
|
295
|
+
* from `createdAt` for listeners that do not send one (`runIdOf`), so the two
|
|
296
|
+
* must agree: `epochToIso` keeps whole seconds and this floors them.
|
|
297
|
+
*/
|
|
298
|
+
export declare const runIdOfEpoch: (epoch: string | undefined) => string | undefined;
|
|
273
299
|
/** Test hook. */
|
|
274
300
|
export declare const resetSessionStates: () => void;
|
|
275
301
|
/**
|
|
@@ -514,6 +540,8 @@ export interface SessionForgetRequest {
|
|
|
514
540
|
* the phone deletes the row it is looking at. Absent (older phone) deletes
|
|
515
541
|
* every run under the name, which is what the request used to mean. */
|
|
516
542
|
agentKind?: string;
|
|
543
|
+
/** With `agentKind`, the one run of it to delete (hwip docs/session-runs.md). Absent: every run of that agent. */
|
|
544
|
+
runId?: string;
|
|
517
545
|
requestId?: string;
|
|
518
546
|
}
|
|
519
547
|
export interface SessionForgetResult {
|
|
@@ -1211,6 +1239,26 @@ export declare const downloadAttachments: (pushId: string, files: PushFileAttach
|
|
|
1211
1239
|
apiKey: string;
|
|
1212
1240
|
baseUrl: string;
|
|
1213
1241
|
}, sealed?: SealedAttachments, io?: AttachmentIo) => Promise<string[]>;
|
|
1242
|
+
/**
|
|
1243
|
+
* The most a sealed answer file may download as: its seal's size padded, and the tag. The relay
|
|
1244
|
+
* points at the bytes, so it could name a larger object; nothing past this is read.
|
|
1245
|
+
*/
|
|
1246
|
+
export declare const answerFileBound: (size: number | undefined) => number;
|
|
1247
|
+
/**
|
|
1248
|
+
* The attachments of a sealed answer (`hwip ask`, answer-files.ts), saved one by one to
|
|
1249
|
+
* `~/.hwip/attachments/<eventId>/<name>`. Unlike a command's, a file that fails does not take the
|
|
1250
|
+
* others with it: the answer is the user's word either way, and each file not saved is named with why.
|
|
1251
|
+
* `sealed.files` gives each file's meta in the same order.
|
|
1252
|
+
*/
|
|
1253
|
+
export declare const downloadSealedEach: (eventId: string, files: PushFileAttachment[], ctx: {
|
|
1254
|
+
apiKey: string;
|
|
1255
|
+
baseUrl: string;
|
|
1256
|
+
}, sealed: SealedAttachments & {
|
|
1257
|
+
files: SealedFileMeta[];
|
|
1258
|
+
}, io?: AttachmentIo) => Promise<{
|
|
1259
|
+
paths: string[];
|
|
1260
|
+
missing: string[];
|
|
1261
|
+
}>;
|
|
1214
1262
|
export interface SaveFileDeps {
|
|
1215
1263
|
ctx: {
|
|
1216
1264
|
apiKey: string;
|