@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/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, and nothing reads-then-rewrites a file, so the two
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 [--days …]` or `--every <N>m|<N>h`, not both and not neither. */
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 'every'";
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
- if (checkedWhen.kind === 'every' && maxRuntimeMin > checkedWhen.minutes)
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
@@ -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()) => (0, exports.knownSessions)(now).find((e) => e.name === name && e.tmuxRunIds?.includes(tmuxRunId))?.runId ?? tmuxRunId;
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
@@ -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
- /** OpenCode — managed block in ~/.config/opencode/AGENTS.md. Stop hook via plugin, no prompt hook (v1). */
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, OpenCode). The shared core scopes Rules 1/2/8/9 to REMOTE
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
- /** OpenCode — managed block in ~/.config/opencode/AGENTS.md. Stop hook via plugin, no prompt hook (v1). */
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 ─────────────────────────────────────────────────
@@ -1,4 +1,4 @@
1
- import type { SessionLiveness } from './session-registry.js';
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
  *
@@ -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, ...(chunk.runId ? { runId: chunk.runId } : {}), sealed: chunk.sealed }),
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) {
@@ -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. Absent from chunks made before runs were recorded. */
102
- runId?: string;
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 =
@@ -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, ...(runId ? { runId } : {}), sealed });
290
+ chunks.push({ seq, firstAt, lastAt, runId, sealed });
283
291
  }
284
292
  }
285
293
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hwip/cli",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Hwip CLI + push notification SDK for AI agents",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",