@hwip/cli 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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;
@@ -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
- if (actionId === undefined && !opened.body && exit !== true)
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
- /** What an opened answer means to the caller: the choice, or the text (with the exit when this ask offers it). */
242
- const sealedOutcome = (opened, acceptsExit) => {
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: opened.value ?? '', exitRemote: true };
249
- if (opened.value)
250
- return { answered: true, value: opened.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
- return { outcome: sealedOutcome(opened, opts.acceptsExit === true), end: mayPass ? 'open' : 'over' };
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
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import { type HwipConfig } from './config.js';
24
24
  import { type ShellAuditEntry } from './shell-audit.js';
25
+ import { type KnownSession } from './session-registry.js';
25
26
  import { type AgentKind } from './remote-agents.js';
26
27
  import { type TranscriptSource } from './transcript-tail.js';
27
28
  import { type AgentState } from './agent-state.js';
@@ -39,6 +40,7 @@ import { silenceListenerLog } from './listener-log.js';
39
40
  import { invalidateTmuxSocketCache, tmuxArgs } from './tmux-socket.js';
40
41
  import { detectRemoteAgent, isSubagentSessionName, recordSubagentTranscripts, recordTargets, subagentTranscriptFor, tmuxTargetFor } from './tmux-pane.js';
41
42
  import { liveSessionNames, sessionExists, sessionLiveness } from './tmux-liveness.js';
43
+ import { type RunSeqs } from './run-seqs.js';
42
44
  import { type PhoneScheduleDeps } from './schedule-remote.js';
43
45
  import { type ScheduleRunnerDeps } from './schedule-runner.js';
44
46
  export declare const PING_IDLE_MS = 240000;
@@ -120,6 +122,16 @@ interface AgentSession {
120
122
  */
121
123
  providerSessionName?: string | null;
122
124
  createdAt?: string;
125
+ /**
126
+ * Which run of the name this is (hwip docs/session-runs.md): tmux
127
+ * `session_created` in epoch seconds, as a decimal string — the same
128
+ * instant as `createdAt`. A name is handed out again when an agent starts
129
+ * in the same folder; this is what tells the two apart. Absent on subagent
130
+ * rows, which are panes of their parent's run.
131
+ */
132
+ runId?: string;
133
+ /** The smallest seq this run's chunks took (hwip docs/session-runs.md Q24): the server reads its conversation from there up. */
134
+ firstSeq?: number;
123
135
  lastActivityAt?: string;
124
136
  /**
125
137
  * Detected agent state (wire contract — flows to the server and the
@@ -196,15 +208,25 @@ export type StaleConnectionAction =
196
208
  * tested as what it is: two guards around one lever.
197
209
  */
198
210
  export declare const staleConnectionAction: (message: unknown, drops: number) => StaleConnectionAction;
199
- export declare const KNOWN_SESSIONS_REPORTED = 30;
211
+ export declare const KNOWN_SESSIONS_REPORTED = 50;
200
212
  export interface ReportedKnownSession {
201
213
  name: string;
202
214
  agentKind: string;
203
215
  project?: string;
204
216
  label?: string;
217
+ /** The run's id and start (hwip docs/session-runs.md); absent for entries recorded before runs. */
218
+ runId?: string;
219
+ startedAt?: string;
220
+ /** The seq block the run's chunks took (Q24): the server reads its conversation from that key range alone. */
221
+ firstSeq?: number;
222
+ lastSeq?: number;
223
+ /** The later tmux sessions the run went on in (Q35): the server ends another run's push window at their start. */
224
+ tmuxRunIds?: string[];
205
225
  lastSeenAt: string;
206
226
  }
207
- export declare const knownSessionsToReport: (live: readonly AgentSession[], now?: number) => ReportedKnownSession[];
227
+ /** Each live run's first seq, for the report (Q24). A run that has recorded nothing yet has none. */
228
+ export declare const withRunSeqs: (sessions: AgentSession[], seqs?: Map<string, RunSeqs>) => AgentSession[];
229
+ export declare const knownSessionsToReport: (live: readonly AgentSession[], now?: number, seqs?: Map<string, RunSeqs>) => ReportedKnownSession[];
208
230
  export declare const knownSessionsFingerprint: (known: ReportedKnownSession[]) => string;
209
231
  export declare const sessionsFingerprint: (sessions: AgentSession[]) => string;
210
232
  /** True when this cycle's inventory must be sent: it changed, or the
@@ -270,6 +292,20 @@ export declare const parseSessionName: (name: string, sidecar?: {
270
292
  } | null;
271
293
  /** Test seam: how many directories have been looked up. */
272
294
  export declare const __checkoutCacheSize: () => number;
295
+ /**
296
+ * A run's id from tmux's `session_created` (hwip docs/session-runs.md): the
297
+ * whole epoch seconds as a decimal string. The server derives the same string
298
+ * from `createdAt` for listeners that do not send one (`runIdOf`), so the two
299
+ * must agree: `epochToIso` keeps whole seconds and this floors them.
300
+ */
301
+ export declare const runIdOfEpoch: (epoch: string | undefined) => string | undefined;
302
+ /**
303
+ * Each live session's run once the sweep has read the conversations (hwip
304
+ * docs/session-runs.md §3): a session carrying on an earlier run's
305
+ * conversation reports — and its recorder tags — that run instead of its new
306
+ * tmux session. `sessions` and `recordable` are this sweep's, changed in place.
307
+ */
308
+ export declare const continueRuns: (sessions: AgentSession[], recordable: Record<string, RecordableSession>, conversationOf: (name: string) => string | null | undefined, known?: readonly KnownSession[]) => void;
273
309
  /** Test hook. */
274
310
  export declare const resetSessionStates: () => void;
275
311
  /**
@@ -514,6 +550,8 @@ export interface SessionForgetRequest {
514
550
  * the phone deletes the row it is looking at. Absent (older phone) deletes
515
551
  * every run under the name, which is what the request used to mean. */
516
552
  agentKind?: string;
553
+ /** With `agentKind`, the one run of it to delete (hwip docs/session-runs.md). Absent: every run of that agent. */
554
+ runId?: string;
517
555
  requestId?: string;
518
556
  }
519
557
  export interface SessionForgetResult {
@@ -1211,6 +1249,26 @@ export declare const downloadAttachments: (pushId: string, files: PushFileAttach
1211
1249
  apiKey: string;
1212
1250
  baseUrl: string;
1213
1251
  }, sealed?: SealedAttachments, io?: AttachmentIo) => Promise<string[]>;
1252
+ /**
1253
+ * The most a sealed answer file may download as: its seal's size padded, and the tag. The relay
1254
+ * points at the bytes, so it could name a larger object; nothing past this is read.
1255
+ */
1256
+ export declare const answerFileBound: (size: number | undefined) => number;
1257
+ /**
1258
+ * The attachments of a sealed answer (`hwip ask`, answer-files.ts), saved one by one to
1259
+ * `~/.hwip/attachments/<eventId>/<name>`. Unlike a command's, a file that fails does not take the
1260
+ * others with it: the answer is the user's word either way, and each file not saved is named with why.
1261
+ * `sealed.files` gives each file's meta in the same order.
1262
+ */
1263
+ export declare const downloadSealedEach: (eventId: string, files: PushFileAttachment[], ctx: {
1264
+ apiKey: string;
1265
+ baseUrl: string;
1266
+ }, sealed: SealedAttachments & {
1267
+ files: SealedFileMeta[];
1268
+ }, io?: AttachmentIo) => Promise<{
1269
+ paths: string[];
1270
+ missing: string[];
1271
+ }>;
1214
1272
  export interface SaveFileDeps {
1215
1273
  ctx: {
1216
1274
  apiKey: string;