@hwip/cli 0.1.10 → 0.1.12
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 +18 -10
- package/dist/device-presence.js +5 -2
- package/dist/listener.js +6 -1
- package/dist/pane-fit.d.ts +6 -0
- package/dist/pane-fit.js +15 -1
- package/dist/schedule-command.d.ts +8 -1
- package/dist/schedule-command.js +132 -21
- package/dist/schedule-remote.d.ts +17 -5
- package/dist/schedule-remote.js +76 -7
- package/dist/schedule-runner.d.ts +25 -1
- package/dist/schedule-runner.js +186 -7
- package/dist/schedule.d.ts +38 -6
- package/dist/schedule.js +91 -11
- package/dist/session-registry.d.ts +33 -2
- package/dist/session-registry.js +52 -3
- package/dist/templates.d.ts +10 -1
- package/dist/templates.js +13 -4
- package/dist/tmux-liveness.d.ts +5 -1
- package/dist/tmux-liveness.js +10 -1
- package/dist/turn-recorder-wire.js +1 -1
- package/dist/turn-recorder.d.ts +2 -2
- package/dist/turn-recorder.js +10 -2
- package/package.json +1 -1
package/dist/schedule.js
CHANGED
|
@@ -3,20 +3,26 @@
|
|
|
3
3
|
* Scheduled sessions (F8): what a schedule is, when it comes due, and the files it lives in.
|
|
4
4
|
*
|
|
5
5
|
* A schedule is one file, `<stateDir>/schedules/<id>.json` (0600). `hwip schedule` writes and
|
|
6
|
-
* deletes them, the listener only reads them
|
|
7
|
-
* never lose each other's change. What the listener has done about each one lives in its own file
|
|
6
|
+
* deletes them, the listener only reads them — except a `once` schedule that has run, which the listener
|
|
7
|
+
* deletes — and nothing reads-then-rewrites a file, so the two never lose each other's change. What the listener has done about each one lives in its own file
|
|
8
8
|
* (`schedule-state.json`, `schedule-runner.ts`).
|
|
9
9
|
*
|
|
10
10
|
* Everything here is pure or touches only that directory. The clock is a parameter.
|
|
11
11
|
*/
|
|
12
12
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
-
exports.writeScheduleState = exports.readScheduleState = exports.scheduleStatePath = exports.removeSchedule = exports.scheduleAnchor = exports.updateSchedule = exports.addSchedule = exports.listSchedules = exports.schedulesDir = exports.validateSchedule = exports.isScheduleId = exports.nextOccurrence = exports.occurrencesBetween = exports.parseWhen = exports.parseDuration = exports.WEEKDAYS = exports.ALL_DAYS = exports.DAY_NAMES = exports.defaultMaxRuntimeMin = exports.MAX_SCHEDULES = void 0;
|
|
13
|
+
exports.writeScheduleState = exports.readScheduleState = exports.scheduleStatePath = exports.removeSchedule = exports.scheduleAnchor = exports.pastWhen = exports.updateSchedule = exports.addSchedule = exports.listSchedules = exports.schedulesDir = exports.validateSchedule = exports.isScheduleId = exports.nextOccurrence = exports.occurrencesBetween = exports.parseWhen = exports.parseDuration = exports.WEEKDAYS = exports.ALL_DAYS = exports.DAY_NAMES = exports.defaultMaxRuntimeMin = exports.MAX_SCHEDULES = exports.SCHEDULE_SESSION_CAPABILITY = void 0;
|
|
14
14
|
const node_crypto_1 = require("node:crypto");
|
|
15
15
|
const node_fs_1 = require("node:fs");
|
|
16
16
|
const node_path_1 = require("node:path");
|
|
17
17
|
const gate_js_1 = require("./gate.js");
|
|
18
18
|
const private_files_js_1 = require("./private-files.js");
|
|
19
19
|
const replace_file_js_1 = require("./replace-file.js");
|
|
20
|
+
/**
|
|
21
|
+
* Advertised in the listener's presence (`device-presence.ts`): this build runs `once` schedules and schedules into a
|
|
22
|
+
* session (`target: 'session'`). An older listener refuses both as fields it does not know, so the apps offer them only
|
|
23
|
+
* to a computer that lists this.
|
|
24
|
+
*/
|
|
25
|
+
exports.SCHEDULE_SESSION_CAPABILITY = 'schedule-session-v1';
|
|
20
26
|
const isErrno = (err, code) => err instanceof Error && 'code' in err && err.code === code;
|
|
21
27
|
const fail = (error) => ({ ok: false, error });
|
|
22
28
|
exports.MAX_SCHEDULES = 20;
|
|
@@ -39,6 +45,9 @@ const DAY_NUMBER = Object.fromEntries(exports.DAY_NAMES.map((name, day) => [name
|
|
|
39
45
|
exports.ALL_DAYS = [0, 1, 2, 3, 4, 5, 6];
|
|
40
46
|
exports.WEEKDAYS = [1, 2, 3, 4, 5];
|
|
41
47
|
const AT_TIME = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
48
|
+
const ON_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
49
|
+
/** An instant with its offset: a time without one would move with the machine's time zone. */
|
|
50
|
+
const AT_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
42
51
|
const parseDays = (raw) => {
|
|
43
52
|
const word = raw.trim().toLowerCase();
|
|
44
53
|
if (word === 'daily')
|
|
@@ -73,9 +82,33 @@ const parseEvery = (raw) => {
|
|
|
73
82
|
return fail('--every must be at most 366 days');
|
|
74
83
|
return { ok: true, value: minutes };
|
|
75
84
|
};
|
|
76
|
-
/** `--at HH:MM
|
|
85
|
+
/** `--on YYYY-MM-DD` and `--at HH:MM` as the instant that local date and time is. */
|
|
86
|
+
const parseOnce = (on, at) => {
|
|
87
|
+
if (at === undefined)
|
|
88
|
+
return fail('--on needs --at HH:MM');
|
|
89
|
+
if (!AT_TIME.test(at.trim()))
|
|
90
|
+
return fail(`--at takes a 24-hour local time like 09:00 (got '${at}')`);
|
|
91
|
+
const date = ON_DATE.exec(on.trim());
|
|
92
|
+
const [year, month, day] = date ? [Number(date[1]), Number(date[2]) - 1, Number(date[3])] : [0, 0, 0];
|
|
93
|
+
const [hour, minute] = at.trim().split(':').map(Number);
|
|
94
|
+
const local = new Date(year, month, day, hour, minute);
|
|
95
|
+
// `Date` rolls an impossible day (Feb 30) into the next month; that is not the day that was asked for.
|
|
96
|
+
if (!date || local.getFullYear() !== year || local.getMonth() !== month || local.getDate() !== day) {
|
|
97
|
+
return fail(`--on takes a date like 2026-10-24 (got '${on}')`);
|
|
98
|
+
}
|
|
99
|
+
// And a time the clocks skip (spring forward) into the next hour.
|
|
100
|
+
if (local.getHours() !== hour || local.getMinutes() !== minute)
|
|
101
|
+
return fail(`${on} ${at.trim()} does not exist here: the clocks skip it`);
|
|
102
|
+
return { ok: true, value: { kind: 'once', at: local.toISOString() } };
|
|
103
|
+
};
|
|
104
|
+
/** `--at HH:MM [--days …]`, `--on YYYY-MM-DD --at HH:MM` or `--every <N>m|<N>h`, one of them and not neither. */
|
|
77
105
|
const parseWhen = (input) => {
|
|
78
|
-
const { at, days, every } = input;
|
|
106
|
+
const { at, days, every, on } = input;
|
|
107
|
+
if (on !== undefined) {
|
|
108
|
+
if (days !== undefined || every !== undefined)
|
|
109
|
+
return fail('--on cannot be combined with --days or --every');
|
|
110
|
+
return parseOnce(on, at);
|
|
111
|
+
}
|
|
79
112
|
if (every !== undefined) {
|
|
80
113
|
if (at !== undefined || days !== undefined)
|
|
81
114
|
return fail('--every cannot be combined with --at or --days');
|
|
@@ -122,6 +155,10 @@ const occurrencesBetween = (when, after, until, anchor) => {
|
|
|
122
155
|
// A cursor read from a damaged state file may not be a number; the day walk below would never end on one.
|
|
123
156
|
if (!Number.isFinite(after) || !Number.isFinite(until) || until <= after)
|
|
124
157
|
return { count: 0, latest: null };
|
|
158
|
+
if (when.kind === 'once') {
|
|
159
|
+
const at = Date.parse(when.at);
|
|
160
|
+
return at > after && at <= until ? { count: 1, latest: at } : { count: 0, latest: null };
|
|
161
|
+
}
|
|
125
162
|
if (when.kind === 'every') {
|
|
126
163
|
const period = when.minutes * 60_000;
|
|
127
164
|
const upTo = periodsSince(anchor, period, until);
|
|
@@ -145,8 +182,12 @@ const occurrencesBetween = (when, after, until, anchor) => {
|
|
|
145
182
|
return { count, latest };
|
|
146
183
|
};
|
|
147
184
|
exports.occurrencesBetween = occurrencesBetween;
|
|
148
|
-
/** The first time after `after` the schedule comes due; null for a `daily` with no days (a file `validateSchedule` would refuse). */
|
|
185
|
+
/** The first time after `after` the schedule comes due; null for a `once` that is past and a `daily` with no days (a file `validateSchedule` would refuse). */
|
|
149
186
|
const nextOccurrence = (when, after, anchor) => {
|
|
187
|
+
if (when.kind === 'once') {
|
|
188
|
+
const at = Date.parse(when.at);
|
|
189
|
+
return at > after ? at : null;
|
|
190
|
+
}
|
|
150
191
|
if (when.kind === 'every') {
|
|
151
192
|
const period = when.minutes * 60_000;
|
|
152
193
|
return anchor + (periodsSince(anchor, period, after) + 1) * period;
|
|
@@ -163,7 +204,9 @@ exports.nextOccurrence = nextOccurrence;
|
|
|
163
204
|
const SCHEDULE_ID = /^[a-z0-9]{6}$/;
|
|
164
205
|
const LABEL = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
165
206
|
const AGENT_KIND = /^[A-Za-z0-9_-]{1,32}$/;
|
|
166
|
-
const KEYS = new Set(['v', 'id', 'createdAt', 'updatedAt', 'source', 'agentKind', 'cwd', 'label', 'session', 'wt', 'agentArgs', 'prompt', 'when', 'maxRuntimeMin']);
|
|
207
|
+
const KEYS = new Set(['v', 'id', 'createdAt', 'updatedAt', 'source', 'agentKind', 'cwd', 'label', 'session', 'target', 'runId', 'wt', 'agentArgs', 'prompt', 'when', 'maxRuntimeMin']);
|
|
208
|
+
/** A tmux `session_created`, as the registry keeps a run id. */
|
|
209
|
+
const RUN_ID = /^\d{1,12}$/;
|
|
167
210
|
const isScheduleId = (value) => typeof value === 'string' && SCHEDULE_ID.test(value);
|
|
168
211
|
exports.isScheduleId = isScheduleId;
|
|
169
212
|
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -182,8 +225,14 @@ const checkWhen = (raw) => {
|
|
|
182
225
|
return 'when has a field it does not know';
|
|
183
226
|
return isInt(raw.minutes, MIN_EVERY_MIN, MAX_EVERY_MIN) ? null : `when.minutes must be a whole number of at least ${MIN_EVERY_MIN}`;
|
|
184
227
|
}
|
|
228
|
+
if (raw.kind === 'once') {
|
|
229
|
+
if (Object.keys(raw).some((k) => k !== 'kind' && k !== 'at'))
|
|
230
|
+
return 'when has a field it does not know';
|
|
231
|
+
const wellFormed = typeof raw.at === 'string' && AT_INSTANT.test(raw.at) && Number.isFinite(Date.parse(raw.at));
|
|
232
|
+
return wellFormed ? null : 'when.at must be an ISO 8601 time with its offset';
|
|
233
|
+
}
|
|
185
234
|
if (raw.kind !== 'daily')
|
|
186
|
-
return "when.kind must be 'daily' or '
|
|
235
|
+
return "when.kind must be 'daily', 'every' or 'once'";
|
|
187
236
|
if (Object.keys(raw).some((k) => k !== 'kind' && k !== 'at' && k !== 'days'))
|
|
188
237
|
return 'when has a field it does not know';
|
|
189
238
|
if (typeof raw.at !== 'string' || !AT_TIME.test(raw.at))
|
|
@@ -203,7 +252,7 @@ const validateSchedule = (input) => {
|
|
|
203
252
|
const unknownKey = Object.keys(input).find((k) => !KEYS.has(k));
|
|
204
253
|
if (unknownKey !== undefined)
|
|
205
254
|
return fail(`schedule has a field it does not know: ${unknownKey}`);
|
|
206
|
-
const { v, id, createdAt, updatedAt, source, agentKind, cwd, label, session, wt, agentArgs, prompt, when, maxRuntimeMin } = input;
|
|
255
|
+
const { v, id, createdAt, updatedAt, source, agentKind, cwd, label, session, target, runId, wt, agentArgs, prompt, when, maxRuntimeMin } = input;
|
|
207
256
|
if (v !== 1)
|
|
208
257
|
return fail('schedule.v must be 1');
|
|
209
258
|
if (!(0, exports.isScheduleId)(id))
|
|
@@ -245,13 +294,29 @@ const validateSchedule = (input) => {
|
|
|
245
294
|
}
|
|
246
295
|
if (source === 'phone' && prompt === undefined)
|
|
247
296
|
return fail('a schedule from the phone needs a prompt');
|
|
297
|
+
if (target !== undefined && target !== 'session')
|
|
298
|
+
return fail("schedule.target must be 'session' when given");
|
|
299
|
+
if (target === 'session') {
|
|
300
|
+
if (typeof runId !== 'string' || !RUN_ID.test(runId))
|
|
301
|
+
return fail('schedule.runId must be the run the session target types into');
|
|
302
|
+
if (prompt === undefined)
|
|
303
|
+
return fail('a schedule into a session needs a prompt');
|
|
304
|
+
if (wt !== undefined)
|
|
305
|
+
return fail('a schedule into a session carries no wt');
|
|
306
|
+
if (agentArgs !== undefined)
|
|
307
|
+
return fail('a schedule into a session carries no agentArgs');
|
|
308
|
+
}
|
|
309
|
+
else if (runId !== undefined) {
|
|
310
|
+
return fail("schedule.runId belongs to target 'session' only");
|
|
311
|
+
}
|
|
248
312
|
const badWhen = checkWhen(when);
|
|
249
313
|
if (badWhen)
|
|
250
314
|
return fail(badWhen);
|
|
251
315
|
const checkedWhen = when; // checkWhen accepted its shape
|
|
252
316
|
if (!isInt(maxRuntimeMin, MIN_MAX_RUNTIME_MIN, MAX_MAX_RUNTIME_MIN))
|
|
253
317
|
return fail(`schedule.maxRuntimeMin must be ${MIN_MAX_RUNTIME_MIN} to ${MAX_MAX_RUNTIME_MIN}`);
|
|
254
|
-
|
|
318
|
+
// A session target has no runtime cap (the session is not the schedule's): its `maxRuntimeMin` bounds nothing.
|
|
319
|
+
if (checkedWhen.kind === 'every' && target !== 'session' && maxRuntimeMin > checkedWhen.minutes)
|
|
255
320
|
return fail('schedule.maxRuntimeMin cannot be longer than the --every period');
|
|
256
321
|
// Every field of `Schedule` was checked above; `input` is a record, which TypeScript cannot narrow field by field.
|
|
257
322
|
return { ok: true, value: input };
|
|
@@ -326,6 +391,10 @@ const addSchedule = (make, newId = newScheduleId) => {
|
|
|
326
391
|
const checked = (0, exports.validateSchedule)(schedule);
|
|
327
392
|
if (!checked.ok)
|
|
328
393
|
return { ok: false, error: checked.error };
|
|
394
|
+
// A `once` made after its time would never run: the runner counts from `createdAt`.
|
|
395
|
+
const past = (0, exports.pastWhen)(schedule.when, Date.parse(schedule.createdAt));
|
|
396
|
+
if (past)
|
|
397
|
+
return { ok: false, error: past };
|
|
329
398
|
if (schedule.id !== id)
|
|
330
399
|
return { ok: false, error: 'schedule.id is not the id it was made for' };
|
|
331
400
|
try {
|
|
@@ -369,6 +438,9 @@ const updateSchedule = (id, change, now) => {
|
|
|
369
438
|
const checked = (0, exports.validateSchedule)({ ...current, ...change, ...(retimed ? { updatedAt: new Date(now).toISOString() } : {}) });
|
|
370
439
|
if (!checked.ok)
|
|
371
440
|
return { ok: false, error: checked.error, failure: 'invalid' };
|
|
441
|
+
const past = retimed ? (0, exports.pastWhen)(checked.value.when, now) : null;
|
|
442
|
+
if (past)
|
|
443
|
+
return { ok: false, error: past, failure: 'invalid' };
|
|
372
444
|
try {
|
|
373
445
|
(0, replace_file_js_1.replaceFile)(schedulePath(id), `${JSON.stringify(checked.value, null, 2)}\n`, undefined, 0o600);
|
|
374
446
|
return { ok: true, schedule: checked.value };
|
|
@@ -378,6 +450,9 @@ const updateSchedule = (id, change, now) => {
|
|
|
378
450
|
}
|
|
379
451
|
};
|
|
380
452
|
exports.updateSchedule = updateSchedule;
|
|
453
|
+
/** Why `when` cannot be given to a schedule at `now`: a `once` whose time has passed would never run. Null when it can. */
|
|
454
|
+
const pastWhen = (when, now) => when.kind === 'once' && Date.parse(when.at) <= now ? 'that time has already passed' : null;
|
|
455
|
+
exports.pastWhen = pastWhen;
|
|
381
456
|
/** The time a schedule's runs are counted from: its last edit, else its creation. */
|
|
382
457
|
const scheduleAnchor = (schedule) => Date.parse(schedule.updatedAt ?? schedule.createdAt);
|
|
383
458
|
exports.scheduleAnchor = scheduleAnchor;
|
|
@@ -398,6 +473,8 @@ exports.removeSchedule = removeSchedule;
|
|
|
398
473
|
// ── What the listener has done ───────────────────────────────────────
|
|
399
474
|
const SCHEDULE_OUTCOMES = [
|
|
400
475
|
'started', 'missed', 'overlap', 'busy', 'start_failed', 'missing_directory', 'unknown_agent', 'prompt_undelivered', 'prompt_unsupported', 'capped',
|
|
476
|
+
// A session target only: the run stayed busy past the wait, or it is over.
|
|
477
|
+
'session_busy', 'session_ended',
|
|
401
478
|
];
|
|
402
479
|
const scheduleStatePath = () => (0, node_path_1.join)((0, gate_js_1.stateDir)(), 'schedule-state.json');
|
|
403
480
|
exports.scheduleStatePath = scheduleStatePath;
|
|
@@ -408,10 +485,13 @@ const readRunState = (raw) => {
|
|
|
408
485
|
if (!isRecord(raw) || !isNumber(raw.cursor))
|
|
409
486
|
return null;
|
|
410
487
|
const state = { cursor: raw.cursor };
|
|
411
|
-
const { running, last, pending } = raw;
|
|
488
|
+
const { running, delivering, last, pending } = raw;
|
|
412
489
|
if (isRecord(running) && isSessionName(running.session) && isNumber(running.startedAt) && typeof running.promptPending === 'boolean') {
|
|
413
490
|
state.running = { session: running.session, startedAt: running.startedAt, promptPending: running.promptPending, ...(running.capped === true ? { capped: true } : {}) };
|
|
414
491
|
}
|
|
492
|
+
if (isRecord(delivering) && isSessionName(delivering.session) && isNumber(delivering.since)) {
|
|
493
|
+
state.delivering = { session: delivering.session, since: delivering.since };
|
|
494
|
+
}
|
|
415
495
|
if (isRecord(last) && isOutcome(last.outcome) && isNumber(last.at))
|
|
416
496
|
state.last = { outcome: last.outcome, at: last.at };
|
|
417
497
|
if (isRecord(pending) && isNumber(pending.since) && isRecord(pending.counts)) {
|
|
@@ -86,6 +86,11 @@ export declare const KNOWN_SESSION_TTL_MS: number;
|
|
|
86
86
|
* runs were recorded) keys as the empty run.
|
|
87
87
|
*/
|
|
88
88
|
export declare const runKey: (name: string, agentKind: string, runId?: string) => string;
|
|
89
|
+
/**
|
|
90
|
+
* The registry file is there but would not read as a list: what `knownSessions` returns then is not all there is
|
|
91
|
+
* (it reads as empty). No file yet is not unreadable.
|
|
92
|
+
*/
|
|
93
|
+
export declare const registryUnreadable: () => boolean;
|
|
89
94
|
/** Sessions this machine has seen, newest first, expired ones dropped. */
|
|
90
95
|
export declare const knownSessions: (now?: number) => KnownSession[];
|
|
91
96
|
/** One remembered session, or null when this machine never saw that name.
|
|
@@ -118,8 +123,34 @@ export declare const MAX_TMUX_RUNS_PER_RUN = 10;
|
|
|
118
123
|
* — a fresh start `/resume`d into an old conversation keeps what it wrote first.
|
|
119
124
|
*/
|
|
120
125
|
export declare const continuedRunId: (entries: readonly KnownSession[], name: string, agentKind: string, tmuxRunId: string, conversationId: string | null | undefined) => string;
|
|
121
|
-
/** The run going on in tmux session `tmuxRunId` of `name`: the one it was joined to, else its own. */
|
|
122
|
-
export declare const runOfTmuxSession: (name: string, tmuxRunId: string, now?: number) => string;
|
|
126
|
+
/** The run going on in tmux session `tmuxRunId` of `name`: the one it was joined to, else its own. `known` is the registry as read. */
|
|
127
|
+
export declare const runOfTmuxSession: (name: string, tmuxRunId: string, now?: number, known?: KnownSession[]) => string;
|
|
128
|
+
/** How long after a schedule is tied to a run the registry may still be without a record of it (the listener's sweep writes it). */
|
|
129
|
+
export declare const UNRECORDED_RUN_GRACE_MS: number;
|
|
130
|
+
/** What tmux is asked to find a run: `liveSessionNames` and `liveRunIdOf` (`tmux-liveness.ts`), passed in so this file stays free of tmux. */
|
|
131
|
+
export interface TmuxRuns {
|
|
132
|
+
/** Every session name tmux holds; null when no tmux answered. */
|
|
133
|
+
names: () => string[] | null;
|
|
134
|
+
/** The `session_created` of the tmux session under `name`; null when there is none, undefined when tmux did not answer. */
|
|
135
|
+
runIdOf: (name: string) => string | null | undefined;
|
|
136
|
+
}
|
|
137
|
+
/** Where a run is now: the tmux session it goes on in, `ended` when tmux answered and none holds it, `unknown` when tmux could not say. */
|
|
138
|
+
export type RunWhereabouts = {
|
|
139
|
+
name: string;
|
|
140
|
+
} | 'ended' | 'unknown';
|
|
141
|
+
/**
|
|
142
|
+
* The tmux session run `runId` goes on in now. The names it is looked for under are `lastName` (where it was last seen —
|
|
143
|
+
* the registry may not have written a new run down yet) and every name the registry has seen it under (a resume can
|
|
144
|
+
* carry it to another one). A name holds it when the tmux session under it is that run (`runOfTmuxSession`), so a name
|
|
145
|
+
* a new run took over does not count. tmux is asked for its names once and for each candidate once.
|
|
146
|
+
*
|
|
147
|
+
* "Ended" only when tmux answered for every candidate: a failure to ask is `unknown`, which a caller waits out rather
|
|
148
|
+
* than reporting the run as over. So is a run the registry has no record of within `UNRECORDED_RUN_GRACE_MS` of
|
|
149
|
+
* `boundAt` (when the caller tied itself to the run): the sweep may not have written it down yet, and it may have gone
|
|
150
|
+
* on under a name nobody knows. Later, no record means the record expired or was evicted — the run is over — unless
|
|
151
|
+
* the registry file would not read, which says nothing either way.
|
|
152
|
+
*/
|
|
153
|
+
export declare const liveSessionOfRun: (runId: string, lastName: string, tmux: TmuxRuns, now?: number, boundAt?: number) => RunWhereabouts;
|
|
123
154
|
/**
|
|
124
155
|
* Make this process a reader of the registry: `rememberSessions` writes nothing from here on. For the
|
|
125
156
|
* CLI commands that look at the sweep (`hwip agent list|read`) — the file is the listener's resume
|
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.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;
|
|
19
|
+
exports.sessionDirectoryExists = exports.knownSessionsPath = exports.isKnownSession = exports.forgetEnded = exports.forgetSession = exports.rememberSessions = exports.keepRegistryUntouched = exports.liveSessionOfRun = exports.UNRECORDED_RUN_GRACE_MS = exports.runOfTmuxSession = exports.continuedRunId = exports.MAX_TMUX_RUNS_PER_RUN = exports.recallRun = exports.recallSession = exports.knownSessions = exports.registryUnreadable = 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");
|
|
@@ -99,6 +99,19 @@ const writeAll = (entries) => {
|
|
|
99
99
|
*/
|
|
100
100
|
const runKey = (name, agentKind, runId) => `${name}\u0000${agentKind}\u0000${runId ?? ''}`;
|
|
101
101
|
exports.runKey = runKey;
|
|
102
|
+
/**
|
|
103
|
+
* The registry file is there but would not read as a list: what `knownSessions` returns then is not all there is
|
|
104
|
+
* (it reads as empty). No file yet is not unreadable.
|
|
105
|
+
*/
|
|
106
|
+
const registryUnreadable = () => {
|
|
107
|
+
try {
|
|
108
|
+
return !Array.isArray(JSON.parse((0, fs_1.readFileSync)(registryPath(), 'utf-8')));
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
return !(e instanceof Error && 'code' in e && e.code === 'ENOENT');
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
exports.registryUnreadable = registryUnreadable;
|
|
102
115
|
/** Sessions this machine has seen, newest first, expired ones dropped. */
|
|
103
116
|
const knownSessions = (now = Date.now()) => readAll()
|
|
104
117
|
.filter((e) => now - Date.parse(e.lastSeenAt) < exports.KNOWN_SESSION_TTL_MS)
|
|
@@ -158,9 +171,45 @@ const continuedRunId = (entries, name, agentKind, tmuxRunId, conversationId) =>
|
|
|
158
171
|
return earlier?.runId ?? tmuxRunId;
|
|
159
172
|
};
|
|
160
173
|
exports.continuedRunId = continuedRunId;
|
|
161
|
-
/** The run going on in tmux session `tmuxRunId` of `name`: the one it was joined to, else its own. */
|
|
162
|
-
const runOfTmuxSession = (name, tmuxRunId, now = Date.now()
|
|
174
|
+
/** The run going on in tmux session `tmuxRunId` of `name`: the one it was joined to, else its own. `known` is the registry as read. */
|
|
175
|
+
const runOfTmuxSession = (name, tmuxRunId, now = Date.now(), known = (0, exports.knownSessions)(now)) => known.find((e) => e.name === name && e.tmuxRunIds?.includes(tmuxRunId))?.runId ?? tmuxRunId;
|
|
163
176
|
exports.runOfTmuxSession = runOfTmuxSession;
|
|
177
|
+
/** How long after a schedule is tied to a run the registry may still be without a record of it (the listener's sweep writes it). */
|
|
178
|
+
exports.UNRECORDED_RUN_GRACE_MS = 10 * 60_000;
|
|
179
|
+
/**
|
|
180
|
+
* The tmux session run `runId` goes on in now. The names it is looked for under are `lastName` (where it was last seen —
|
|
181
|
+
* the registry may not have written a new run down yet) and every name the registry has seen it under (a resume can
|
|
182
|
+
* carry it to another one). A name holds it when the tmux session under it is that run (`runOfTmuxSession`), so a name
|
|
183
|
+
* a new run took over does not count. tmux is asked for its names once and for each candidate once.
|
|
184
|
+
*
|
|
185
|
+
* "Ended" only when tmux answered for every candidate: a failure to ask is `unknown`, which a caller waits out rather
|
|
186
|
+
* than reporting the run as over. So is a run the registry has no record of within `UNRECORDED_RUN_GRACE_MS` of
|
|
187
|
+
* `boundAt` (when the caller tied itself to the run): the sweep may not have written it down yet, and it may have gone
|
|
188
|
+
* on under a name nobody knows. Later, no record means the record expired or was evicted — the run is over — unless
|
|
189
|
+
* the registry file would not read, which says nothing either way.
|
|
190
|
+
*/
|
|
191
|
+
const liveSessionOfRun = (runId, lastName, tmux, now = Date.now(), boundAt = now) => {
|
|
192
|
+
const names = tmux.names();
|
|
193
|
+
if (names === null)
|
|
194
|
+
return 'unknown';
|
|
195
|
+
const running = new Set(names);
|
|
196
|
+
const known = (0, exports.knownSessions)(now);
|
|
197
|
+
const recorded = known.filter((e) => e.runId === runId);
|
|
198
|
+
const candidates = [...new Set([lastName, ...recorded.map((e) => e.name)])];
|
|
199
|
+
let unsure = false;
|
|
200
|
+
for (const name of candidates) {
|
|
201
|
+
if (!running.has(name))
|
|
202
|
+
continue;
|
|
203
|
+
const tmuxRunId = tmux.runIdOf(name);
|
|
204
|
+
if (tmuxRunId === undefined)
|
|
205
|
+
unsure = true;
|
|
206
|
+
else if (tmuxRunId !== null && (0, exports.runOfTmuxSession)(name, tmuxRunId, now, known) === runId)
|
|
207
|
+
return { name };
|
|
208
|
+
}
|
|
209
|
+
const noRecord = recorded.length === 0 && (now - boundAt < exports.UNRECORDED_RUN_GRACE_MS || (0, exports.registryUnreadable)());
|
|
210
|
+
return unsure || noRecord ? 'unknown' : 'ended';
|
|
211
|
+
};
|
|
212
|
+
exports.liveSessionOfRun = liveSessionOfRun;
|
|
164
213
|
let readOnly = false;
|
|
165
214
|
/**
|
|
166
215
|
* Make this process a reader of the registry: `rememberSessions` writes nothing from here on. For the
|
package/dist/templates.d.ts
CHANGED
|
@@ -16,7 +16,16 @@ export declare const CLINE_RULE: string;
|
|
|
16
16
|
export declare const AIDER_RULE: string;
|
|
17
17
|
/** Pi — managed block in ~/.pi/agent/AGENTS.md. Extension = Stop-equivalent + prompt hook + waiting-on-you push (PI_EXTENSION), so NORMAL-only core. */
|
|
18
18
|
export declare const PI_RULE: string;
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* OpenCode — managed block in ~/.config/opencode/AGENTS.md. Stop hook via plugin, no prompt hook.
|
|
21
|
+
*
|
|
22
|
+
* No REMOTE_ENTRY_NO_HOOK, unlike the other hook-less agents: its after-work
|
|
23
|
+
* ask landed on the phone while the user sat at the terminal (2026-10-29,
|
|
24
|
+
* OpenCode 2.1.3), and the 2.x plugin API (event stream only) has no per-turn
|
|
25
|
+
* hook to tell the two apart. So a NORMAL session owes no ask, and REMOTE
|
|
26
|
+
* starts only from an ask the agent chose to send. Full core, because no hook
|
|
27
|
+
* delivers the REMOTE sections on entry.
|
|
28
|
+
*/
|
|
20
29
|
export declare const OPENCODE_RULE: string;
|
|
21
30
|
export declare const CURSOR_HOOKS: {
|
|
22
31
|
version: number;
|
package/dist/templates.js
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
// hook note (a phone message) or by a `hwip_ask` answer. Only Gemini, Codex
|
|
22
22
|
// and Pi have that hook (remoteHookCmd below). Every other agent's only door is the
|
|
23
23
|
// ask itself — so for them the NORMAL branch must still send one after real
|
|
24
|
-
// work, or the phone loop can never begin. That is REMOTE_ENTRY_NO_HOOK
|
|
24
|
+
// work, or the phone loop can never begin. That is REMOTE_ENTRY_NO_HOOK
|
|
25
|
+
// (OpenCode excepted — OPENCODE_RULE says why).
|
|
25
26
|
//
|
|
26
27
|
// The same axis decides how much of the core the static rule file carries.
|
|
27
28
|
// That file is read on every turn, and a session is NORMAL nearly all of the
|
|
@@ -125,7 +126,7 @@ refactor, multi-file changes) call hwip_notify. Skip it for trivial
|
|
|
125
126
|
operations (file reads, simple searches). Set priority "high" for
|
|
126
127
|
errors/blockers.`;
|
|
127
128
|
// REMOTE-entry preamble — agents with NO prompt-submit hook (Cursor, Windsurf,
|
|
128
|
-
// Copilot, Cline, Aider,
|
|
129
|
+
// Copilot, Cline, Aider; OpenCode opts out, see OPENCODE_RULE). The shared core scopes Rules 1/2/8/9 to REMOTE
|
|
129
130
|
// and tells a NORMAL session it owes no `hwip_ask`. That is right where a
|
|
130
131
|
// hook can announce the phone message that starts REMOTE; here nothing can,
|
|
131
132
|
// and the only remaining entry is a `hwip_ask` answer reporting REMOTE.
|
|
@@ -266,10 +267,18 @@ exports.PI_RULE = buildRule({
|
|
|
266
267
|
pushSignal: PI_PUSH_SIGNAL,
|
|
267
268
|
core: PROMPT_HOOK_CORE,
|
|
268
269
|
});
|
|
269
|
-
/**
|
|
270
|
+
/**
|
|
271
|
+
* OpenCode — managed block in ~/.config/opencode/AGENTS.md. Stop hook via plugin, no prompt hook.
|
|
272
|
+
*
|
|
273
|
+
* No REMOTE_ENTRY_NO_HOOK, unlike the other hook-less agents: its after-work
|
|
274
|
+
* ask landed on the phone while the user sat at the terminal (2026-10-29,
|
|
275
|
+
* OpenCode 2.1.3), and the 2.x plugin API (event stream only) has no per-turn
|
|
276
|
+
* hook to tell the two apart. So a NORMAL session owes no ask, and REMOTE
|
|
277
|
+
* starts only from an ask the agent chose to send. Full core, because no hook
|
|
278
|
+
* delivers the REMOTE sections on entry.
|
|
279
|
+
*/
|
|
270
280
|
exports.OPENCODE_RULE = buildRule({
|
|
271
281
|
notify: HOOK_DRIVEN_NOTIFY,
|
|
272
|
-
remoteEntry: REMOTE_ENTRY_NO_HOOK,
|
|
273
282
|
core: hwip_core_generated_js_1.HWIP_CORE_HOOK_DRIVEN,
|
|
274
283
|
});
|
|
275
284
|
// ── Hook configs ─────────────────────────────────────────────────
|
package/dist/tmux-liveness.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type SessionLiveness, type TmuxRuns } from './session-registry.js';
|
|
2
2
|
/**
|
|
3
3
|
* Every session name the tmux server currently holds, or null when there was no
|
|
4
4
|
* server to ask — the binary is missing, or nothing answers on the socket this
|
|
@@ -18,6 +18,10 @@ export declare const liveSessionNames: () => string[] | null;
|
|
|
18
18
|
* must refuse on that, as it refuses on `unknown` liveness.
|
|
19
19
|
*/
|
|
20
20
|
export declare const liveRunIdOf: (name: string) => string | null | undefined;
|
|
21
|
+
/** What `liveSessionOfRun` (session-registry.ts) asks tmux. */
|
|
22
|
+
export declare const liveTmuxRuns: TmuxRuns;
|
|
23
|
+
/** The run going on under `name` now (`runOfTmuxSession`): null when the name is not running, undefined when no tmux answered. */
|
|
24
|
+
export declare const liveRunOf: (name: string) => string | null | undefined;
|
|
21
25
|
/**
|
|
22
26
|
* The three-state answer, for callers that destroy something on "ended".
|
|
23
27
|
*
|
package/dist/tmux-liveness.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.sessionExists = exports.sessionLiveness = exports.liveRunIdOf = exports.liveSessionNames = void 0;
|
|
3
|
+
exports.sessionExists = exports.sessionLiveness = exports.liveRunOf = exports.liveTmuxRuns = exports.liveRunIdOf = exports.liveSessionNames = void 0;
|
|
4
4
|
const child_process_1 = require("child_process");
|
|
5
|
+
const session_registry_js_1 = require("./session-registry.js");
|
|
5
6
|
const tmux_pane_js_1 = require("./tmux-pane.js");
|
|
6
7
|
const tmux_socket_js_1 = require("./tmux-socket.js");
|
|
7
8
|
/**
|
|
@@ -48,6 +49,14 @@ const liveRunIdOf = (name) => {
|
|
|
48
49
|
return null;
|
|
49
50
|
};
|
|
50
51
|
exports.liveRunIdOf = liveRunIdOf;
|
|
52
|
+
/** What `liveSessionOfRun` (session-registry.ts) asks tmux. */
|
|
53
|
+
exports.liveTmuxRuns = { names: exports.liveSessionNames, runIdOf: exports.liveRunIdOf };
|
|
54
|
+
/** The run going on under `name` now (`runOfTmuxSession`): null when the name is not running, undefined when no tmux answered. */
|
|
55
|
+
const liveRunOf = (name) => {
|
|
56
|
+
const tmuxRun = (0, exports.liveRunIdOf)(name);
|
|
57
|
+
return typeof tmuxRun === 'string' ? (0, session_registry_js_1.runOfTmuxSession)(name, tmuxRun) : tmuxRun;
|
|
58
|
+
};
|
|
59
|
+
exports.liveRunOf = liveRunOf;
|
|
51
60
|
/**
|
|
52
61
|
* The three-state answer, for callers that destroy something on "ended".
|
|
53
62
|
*
|
|
@@ -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,
|
|
101
|
+
body: JSON.stringify({ seq: chunk.seq, firstAt: chunk.firstAt, lastAt: chunk.lastAt, runId: chunk.runId, sealed: chunk.sealed }),
|
|
102
102
|
});
|
|
103
103
|
}
|
|
104
104
|
catch (err) {
|
package/dist/turn-recorder.d.ts
CHANGED
|
@@ -98,8 +98,8 @@ export interface OutboxChunk {
|
|
|
98
98
|
seq: number;
|
|
99
99
|
firstAt: string;
|
|
100
100
|
lastAt: string;
|
|
101
|
-
/** The run the events were recorded in
|
|
102
|
-
runId
|
|
101
|
+
/** The run the events were recorded in: the server takes no chunk without one. */
|
|
102
|
+
runId: string;
|
|
103
103
|
sealed: SealedChunk;
|
|
104
104
|
}
|
|
105
105
|
export type UploadOutcome =
|
package/dist/turn-recorder.js
CHANGED
|
@@ -90,7 +90,7 @@ const isOutboxChunk = (c) => {
|
|
|
90
90
|
const o = c;
|
|
91
91
|
const s = o.sealed;
|
|
92
92
|
return (Number.isSafeInteger(o.seq) && typeof o.firstAt === 'string' && typeof o.lastAt === 'string' &&
|
|
93
|
-
!!s && typeof s.body === 'string' && typeof s.senderPublicKey === 'string' &&
|
|
93
|
+
typeof o.runId === 'string' && !!o.runId && !!s && typeof s.body === 'string' && typeof s.senderPublicKey === 'string' &&
|
|
94
94
|
!!s.deviceKeyMap && typeof s.deviceKeyMap === 'object');
|
|
95
95
|
};
|
|
96
96
|
/** What is on disk, or null when it is missing or not ours (a bad file is a fresh start, never a crash). */
|
|
@@ -273,13 +273,21 @@ const createTurnRecorder = (deps) => {
|
|
|
273
273
|
const chunks = [];
|
|
274
274
|
const runId = runOf(live, live.tailPath);
|
|
275
275
|
let seq = base.seq;
|
|
276
|
+
// No run to name (a session name that ended, its file still written to): the server takes no such chunk,
|
|
277
|
+
// so what was read is passed over rather than sealed and sent to be refused.
|
|
278
|
+
if (!runId && !live.noRunWarned) {
|
|
279
|
+
live.noRunWarned = true;
|
|
280
|
+
deps.log(`⧉ turn-recorder ${live.name}: no run to tag its chunks with — what it writes is not recorded`);
|
|
281
|
+
}
|
|
276
282
|
try {
|
|
277
283
|
for (const page of pages) {
|
|
284
|
+
if (!runId)
|
|
285
|
+
break;
|
|
278
286
|
seq += 1;
|
|
279
287
|
const firstAt = eventAt(page, 'first', fallbackAt);
|
|
280
288
|
const lastAt = eventAt(page, 'last', fallbackAt);
|
|
281
289
|
const sealed = await deps.seal(JSON.stringify({ sessionKey: live.sessionKey, seq, events: page }), recipients);
|
|
282
|
-
chunks.push({ seq, firstAt, lastAt,
|
|
290
|
+
chunks.push({ seq, firstAt, lastAt, runId, sealed });
|
|
283
291
|
}
|
|
284
292
|
}
|
|
285
293
|
catch (err) {
|