@hwip/cli 0.1.0 → 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.
@@ -23,6 +23,20 @@ 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;
26
40
  /** Last time this machine saw the session running. */
27
41
  lastSeenAt: string;
28
42
  }
@@ -33,12 +47,14 @@ export interface KnownSession {
33
47
  * that has run agents for years.
34
48
  */
35
49
  export declare const MAX_KNOWN_SESSIONS = 100;
50
+ /** Runs kept per name: a slot reused all day would otherwise fill the file with one project. */
51
+ export declare const MAX_RUNS_PER_NAME = 10;
36
52
  /** Forgotten after this long unseen — a directory from months ago is more
37
53
  * likely to have moved than to be what the user meant. */
38
54
  export declare const KNOWN_SESSION_TTL_MS: number;
39
55
  /**
40
- * What makes two sightings the same remembered run: the tmux name AND the
41
- * agent that was running under it.
56
+ * What makes two sightings the same remembered run: the tmux name, the agent
57
+ * that was running under it, AND the tmux session it ran in (`runId`).
42
58
  *
43
59
  * Keyed by name alone, a `hwip cc` in a project erased whatever pi had done in
44
60
  * the same slot — the ended run left no row to resume and no row to read, and
@@ -48,8 +64,13 @@ export declare const KNOWN_SESSION_TTL_MS: number;
48
64
  * NUL rather than a printable separator: a project directory can be called
49
65
  * almost anything, and a name holding the separator must not be able to forge
50
66
  * another run's key.
67
+ *
68
+ * The run id splits two sittings of the same agent in the same slot: closing
69
+ * `hwip cc` and starting it again is a new conversation, and the record of the
70
+ * first is a past session of its own. An entry without one (written before
71
+ * runs were recorded) keys as the empty run.
51
72
  */
52
- export declare const runKey: (name: string, agentKind: string) => string;
73
+ export declare const runKey: (name: string, agentKind: string, runId?: string) => string;
53
74
  /** Sessions this machine has seen, newest first, expired ones dropped. */
54
75
  export declare const knownSessions: (now?: number) => KnownSession[];
55
76
  /** One remembered session, or null when this machine never saw that name.
@@ -87,6 +108,14 @@ export declare const rememberSessions: (live: Array<{
87
108
  agentKind: string;
88
109
  project?: string | null;
89
110
  label?: string | null;
111
+ runId?: string;
112
+ startedAt?: string;
113
+ /**
114
+ * The conversation a resume may pick up. Absent: not readable this
115
+ * sweep, so the run keeps the one it had. Null: read, but not this
116
+ * run's alone to resume — the run forgets it.
117
+ */
118
+ agentSessionId?: string | null;
90
119
  }>, now?: number) => void;
91
120
  /**
92
121
  * Forget one session, by name. Returns false when this machine never knew it.
@@ -107,7 +136,8 @@ export type ForgetOutcome =
107
136
  | 'forgotten'
108
137
  /** Registry entry gone, but the scrollback file would not delete. */
109
138
  | 'scrollback_kept';
110
- export declare const forgetSession: (name: string, agentKind?: string) => ForgetOutcome;
139
+ /** `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. */
140
+ export declare const forgetSession: (name: string, agentKind?: string, runId?: string, nameRunning?: boolean) => ForgetOutcome;
111
141
  /**
112
142
  * What the caller was able to find out about a name. `unknown` is not a
113
143
  * 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.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,6 +29,8 @@ 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;
@@ -46,7 +48,8 @@ const readAll = () => {
46
48
  typeof e.name === 'string' &&
47
49
  typeof e.cwd === 'string' &&
48
50
  typeof e.agentKind === 'string' &&
49
- typeof e.lastSeenAt === 'string');
51
+ typeof e.lastSeenAt === 'string' &&
52
+ (e.runId === undefined || typeof e.runId === 'string'));
50
53
  }
51
54
  catch {
52
55
  return [];
@@ -67,8 +70,8 @@ const writeAll = (entries) => {
67
70
  }
68
71
  };
69
72
  /**
70
- * What makes two sightings the same remembered run: the tmux name AND the
71
- * agent that was running under it.
73
+ * What makes two sightings the same remembered run: the tmux name, the agent
74
+ * that was running under it, AND the tmux session it ran in (`runId`).
72
75
  *
73
76
  * Keyed by name alone, a `hwip cc` in a project erased whatever pi had done in
74
77
  * the same slot — the ended run left no row to resume and no row to read, and
@@ -78,8 +81,13 @@ const writeAll = (entries) => {
78
81
  * NUL rather than a printable separator: a project directory can be called
79
82
  * almost anything, and a name holding the separator must not be able to forge
80
83
  * another run's key.
84
+ *
85
+ * The run id splits two sittings of the same agent in the same slot: closing
86
+ * `hwip cc` and starting it again is a new conversation, and the record of the
87
+ * first is a past session of its own. An entry without one (written before
88
+ * runs were recorded) keys as the empty run.
81
89
  */
82
- const runKey = (name, agentKind) => `${name}\u0000${agentKind}`;
90
+ const runKey = (name, agentKind, runId) => `${name}\u0000${agentKind}\u0000${runId ?? ''}`;
83
91
  exports.runKey = runKey;
84
92
  /** Sessions this machine has seen, newest first, expired ones dropped. */
85
93
  const knownSessions = (now = Date.now()) => readAll()
@@ -128,30 +136,58 @@ const rememberSessions = (live, now = Date.now()) => {
128
136
  if (usable.length === 0)
129
137
  return;
130
138
  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]));
139
+ const byRun = new Map((0, exports.knownSessions)(now).map((e) => [(0, exports.runKey)(e.name, e.agentKind, e.runId), e]));
132
140
  for (const s of usable) {
133
- byRun.set((0, exports.runKey)(s.name, s.agentKind), {
141
+ // The entry this run had before runs were recorded: same slot and agent,
142
+ // no run, seen while this run was already going. It is this run, not a
143
+ // past one, and would otherwise be reported as one.
144
+ if (s.runId && s.startedAt) {
145
+ const before = byRun.get((0, exports.runKey)(s.name, s.agentKind));
146
+ if (before && before.lastSeenAt >= s.startedAt)
147
+ byRun.delete((0, exports.runKey)(s.name, s.agentKind));
148
+ }
149
+ const key = (0, exports.runKey)(s.name, s.agentKind, s.runId);
150
+ // A sweep that could not read the id — the agent has not written its
151
+ // record yet — keeps the one this run already had. Keyed by run and
152
+ // directory, so neither another agent taking the name nor the same
153
+ // agent started elsewhere under it inherits a conversation.
154
+ const prev = byRun.get(key);
155
+ const keepPrev = s.agentSessionId === undefined && prev?.cwd === s.cwd;
156
+ const agentSessionId = s.agentSessionId || (keepPrev ? prev?.agentSessionId : undefined);
157
+ byRun.set(key, {
134
158
  name: s.name,
135
159
  cwd: s.cwd,
136
160
  agentKind: s.agentKind,
137
161
  ...(s.project ? { project: s.project } : {}),
138
162
  ...(s.label ? { label: s.label } : {}),
163
+ ...(s.runId ? { runId: s.runId } : {}),
164
+ ...(s.startedAt ? { startedAt: s.startedAt } : {}),
165
+ ...(agentSessionId ? { agentSessionId } : {}),
139
166
  lastSeenAt: seenAt,
140
167
  });
141
168
  }
169
+ const perName = new Map();
142
170
  const entries = [...byRun.values()]
143
171
  .sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt))
172
+ .filter((e) => {
173
+ const n = (perName.get(e.name) ?? 0) + 1;
174
+ perName.set(e.name, n);
175
+ return n <= exports.MAX_RUNS_PER_NAME;
176
+ })
144
177
  .slice(0, exports.MAX_KNOWN_SESSIONS);
145
178
  writeAll(entries);
146
179
  };
147
180
  exports.rememberSessions = rememberSessions;
148
- const forgetSession = (name, agentKind) => {
181
+ /** `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. */
182
+ const forgetSession = (name, agentKind, runId, nameRunning = false) => {
149
183
  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));
184
+ // With a kind, only that agent's runs go — the phone deletes the row it is
185
+ // looking at, and a name that held both pi and claude shows two rows. With a
186
+ // run as well, only that one run. Without a kind (an older phone, which
187
+ // sends none), the name goes entirely: that is what the request meant when a
188
+ // name could only mean one run.
189
+ const doomed = (e) => e.name === name && (!agentKind || e.agentKind === agentKind) && (!runId || e.runId === runId);
190
+ const kept = entries.filter((e) => !doomed(e));
155
191
  if (kept.length === entries.length)
156
192
  return 'unknown';
157
193
  // The chat's scrollback for that session goes with it. Forgetting a session
@@ -162,7 +198,7 @@ const forgetSession = (name, agentKind) => {
162
198
  // Except while another run still answers to the name: the scrollback is per
163
199
  // NAME, so deleting the pi row would take the claude row's history with it.
164
200
  // The record is gone either way; the file waits for the last run to go.
165
- const nameStillHeld = kept.some((e) => e.name === name);
201
+ const nameStillHeld = nameRunning || kept.some((e) => e.name === name);
166
202
  writeAll(kept);
167
203
  if (nameStillHeld)
168
204
  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.0",
3
+ "version": "0.1.2",
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"