@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.
@@ -23,6 +23,26 @@ export interface KnownSession {
23
23
  agentKind: string;
24
24
  project?: string;
25
25
  label?: string;
26
+ /**
27
+ * Which run of the name this was (hwip docs/session-runs.md): tmux
28
+ * `session_created` in epoch seconds, as a decimal string. Absent from
29
+ * entries written before runs were recorded.
30
+ */
31
+ runId?: string;
32
+ /** When that run started (ISO) — the same instant as `runId`. */
33
+ startedAt?: string;
34
+ /**
35
+ * The agent's own id for the conversation this run last had, as the sweep
36
+ * read it off the live pane — what a resume picks back up. Absent for an
37
+ * agent whose id this machine cannot read, and in files written before it.
38
+ */
39
+ agentSessionId?: string;
40
+ /**
41
+ * The later tmux sessions this run went on in (`session_created`, as
42
+ * `runId`): the same conversation carried on after an `exit` is the same
43
+ * run (`continuedRunId`). Absent for a run that has had one tmux session.
44
+ */
45
+ tmuxRunIds?: string[];
26
46
  /** Last time this machine saw the session running. */
27
47
  lastSeenAt: string;
28
48
  }
@@ -33,12 +53,14 @@ export interface KnownSession {
33
53
  * that has run agents for years.
34
54
  */
35
55
  export declare const MAX_KNOWN_SESSIONS = 100;
56
+ /** Runs kept per name: a slot reused all day would otherwise fill the file with one project. */
57
+ export declare const MAX_RUNS_PER_NAME = 10;
36
58
  /** Forgotten after this long unseen — a directory from months ago is more
37
59
  * likely to have moved than to be what the user meant. */
38
60
  export declare const KNOWN_SESSION_TTL_MS: number;
39
61
  /**
40
- * What makes two sightings the same remembered run: the tmux name AND the
41
- * agent that was running under it.
62
+ * What makes two sightings the same remembered run: the tmux name, the agent
63
+ * that was running under it, AND the tmux session it ran in (`runId`).
42
64
  *
43
65
  * Keyed by name alone, a `hwip cc` in a project erased whatever pi had done in
44
66
  * the same slot — the ended run left no row to resume and no row to read, and
@@ -48,8 +70,13 @@ export declare const KNOWN_SESSION_TTL_MS: number;
48
70
  * NUL rather than a printable separator: a project directory can be called
49
71
  * almost anything, and a name holding the separator must not be able to forge
50
72
  * another run's key.
73
+ *
74
+ * The run id splits two sittings of the same agent in the same slot: closing
75
+ * `hwip cc` and starting it again is a new conversation, and the record of the
76
+ * first is a past session of its own. An entry without one (written before
77
+ * runs were recorded) keys as the empty run.
51
78
  */
52
- export declare const runKey: (name: string, agentKind: string) => string;
79
+ export declare const runKey: (name: string, agentKind: string, runId?: string) => string;
53
80
  /** Sessions this machine has seen, newest first, expired ones dropped. */
54
81
  export declare const knownSessions: (now?: number) => KnownSession[];
55
82
  /** One remembered session, or null when this machine never saw that name.
@@ -65,6 +92,20 @@ export declare const recallSession: (name: string, now?: number) => KnownSession
65
92
  * decides whether to fall back to the newest run or refuse.
66
93
  */
67
94
  export declare const recallRun: (name: string, agentKind: string, now?: number) => KnownSession | null;
95
+ /** tmux sessions remembered per run: an `exit` and `-c` every few minutes all day stays a short list. */
96
+ export declare const MAX_TMUX_RUNS_PER_RUN = 10;
97
+ /**
98
+ * Which run the tmux session `tmuxRunId` of `name` is (hwip docs/session-runs.md §3).
99
+ *
100
+ * Its own, unless it carries on an earlier run: it was already joined to one
101
+ * (a sweep or a listener restart since), or the agent's conversation
102
+ * `conversationId` is the one an earlier run of the same name and agent last
103
+ * had. A tmux session that has had a conversation of its own stays its own run
104
+ * — a fresh start `/resume`d into an old conversation keeps what it wrote first.
105
+ */
106
+ export declare const continuedRunId: (entries: readonly KnownSession[], name: string, agentKind: string, tmuxRunId: string, conversationId: string | null | undefined) => string;
107
+ /** The run going on in tmux session `tmuxRunId` of `name`: the one it was joined to, else its own. */
108
+ export declare const runOfTmuxSession: (name: string, tmuxRunId: string, now?: number) => string;
68
109
  /**
69
110
  * Make this process a reader of the registry: `rememberSessions` writes nothing from here on. For the
70
111
  * CLI commands that look at the sweep (`hwip agent list|read`) — the file is the listener's resume
@@ -87,6 +128,16 @@ export declare const rememberSessions: (live: Array<{
87
128
  agentKind: string;
88
129
  project?: string | null;
89
130
  label?: string | null;
131
+ runId?: string;
132
+ startedAt?: string;
133
+ /** The tmux session it runs in; one other than `runId` means the run goes on in it (`continuedRunId`). */
134
+ tmuxRunId?: string;
135
+ /**
136
+ * The conversation a resume may pick up. Absent: not readable this
137
+ * sweep, so the run keeps the one it had. Null: read, but not this
138
+ * run's alone to resume — the run forgets it.
139
+ */
140
+ agentSessionId?: string | null;
90
141
  }>, now?: number) => void;
91
142
  /**
92
143
  * Forget one session, by name. Returns false when this machine never knew it.
@@ -107,7 +158,8 @@ export type ForgetOutcome =
107
158
  | 'forgotten'
108
159
  /** Registry entry gone, but the scrollback file would not delete. */
109
160
  | 'scrollback_kept';
110
- export declare const forgetSession: (name: string, agentKind?: string) => ForgetOutcome;
161
+ /** `nameRunning`: a run of this name is going right now, so its scrollback stays whether or not the registry has written that run down yet. */
162
+ export declare const forgetSession: (name: string, agentKind?: string, runId?: string, nameRunning?: boolean) => ForgetOutcome;
111
163
  /**
112
164
  * What the caller was able to find out about a name. `unknown` is not a
113
165
  * synonym for ended: it is the terminal failing to answer at all — no tmux to
@@ -16,7 +16,7 @@
16
16
  * another program.
17
17
  */
18
18
  Object.defineProperty(exports, "__esModule", { value: true });
19
- exports.sessionDirectoryExists = exports.knownSessionsPath = exports.isKnownSession = exports.forgetEnded = exports.forgetSession = exports.rememberSessions = exports.keepRegistryUntouched = exports.recallRun = exports.recallSession = exports.knownSessions = exports.runKey = exports.KNOWN_SESSION_TTL_MS = exports.MAX_KNOWN_SESSIONS = void 0;
19
+ exports.sessionDirectoryExists = exports.knownSessionsPath = exports.isKnownSession = exports.forgetEnded = exports.forgetSession = exports.rememberSessions = exports.keepRegistryUntouched = exports.runOfTmuxSession = exports.continuedRunId = exports.MAX_TMUX_RUNS_PER_RUN = exports.recallRun = exports.recallSession = exports.knownSessions = exports.runKey = exports.KNOWN_SESSION_TTL_MS = exports.MAX_RUNS_PER_NAME = exports.MAX_KNOWN_SESSIONS = void 0;
20
20
  const fs_1 = require("fs");
21
21
  const path_1 = require("path");
22
22
  const turn_ring_js_1 = require("./turn-ring.js");
@@ -29,10 +29,13 @@ const private_files_js_1 = require("./private-files.js");
29
29
  * that has run agents for years.
30
30
  */
31
31
  exports.MAX_KNOWN_SESSIONS = 100;
32
+ /** Runs kept per name: a slot reused all day would otherwise fill the file with one project. */
33
+ exports.MAX_RUNS_PER_NAME = 10;
32
34
  /** Forgotten after this long unseen — a directory from months ago is more
33
35
  * likely to have moved than to be what the user meant. */
34
36
  exports.KNOWN_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
35
37
  const registryPath = () => (0, path_1.join)((0, gate_js_1.stateDir)(), 'known-sessions.json');
38
+ const isTmuxRunIds = (v) => v === undefined || (Array.isArray(v) && v.every((id) => typeof id === 'string'));
36
39
  const readAll = () => {
37
40
  try {
38
41
  const raw = (0, fs_1.readFileSync)(registryPath(), 'utf-8');
@@ -46,7 +49,9 @@ const readAll = () => {
46
49
  typeof e.name === 'string' &&
47
50
  typeof e.cwd === 'string' &&
48
51
  typeof e.agentKind === 'string' &&
49
- typeof e.lastSeenAt === 'string');
52
+ typeof e.lastSeenAt === 'string' &&
53
+ (e.runId === undefined || typeof e.runId === 'string') &&
54
+ isTmuxRunIds(e.tmuxRunIds));
50
55
  }
51
56
  catch {
52
57
  return [];
@@ -67,8 +72,8 @@ const writeAll = (entries) => {
67
72
  }
68
73
  };
69
74
  /**
70
- * What makes two sightings the same remembered run: the tmux name AND the
71
- * agent that was running under it.
75
+ * What makes two sightings the same remembered run: the tmux name, the agent
76
+ * that was running under it, AND the tmux session it ran in (`runId`).
72
77
  *
73
78
  * Keyed by name alone, a `hwip cc` in a project erased whatever pi had done in
74
79
  * the same slot — the ended run left no row to resume and no row to read, and
@@ -78,8 +83,13 @@ const writeAll = (entries) => {
78
83
  * NUL rather than a printable separator: a project directory can be called
79
84
  * almost anything, and a name holding the separator must not be able to forge
80
85
  * another run's key.
86
+ *
87
+ * The run id splits two sittings of the same agent in the same slot: closing
88
+ * `hwip cc` and starting it again is a new conversation, and the record of the
89
+ * first is a past session of its own. An entry without one (written before
90
+ * runs were recorded) keys as the empty run.
81
91
  */
82
- const runKey = (name, agentKind) => `${name}\u0000${agentKind}`;
92
+ const runKey = (name, agentKind, runId) => `${name}\u0000${agentKind}\u0000${runId ?? ''}`;
83
93
  exports.runKey = runKey;
84
94
  /** Sessions this machine has seen, newest first, expired ones dropped. */
85
95
  const knownSessions = (now = Date.now()) => readAll()
@@ -101,6 +111,37 @@ exports.recallSession = recallSession;
101
111
  */
102
112
  const recallRun = (name, agentKind, now = Date.now()) => (0, exports.knownSessions)(now).find((e) => e.name === name && e.agentKind === agentKind) ?? null;
103
113
  exports.recallRun = recallRun;
114
+ /** tmux sessions remembered per run: an `exit` and `-c` every few minutes all day stays a short list. */
115
+ exports.MAX_TMUX_RUNS_PER_RUN = 10;
116
+ /** Agents whose own resume is measured to keep the conversation id: Claude Code 2.1.300's `-c` does. */
117
+ const CONTINUING_KINDS = new Set(['claude']);
118
+ /**
119
+ * Which run the tmux session `tmuxRunId` of `name` is (hwip docs/session-runs.md §3).
120
+ *
121
+ * Its own, unless it carries on an earlier run: it was already joined to one
122
+ * (a sweep or a listener restart since), or the agent's conversation
123
+ * `conversationId` is the one an earlier run of the same name and agent last
124
+ * had. A tmux session that has had a conversation of its own stays its own run
125
+ * — a fresh start `/resume`d into an old conversation keeps what it wrote first.
126
+ */
127
+ const continuedRunId = (entries, name, agentKind, tmuxRunId, conversationId) => {
128
+ const ofName = entries.filter((e) => e.name === name && e.agentKind === agentKind && e.runId);
129
+ const joined = ofName.find((e) => e.runId !== tmuxRunId && e.tmuxRunIds?.includes(tmuxRunId));
130
+ if (joined?.runId)
131
+ return joined.runId;
132
+ if (!conversationId || !CONTINUING_KINDS.has(agentKind))
133
+ return tmuxRunId;
134
+ if (ofName.some((e) => e.runId === tmuxRunId && e.agentSessionId))
135
+ return tmuxRunId;
136
+ const earlier = ofName
137
+ .filter((e) => e.agentSessionId === conversationId && Number(e.runId) < Number(tmuxRunId))
138
+ .sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt))[0];
139
+ return earlier?.runId ?? tmuxRunId;
140
+ };
141
+ exports.continuedRunId = continuedRunId;
142
+ /** The run going on in tmux session `tmuxRunId` of `name`: the one it was joined to, else its own. */
143
+ const runOfTmuxSession = (name, tmuxRunId, now = Date.now()) => (0, exports.knownSessions)(now).find((e) => e.name === name && e.tmuxRunIds?.includes(tmuxRunId))?.runId ?? tmuxRunId;
144
+ exports.runOfTmuxSession = runOfTmuxSession;
104
145
  let readOnly = false;
105
146
  /**
106
147
  * Make this process a reader of the registry: `rememberSessions` writes nothing from here on. For the
@@ -128,30 +169,68 @@ const rememberSessions = (live, now = Date.now()) => {
128
169
  if (usable.length === 0)
129
170
  return;
130
171
  const seenAt = new Date(now).toISOString();
131
- const byRun = new Map((0, exports.knownSessions)(now).map((e) => [(0, exports.runKey)(e.name, e.agentKind), e]));
172
+ const byRun = new Map((0, exports.knownSessions)(now).map((e) => [(0, exports.runKey)(e.name, e.agentKind, e.runId), e]));
132
173
  for (const s of usable) {
133
- byRun.set((0, exports.runKey)(s.name, s.agentKind), {
174
+ // The entry this run had before runs were recorded: same slot and agent,
175
+ // no run, seen while this run was already going. It is this run, not a
176
+ // past one, and would otherwise be reported as one.
177
+ if (s.runId && s.startedAt) {
178
+ const before = byRun.get((0, exports.runKey)(s.name, s.agentKind));
179
+ if (before && before.lastSeenAt >= s.startedAt)
180
+ byRun.delete((0, exports.runKey)(s.name, s.agentKind));
181
+ }
182
+ // Carried on in a new tmux session: what an earlier sweep wrote under that session is this run.
183
+ const continued = !!s.runId && !!s.tmuxRunId && s.tmuxRunId !== s.runId;
184
+ if (continued)
185
+ byRun.delete((0, exports.runKey)(s.name, s.agentKind, s.tmuxRunId));
186
+ const key = (0, exports.runKey)(s.name, s.agentKind, s.runId);
187
+ // A sweep that could not read the id — the agent has not written its
188
+ // record yet — keeps the one this run already had. Keyed by run and
189
+ // directory, so neither another agent taking the name nor the same
190
+ // agent started elsewhere under it inherits a conversation.
191
+ const prev = byRun.get(key);
192
+ const keepPrev = s.agentSessionId === undefined && prev?.cwd === s.cwd;
193
+ const agentSessionId = s.agentSessionId || (keepPrev ? prev?.agentSessionId : undefined);
194
+ // A run keeps its first start whichever tmux session it goes on in.
195
+ const startedAt = continued ? prev?.startedAt ?? s.startedAt : s.startedAt;
196
+ const tmuxRunIds = continued && s.tmuxRunId
197
+ ? [...(prev?.tmuxRunIds ?? []).filter((id) => id !== s.tmuxRunId), s.tmuxRunId].slice(-exports.MAX_TMUX_RUNS_PER_RUN)
198
+ : prev?.tmuxRunIds;
199
+ byRun.set(key, {
134
200
  name: s.name,
135
201
  cwd: s.cwd,
136
202
  agentKind: s.agentKind,
137
203
  ...(s.project ? { project: s.project } : {}),
138
204
  ...(s.label ? { label: s.label } : {}),
205
+ ...(s.runId ? { runId: s.runId } : {}),
206
+ ...(startedAt ? { startedAt } : {}),
207
+ ...(agentSessionId ? { agentSessionId } : {}),
208
+ ...(tmuxRunIds?.length ? { tmuxRunIds } : {}),
139
209
  lastSeenAt: seenAt,
140
210
  });
141
211
  }
212
+ const perName = new Map();
142
213
  const entries = [...byRun.values()]
143
214
  .sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt))
215
+ .filter((e) => {
216
+ const n = (perName.get(e.name) ?? 0) + 1;
217
+ perName.set(e.name, n);
218
+ return n <= exports.MAX_RUNS_PER_NAME;
219
+ })
144
220
  .slice(0, exports.MAX_KNOWN_SESSIONS);
145
221
  writeAll(entries);
146
222
  };
147
223
  exports.rememberSessions = rememberSessions;
148
- const forgetSession = (name, agentKind) => {
224
+ /** `nameRunning`: a run of this name is going right now, so its scrollback stays whether or not the registry has written that run down yet. */
225
+ const forgetSession = (name, agentKind, runId, nameRunning = false) => {
149
226
  const entries = readAll();
150
- // With a kind, only that run goes — the phone deletes the row it is looking
151
- // at, and a name that held both pi and claude shows two rows. Without one
152
- // (an older phone, which sends no kind), the name goes entirely: that is
153
- // what the request meant when a name could only mean one run.
154
- const kept = entries.filter((e) => e.name !== name || (agentKind ? e.agentKind !== agentKind : false));
227
+ // With a kind, only that agent's runs go — the phone deletes the row it is
228
+ // looking at, and a name that held both pi and claude shows two rows. With a
229
+ // run as well, only that one run. Without a kind (an older phone, which
230
+ // sends none), the name goes entirely: that is what the request meant when a
231
+ // name could only mean one run.
232
+ const doomed = (e) => e.name === name && (!agentKind || e.agentKind === agentKind) && (!runId || e.runId === runId);
233
+ const kept = entries.filter((e) => !doomed(e));
155
234
  if (kept.length === entries.length)
156
235
  return 'unknown';
157
236
  // The chat's scrollback for that session goes with it. Forgetting a session
@@ -162,7 +241,7 @@ const forgetSession = (name, agentKind) => {
162
241
  // Except while another run still answers to the name: the scrollback is per
163
242
  // NAME, so deleting the pi row would take the claude row's history with it.
164
243
  // The record is gone either way; the file waits for the last run to go.
165
- const nameStillHeld = kept.some((e) => e.name === name);
244
+ const nameStillHeld = nameRunning || kept.some((e) => e.name === name);
166
245
  writeAll(kept);
167
246
  if (nameStillHeld)
168
247
  return 'forgotten';
package/dist/templates.js CHANGED
@@ -368,6 +368,7 @@ exports.COPILOT_HOOKS = JSON.stringify({
368
368
  /** Pi extension source — written to ~/.pi/agent/extensions/hwip.ts (drop-in auto-load). */
369
369
  exports.PI_EXTENSION = `// Generated by @hwip/cli — reinstall overwrites; \`hwip uninstall\` removes.
370
370
  import { spawn, execFile, execFileSync } from "node:child_process";
371
+ import { statSync } from "node:fs";
371
372
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
372
373
 
373
374
  const sh = (cmd: string, cwd: string, stdin?: string): Promise<string> =>
@@ -436,14 +437,21 @@ const SUBAGENT_SESSION = (() => {
436
437
  // to the pane the wrapper records as session options (cli config
437
438
  // HERDR_SESSION_OPTIONS). Read per report, not once: a reattach from another
438
439
  // herdr pane retargets a running pi. No options -> not in herdr -> no-op. herdr's
439
- // own pi extension reads HERDR_* from the env, which tmux does not pass, so it
440
- // stays silent here. Same protocol as the Claude Code plugin's hwip-herdr.sh.
440
+ // own pi extension reads HERDR_* from the env, which the wrapper empties for a
441
+ // \`hwip pi\` session (cli wrapper herdrEnvArgs), so it stays silent here: a second
442
+ // report on the pane would replace this one. Same protocol as the Claude Code
443
+ // plugin's hwip-herdr.sh.
441
444
  const TMUX_PANE = process.env.TMUX_PANE || "";
442
445
  // Set by session_start for the TUI only: a \`pi -p\` or RPC run from a shell in
443
446
  // a wrapped pane inherits TMUX_PANE, and must not report (or release) that
444
447
  // pane's agent. herdr's own pi extension gates on the same \`ctx.mode\`.
445
448
  let herdrTui = false;
446
449
  let herdrLast = "";
450
+ // herdr forgets every report when it restarts, and recreates its socket when it
451
+ // starts: the socket's mtime is part of what counts as already sent.
452
+ const herdrBorn = (socket: string): number => {
453
+ try { return statSync(socket).mtimeMs; } catch { return 0; }
454
+ };
447
455
  let herdrSeq = 0;
448
456
  // Resolves once herdr is spawned, so session_shutdown can hold pi's exit until
449
457
  // the release is on its way.
@@ -454,9 +462,12 @@ const herdr = (state: "idle" | "working" | "blocked" | "release"): Promise<void>
454
462
  // it now, in event order, not when tmux answers.
455
463
  const seq = herdrSeq = Math.max(Date.now() * 1000, herdrSeq + 1);
456
464
  execFile("tmux", ["display-message", "-p", "-t", TMUX_PANE, "#{@hwip_herdr_pane}\\t#{@hwip_herdr_socket}\\t#{@hwip_herdr_bin}"], { timeout: 2000 }, (err, out) => {
457
- const [pane, socket, bin] = String(out).trim().split("\\t");
458
- if (err || !pane || !socket || herdrLast === pane + " " + state) return resolve();
459
- herdrLast = pane + " " + state;
465
+ // Only the newline off the end: trim() would also eat an empty pane's tab and
466
+ // read the socket as the pane.
467
+ const [pane, socket, bin] = String(out).replace(/\\r?\\n$/, "").split("\\t");
468
+ const sent = pane + " " + state + " " + (socket ? herdrBorn(socket) : 0);
469
+ if (err || !pane || !socket || herdrLast === sent) return resolve();
470
+ herdrLast = sent;
460
471
  const who = ["--source", "hwip-pi", "--agent", "hwip pi"];
461
472
  const args = state === "release"
462
473
  ? ["pane", "release-agent", pane, ...who, "--seq", String(seq)]
@@ -11,6 +11,13 @@ import type { SessionLiveness } from './session-registry.js';
11
11
  * user's server would otherwise call every live session an ended one.
12
12
  */
13
13
  export declare const liveSessionNames: () => string[] | null;
14
+ /**
15
+ * The run (hwip docs/session-runs.md) tmux holds under `name` right now: its
16
+ * `session_created` in epoch seconds. Null when the name is not running, and
17
+ * undefined when no tmux answered — a caller that deletes on "another run"
18
+ * must refuse on that, as it refuses on `unknown` liveness.
19
+ */
20
+ export declare const liveRunIdOf: (name: string) => string | null | undefined;
14
21
  /**
15
22
  * The three-state answer, for callers that destroy something on "ended".
16
23
  *
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.sessionExists = exports.sessionLiveness = exports.liveSessionNames = void 0;
3
+ exports.sessionExists = exports.sessionLiveness = exports.liveRunIdOf = exports.liveSessionNames = void 0;
4
4
  const child_process_1 = require("child_process");
5
5
  const tmux_pane_js_1 = require("./tmux-pane.js");
6
6
  const tmux_socket_js_1 = require("./tmux-socket.js");
@@ -27,6 +27,27 @@ const liveSessionNames = () => {
27
27
  return (probe.stdout ?? '').split('\n').map((line) => line.trim()).filter(Boolean);
28
28
  };
29
29
  exports.liveSessionNames = liveSessionNames;
30
+ /**
31
+ * The run (hwip docs/session-runs.md) tmux holds under `name` right now: its
32
+ * `session_created` in epoch seconds. Null when the name is not running, and
33
+ * undefined when no tmux answered — a caller that deletes on "another run"
34
+ * must refuse on that, as it refuses on `unknown` liveness.
35
+ */
36
+ const liveRunIdOf = (name) => {
37
+ const probe = (0, child_process_1.spawnSync)('tmux', (0, tmux_socket_js_1.tmuxArgs)(['list-sessions', '-F', '#{session_name}\t#{session_created}']), {
38
+ encoding: 'utf-8',
39
+ stdio: ['ignore', 'pipe', 'ignore'],
40
+ });
41
+ if (probe.error || probe.status !== 0)
42
+ return undefined;
43
+ for (const line of (probe.stdout ?? '').split('\n')) {
44
+ const tab = line.lastIndexOf('\t');
45
+ if (tab > 0 && line.slice(0, tab) === name)
46
+ return /^\d{1,12}$/.test(line.slice(tab + 1).trim()) ? line.slice(tab + 1).trim() : undefined;
47
+ }
48
+ return null;
49
+ };
50
+ exports.liveRunIdOf = liveRunIdOf;
30
51
  /**
31
52
  * The three-state answer, for callers that destroy something on "ended".
32
53
  *
@@ -98,7 +98,7 @@ const createChunkUploader = (opts) => {
98
98
  try {
99
99
  res = await (0, api_js_1.apiFetch)(fetchFn, (0, api_js_1.apiUrl)(opts.baseUrl, `/sessions/${encodeURIComponent(sessionKey)}/turns`), opts.apiKey, UPLOAD_TIMEOUT_MS, {
100
100
  method: 'POST',
101
- body: JSON.stringify({ seq: chunk.seq, firstAt: chunk.firstAt, lastAt: chunk.lastAt, sealed: chunk.sealed }),
101
+ body: JSON.stringify({ seq: chunk.seq, firstAt: chunk.firstAt, lastAt: chunk.lastAt, ...(chunk.runId ? { runId: chunk.runId } : {}), sealed: chunk.sealed }),
102
102
  });
103
103
  }
104
104
  catch (err) {
@@ -80,6 +80,8 @@ export interface RecordableSession {
80
80
  path: string;
81
81
  /** `RegisteredRemoteAgent.kind` — which projector reads it. */
82
82
  kind: string;
83
+ /** The run (tmux session) writing it (hwip docs/session-runs.md); every chunk read from it carries this. */
84
+ runId?: string;
83
85
  }
84
86
  /** A pinned device's key, or this machine's own. */
85
87
  export interface RecorderRecipient {
@@ -96,6 +98,8 @@ export interface OutboxChunk {
96
98
  seq: number;
97
99
  firstAt: string;
98
100
  lastAt: string;
101
+ /** The run the events were recorded in. Absent from chunks made before runs were recorded. */
102
+ runId?: string;
99
103
  sealed: SealedChunk;
100
104
  }
101
105
  export type UploadOutcome =
@@ -145,6 +149,8 @@ export interface TurnRecorderDeps {
145
149
  /** The newest `seq` the server holds for a session, or null when it cannot be read. After a 409
146
150
  * (the same `seq` under another body) the next chunk is numbered past it. */
147
151
  latestSeq?: (sessionKey: string) => Promise<number | null>;
152
+ /** The seqs a run was just handed, once they are in the outbox: the run's seq block is reported from these (`run-seqs.ts`). */
153
+ noteSeqs?: (name: string, runId: string, firstSeq: number, lastSeq: number) => void;
148
154
  log: (message: string) => void;
149
155
  now?: () => number;
150
156
  }
@@ -155,6 +161,8 @@ interface SessionState {
155
161
  path: string;
156
162
  /** The agent kind that wrote it, so a session that is gone after a restart can still be read to its end. */
157
163
  kind?: string;
164
+ /** The run that wrote it, for the same reason: what is read from it after a restart is still that run's. */
165
+ runId?: string;
158
166
  ino: number;
159
167
  /** End of the last complete line whose events are in a chunk. */
160
168
  offset: number;
@@ -107,6 +107,7 @@ const readSessionState = (dir, sessionKey) => {
107
107
  sessionKey,
108
108
  path: raw.path,
109
109
  ...(typeof raw.kind === 'string' ? { kind: raw.kind } : {}),
110
+ ...(typeof raw.runId === 'string' ? { runId: raw.runId } : {}),
110
111
  ino: typeof raw.ino === 'number' ? raw.ino : 0,
111
112
  offset: raw.offset,
112
113
  ...(raw.cursor !== undefined ? { cursor: raw.cursor } : {}),
@@ -212,6 +213,9 @@ const createTurnRecorder = (deps) => {
212
213
  /** The kind that wrote `path`: the file a session has left was written by the kind its state names,
213
214
  * which is not always the kind of the file it writes now. */
214
215
  const kindOf = (live, path) => path !== live.source.path ? live.state?.kind ?? live.source.kind : live.source.kind;
216
+ /** The run that wrote `path`, by the same rule: a file the session has left is the run its state names.
217
+ * A new run under the same name keeps the name's `seq` numbering (the server's key) but tags its own. */
218
+ const runOf = (live, path) => path !== live.source.path ? live.state?.runId ?? live.source.runId : live.source.runId;
215
219
  const readerOf = (live, path) => deps.readerFor?.(kindOf(live, path)) ?? null;
216
220
  const startTail = (live) => {
217
221
  const state = live.state;
@@ -267,6 +271,7 @@ const createTurnRecorder = (deps) => {
267
271
  };
268
272
  const fallbackAt = new Date(now()).toISOString();
269
273
  const chunks = [];
274
+ const runId = runOf(live, live.tailPath);
270
275
  let seq = base.seq;
271
276
  try {
272
277
  for (const page of pages) {
@@ -274,7 +279,7 @@ const createTurnRecorder = (deps) => {
274
279
  const firstAt = eventAt(page, 'first', fallbackAt);
275
280
  const lastAt = eventAt(page, 'last', fallbackAt);
276
281
  const sealed = await deps.seal(JSON.stringify({ sessionKey: live.sessionKey, seq, events: page }), recipients);
277
- chunks.push({ seq, firstAt, lastAt, sealed });
282
+ chunks.push({ seq, firstAt, lastAt, ...(runId ? { runId } : {}), sealed });
278
283
  }
279
284
  }
280
285
  catch (err) {
@@ -291,6 +296,7 @@ const createTurnRecorder = (deps) => {
291
296
  ...base,
292
297
  path: live.tailPath,
293
298
  kind: kindOf(live, live.tailPath),
299
+ runId,
294
300
  ino: live.tail.ino,
295
301
  offset: committed,
296
302
  cursor: live.tail.cursor,
@@ -300,6 +306,8 @@ const createTurnRecorder = (deps) => {
300
306
  };
301
307
  if (!persist(live, next))
302
308
  return;
309
+ if (runId && chunks.length)
310
+ deps.noteSeqs?.(live.name, runId, chunks[0].seq, seq);
303
311
  live.pending = [];
304
312
  live.pendingBytes = 0;
305
313
  live.hasPrompt = false;
@@ -615,7 +623,7 @@ const createTurnRecorder = (deps) => {
615
623
  const state = (0, exports.readSessionState)(deps.dir, sessionKey);
616
624
  if (!state)
617
625
  continue;
618
- sessions.set(name, newLive(name, { path: state.path, kind: state.kind ?? '' }, now()));
626
+ sessions.set(name, newLive(name, { path: state.path, kind: state.kind ?? '', ...(state.runId ? { runId: state.runId } : {}) }, now()));
619
627
  }
620
628
  };
621
629
  /**
package/dist/wrapper.d.ts CHANGED
@@ -20,12 +20,20 @@ export declare const detectProjectName: () => string;
20
20
  * session (measured on tmux 3.5a).
21
21
  */
22
22
  export declare const sessionSidecarArgs: (session: string, projectDir: string, label?: string) => string[];
23
+ export declare const herdrSessionArgs: (session: string, env?: NodeJS.ProcessEnv) => string[];
23
24
  /**
24
- * tmux commands, chained after `new -A`, that point config
25
- * `HERDR_SESSION_OPTIONS` at the herdr pane this attach runs in, or clear them
26
- * outside herdr.
25
+ * `new-session -e` flags for config `HERDR_ENV_VARS`, so herdr's native integration
26
+ * in the agent reports to the herdr pane this launch attaches from — or, empty, to no
27
+ * pane at all: for an agent hwip reports itself (`HERDR_SELF_REPORTING_KINDS`), a
28
+ * detached launch, or a launch from outside herdr. Without them the agent inherits the
29
+ * tmux server's global env and reports to a pane that is gone. Session env, so a later
30
+ * split pane gets the same. A reattach does not move a running agent's env: herdr's
31
+ * integrations keep the pane the session was created from. `new-session -e` is tmux 3.2;
32
+ * an older tmux gets none, as before.
27
33
  */
28
- export declare const herdrSessionArgs: (session: string, env?: NodeJS.ProcessEnv) => string[];
34
+ export declare const herdrEnvArgs: (agentKind: string | undefined, env: NodeJS.ProcessEnv, tmuxVersion: string) => string[];
35
+ /** `tmux -V`, read once per process; empty when tmux does not answer. */
36
+ export declare const installedTmuxVersion: () => string;
29
37
  /**
30
38
  * `hwip-<project>` — the canonical tmux session base name. tmux itself turns
31
39
  * `.` and `:` into `_` when it creates a session (3.5a: `hwip-foo.bar` becomes
package/dist/wrapper.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.handleShellSession = exports.shellTargets = exports.readShellSessionState = exports.planShellSession = exports.handleAgentSession = exports.tmuxMissingMessage = exports.inPaneNote = exports.handOffExec = exports.ensureListenerRunning = exports.takeListenerLaunchMarker = exports.listenerVersionDrifted = exports.targetForAgent = exports.splitAgentOptions = exports.sanitizeLabel = exports.findAvailableSession = exports.firstFreeSession = exports.tmuxSessionName = exports.herdrSessionArgs = exports.sessionSidecarArgs = exports.detectProjectName = exports.detectProjectDir = exports.safeBasename = void 0;
3
+ exports.handleShellSession = exports.shellTargets = exports.readShellSessionState = exports.planShellSession = exports.handleAgentSession = exports.tmuxMissingMessage = exports.inPaneNote = exports.handOffExec = exports.ensureListenerRunning = exports.takeListenerLaunchMarker = exports.listenerVersionDrifted = exports.targetForAgent = exports.splitAgentOptions = exports.sanitizeLabel = exports.findAvailableSession = exports.firstFreeSession = exports.tmuxSessionName = exports.installedTmuxVersion = exports.herdrEnvArgs = exports.herdrSessionArgs = exports.sessionSidecarArgs = exports.detectProjectName = exports.detectProjectDir = exports.safeBasename = void 0;
4
4
  /**
5
5
  * `hwip cc` / `hwip codex` / `hwip gemini` — spawn an agent inside a named
6
6
  * tmux session so the resident listener (`hwip listener`) can address it
@@ -79,14 +79,52 @@ exports.sessionSidecarArgs = sessionSidecarArgs;
79
79
  * `HERDR_SESSION_OPTIONS` at the herdr pane this attach runs in, or clear them
80
80
  * outside herdr.
81
81
  */
82
+ const inHerdr = (env) => env.HERDR_ENV === '1' && !!env.HERDR_PANE_ID && !!env.HERDR_SOCKET_PATH;
82
83
  const herdrSessionArgs = (session, env = process.env) => {
83
- const inHerdr = env.HERDR_ENV === '1' && !!env.HERDR_PANE_ID && !!env.HERDR_SOCKET_PATH;
84
+ const attached = inHerdr(env);
84
85
  return Object.entries(config_js_1.HERDR_SESSION_OPTIONS).flatMap(([name, option]) => {
85
- const value = inHerdr ? env[name] : undefined;
86
+ const value = attached ? env[name] : undefined;
86
87
  return [';', 'set-option', ...(value ? [] : ['-u']), '-t', sessionOptionTarget(session), option, ...(value ? [value] : [])];
87
88
  });
88
89
  };
89
90
  exports.herdrSessionArgs = herdrSessionArgs;
91
+ /**
92
+ * `new-session -e` flags for config `HERDR_ENV_VARS`, so herdr's native integration
93
+ * in the agent reports to the herdr pane this launch attaches from — or, empty, to no
94
+ * pane at all: for an agent hwip reports itself (`HERDR_SELF_REPORTING_KINDS`), a
95
+ * detached launch, or a launch from outside herdr. Without them the agent inherits the
96
+ * tmux server's global env and reports to a pane that is gone. Session env, so a later
97
+ * split pane gets the same. A reattach does not move a running agent's env: herdr's
98
+ * integrations keep the pane the session was created from. `new-session -e` is tmux 3.2;
99
+ * an older tmux gets none, as before.
100
+ */
101
+ const herdrEnvArgs = (agentKind, env, tmuxVersion) => {
102
+ if (!sessionEnvSupported(tmuxVersion))
103
+ return [];
104
+ const native = inHerdr(env) && !config_js_1.HERDR_SELF_REPORTING_KINDS.has(agentKind ?? '');
105
+ return config_js_1.HERDR_ENV_VARS.flatMap((name) => ['-e', `${name}=${native ? env[name] ?? '' : ''}`]);
106
+ };
107
+ exports.herdrEnvArgs = herdrEnvArgs;
108
+ /** `tmux -V` says 3.2 or later (`new-session -e`); a development build (`next-3.7`, `master`) is newer. */
109
+ const sessionEnvSupported = (version) => {
110
+ if (/\b(master|next-)/.test(version))
111
+ return true;
112
+ const m = version.match(/(\d+)\.(\d+)/);
113
+ if (!m)
114
+ return false;
115
+ const [major, minor] = [Number(m[1]), Number(m[2])];
116
+ return major > 3 || (major === 3 && minor >= 2);
117
+ };
118
+ let tmuxVersionCache;
119
+ /** `tmux -V`, read once per process; empty when tmux does not answer. */
120
+ const installedTmuxVersion = () => {
121
+ if (tmuxVersionCache === undefined) {
122
+ const ver = (0, child_process_1.spawnSync)('tmux', ['-V'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 });
123
+ tmuxVersionCache = (ver.stdout ?? '').trim();
124
+ }
125
+ return tmuxVersionCache;
126
+ };
127
+ exports.installedTmuxVersion = installedTmuxVersion;
90
128
  /**
91
129
  * The option target for one session by exact name. `=` stops a prefix match;
92
130
  * the trailing `:` makes `show-options`/`set-option`, which read `-t` as a
@@ -300,10 +338,13 @@ const targetForAgent = (agent, extra, agentKind, opts = {}) => {
300
338
  // would otherwise get a session named for one tree running in another.
301
339
  const sidecar = (0, exports.sessionSidecarArgs)(session, projectDir, opts.label);
302
340
  if (opts.detach) {
303
- return { kind: 'tmux-detached', cmd: 'tmux', args: ['new', '-d', '-s', session, '-c', projectDir, shellCmd, ...sidecar], session };
341
+ // Attached to no herdr pane: blank, whatever pane this command was typed in.
342
+ const herdrEnv = (0, exports.herdrEnvArgs)(agentKind, {}, (0, exports.installedTmuxVersion)());
343
+ return { kind: 'tmux-detached', cmd: 'tmux', args: ['new', '-d', '-s', session, '-c', projectDir, ...herdrEnv, shellCmd, ...sidecar], session };
304
344
  }
305
- // `tmux new -A`: attach if the named session exists, else create it.
306
- return { kind: 'tmux-new', cmd: 'tmux', args: ['new', '-A', '-s', session, shellCmd, ...sidecar, ...(0, exports.herdrSessionArgs)(session)], session };
345
+ // `tmux new -A`: attach if the named session exists, else create it. `-e` applies only when it creates.
346
+ const herdrEnv = (0, exports.herdrEnvArgs)(agentKind, process.env, (0, exports.installedTmuxVersion)());
347
+ return { kind: 'tmux-new', cmd: 'tmux', args: ['new', '-A', '-s', session, ...herdrEnv, shellCmd, ...sidecar, ...(0, exports.herdrSessionArgs)(session)], session };
307
348
  };
308
349
  exports.targetForAgent = targetForAgent;
309
350
  // ── Background listener auto-start ────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hwip/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Hwip CLI + push notification SDK for AI agents",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -43,7 +43,21 @@
43
43
  "main"
44
44
  ],
45
45
  "plugins": [
46
- "@semantic-release/commit-analyzer",
46
+ [
47
+ "@semantic-release/commit-analyzer",
48
+ {
49
+ "releaseRules": [
50
+ {
51
+ "breaking": true,
52
+ "release": "patch"
53
+ },
54
+ {
55
+ "type": "feat",
56
+ "release": "patch"
57
+ }
58
+ ]
59
+ }
60
+ ],
47
61
  "@semantic-release/release-notes-generator",
48
62
  "@semantic-release/npm",
49
63
  "@semantic-release/github"