@hwip/cli 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -1
- package/dist/answer-files.d.ts +39 -0
- package/dist/answer-files.js +35 -0
- package/dist/ask-seal.d.ts +5 -1
- package/dist/ask-seal.js +4 -1
- package/dist/ask.d.ts +13 -0
- package/dist/ask.js +37 -6
- package/dist/config.d.ts +14 -0
- package/dist/config.js +23 -1
- package/dist/listener.d.ts +50 -2
- package/dist/listener.js +159 -30
- package/dist/remote-agents.d.ts +22 -0
- package/dist/remote-agents.js +22 -5
- package/dist/run-seqs.d.ts +28 -0
- package/dist/run-seqs.js +79 -0
- package/dist/session-registry.d.ts +34 -4
- package/dist/session-registry.js +50 -14
- package/dist/templates.js +16 -5
- package/dist/tmux-liveness.d.ts +7 -0
- package/dist/tmux-liveness.js +22 -1
- package/dist/turn-recorder-wire.js +1 -1
- package/dist/turn-recorder.d.ts +8 -0
- package/dist/turn-recorder.js +10 -2
- package/dist/wrapper.d.ts +12 -4
- package/dist/wrapper.js +47 -6
- package/package.json +16 -2
|
@@ -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
|
|
41
|
-
*
|
|
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
|
-
|
|
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
|
package/dist/session-registry.js
CHANGED
|
@@ -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
|
|
71
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
151
|
-
// at, and a name that held both pi and claude shows two rows.
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
|
|
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
|
|
440
|
-
//
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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)]
|
package/dist/tmux-liveness.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/tmux-liveness.js
CHANGED
|
@@ -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) {
|
package/dist/turn-recorder.d.ts
CHANGED
|
@@ -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;
|
package/dist/turn-recorder.js
CHANGED
|
@@ -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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
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
|
|
84
|
+
const attached = inHerdr(env);
|
|
84
85
|
return Object.entries(config_js_1.HERDR_SESSION_OPTIONS).flatMap(([name, option]) => {
|
|
85
|
-
const value =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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"
|