@hwip/cli 0.1.9 → 0.1.11

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
@@ -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
  *
package/dist/wrapper.d.ts CHANGED
@@ -32,6 +32,8 @@ export declare const herdrSessionArgs: (session: string, env?: NodeJS.ProcessEnv
32
32
  * an older tmux gets none, as before.
33
33
  */
34
34
  export declare const herdrEnvArgs: (agentKind: string | undefined, env: NodeJS.ProcessEnv, tmuxVersion: string) => string[];
35
+ export declare const agentLaunchEnv: (agentKind: string | undefined, env: NodeJS.ProcessEnv) => Record<string, string>;
36
+ export declare const agentLaunchArgs: (agentKind: string | undefined, extra: string[]) => string[];
35
37
  /** `tmux -V`, read once per process; empty when tmux does not answer. */
36
38
  export declare const installedTmuxVersion: () => string;
37
39
  /**
@@ -91,6 +93,8 @@ export interface SpawnTarget {
91
93
  args: string[];
92
94
  /** The tmux session name for the two tmux kinds — carried, not re-read off argv. */
93
95
  session?: string;
96
+ /** Variables the agent runs with on top of this process's env (`direct`); the tmux kinds carry them as `-e`. */
97
+ env?: Record<string, string>;
94
98
  }
95
99
  /**
96
100
  * `hwip <agent> --detach --label <x> --wt <name> …` — the wrapper-owned flags. They
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.familyNames = exports.firstFreeSession = exports.tmuxSessionName = exports.installedTmuxVersion = exports.herdrEnvArgs = 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.familyNames = exports.firstFreeSession = exports.tmuxSessionName = exports.installedTmuxVersion = exports.agentLaunchArgs = exports.agentLaunchEnv = 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
@@ -106,6 +106,36 @@ const herdrEnvArgs = (agentKind, env, tmuxVersion) => {
106
106
  return config_js_1.HERDR_ENV_VARS.flatMap((name) => ['-e', `${name}=${native ? env[name] ?? '' : ''}`]);
107
107
  };
108
108
  exports.herdrEnvArgs = herdrEnvArgs;
109
+ /**
110
+ * Env that keeps an agent's transcript in tmux scrollback, by kind. A
111
+ * full-screen renderer draws on the alternate screen, where tmux keeps no
112
+ * history, so the phone cannot page back. Claude Code's fullscreen renderer
113
+ * is a user setting (`/tui fullscreen`); this forces the classic one. A value
114
+ * the launching shell already sets wins.
115
+ */
116
+ const AGENT_LAUNCH_ENV = {
117
+ claude: { CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: '1' },
118
+ };
119
+ const agentLaunchEnv = (agentKind, env) => Object.fromEntries(Object.entries(AGENT_LAUNCH_ENV[agentKind ?? ''] ?? {}).map(([name, value]) => [name, env[name] ?? value]));
120
+ exports.agentLaunchEnv = agentLaunchEnv;
121
+ /**
122
+ * Leading flags with the same aim, by kind, for an agent whose renderer is a
123
+ * flag: pi defaults to `--tui-mode fullscreen`. Left off when the caller
124
+ * passes the flag itself.
125
+ */
126
+ const AGENT_LAUNCH_ARGS = {
127
+ pi: ['--tui-mode', 'regular'],
128
+ };
129
+ const agentLaunchArgs = (agentKind, extra) => {
130
+ const lead = AGENT_LAUNCH_ARGS[agentKind ?? ''] ?? [];
131
+ const flag = lead[0];
132
+ if (flag === undefined || extra.some((a) => a === flag || a.startsWith(`${flag}=`)))
133
+ return extra;
134
+ return [...lead, ...extra];
135
+ };
136
+ exports.agentLaunchArgs = agentLaunchArgs;
137
+ /** `new-session -e` flags for `agentLaunchEnv`; none before tmux 3.2, as for herdr. */
138
+ const launchEnvArgs = (launchEnv, tmuxVersion) => sessionEnvSupported(tmuxVersion) ? Object.entries(launchEnv).flatMap(([name, value]) => ['-e', `${name}=${value}`]) : [];
109
139
  /** `tmux -V` says 3.2 or later (`new-session -e`); a development build (`next-3.7`, `master`) is newer. */
110
140
  const sessionEnvSupported = (version) => {
111
141
  if (/\b(master|next-)/.test(version))
@@ -310,8 +340,10 @@ const targetForAgent = (agent, extra, agentKind, opts = {}) => {
310
340
  // is exactly where it runs from.
311
341
  // A label asks for a tmux session by name; running in the current pane
312
342
  // would drop that silently, so a label is the other exception.
343
+ const launchEnv = (0, exports.agentLaunchEnv)(agentKind, process.env);
344
+ extra = (0, exports.agentLaunchArgs)(agentKind, extra);
313
345
  if (process.env.TMUX && !opts.detach && !opts.label) {
314
- return { kind: 'direct', cmd: agent, args: extra };
346
+ return { kind: 'direct', cmd: agent, args: extra, ...(Object.keys(launchEnv).length ? { env: launchEnv } : {}) };
315
347
  }
316
348
  const projectDir = opts.projectDir ?? (0, exports.detectProjectDir)();
317
349
  const base = (0, exports.tmuxSessionName)((0, exports.safeBasename)(projectDir));
@@ -342,11 +374,13 @@ const targetForAgent = (agent, extra, agentKind, opts = {}) => {
342
374
  if (opts.detach) {
343
375
  // Attached to no herdr pane: blank, whatever pane this command was typed in.
344
376
  const herdrEnv = (0, exports.herdrEnvArgs)(agentKind, {}, (0, exports.installedTmuxVersion)());
345
- return { kind: 'tmux-detached', cmd: 'tmux', args: ['new', '-d', '-s', session, '-c', projectDir, ...herdrEnv, shellCmd, ...sidecar], session };
377
+ const agentEnv = launchEnvArgs(launchEnv, (0, exports.installedTmuxVersion)());
378
+ return { kind: 'tmux-detached', cmd: 'tmux', args: ['new', '-d', '-s', session, '-c', projectDir, ...herdrEnv, ...agentEnv, shellCmd, ...sidecar], session };
346
379
  }
347
380
  // `tmux new -A`: attach if the named session exists, else create it. `-e` applies only when it creates.
348
381
  const herdrEnv = (0, exports.herdrEnvArgs)(agentKind, process.env, (0, exports.installedTmuxVersion)());
349
- return { kind: 'tmux-new', cmd: 'tmux', args: ['new', '-A', '-s', session, ...herdrEnv, shellCmd, ...sidecar, ...(0, exports.herdrSessionArgs)(session)], session };
382
+ const agentEnv = launchEnvArgs(launchEnv, (0, exports.installedTmuxVersion)());
383
+ return { kind: 'tmux-new', cmd: 'tmux', args: ['new', '-A', '-s', session, ...herdrEnv, ...agentEnv, shellCmd, ...sidecar, ...(0, exports.herdrSessionArgs)(session)], session };
350
384
  };
351
385
  exports.targetForAgent = targetForAgent;
352
386
  // ── Background listener auto-start ────────────────────────────────────
@@ -561,7 +595,7 @@ const handleAgentSession = async (agent, extra = [], opts = {}, deps = {}) => {
561
595
  // spawned from here would inherit the worktree as its cwd, and it is the one that later removes the directory.
562
596
  if (opts.worktree && opts.projectDir)
563
597
  process.chdir(opts.projectDir);
564
- const { kind, cmd, args, session } = (0, exports.targetForAgent)(agent.binary, extra, agent.kind, opts);
598
+ const { kind, cmd, args, session, env } = (0, exports.targetForAgent)(agent.binary, extra, agent.kind, opts);
565
599
  const note = kind === 'direct' ? (0, exports.inPaneNote)(agent.subcommands[0], currentTmuxSession()) : undefined;
566
600
  if (note)
567
601
  console.error(note);
@@ -575,7 +609,7 @@ const handleAgentSession = async (agent, extra = [], opts = {}, deps = {}) => {
575
609
  return 1;
576
610
  }
577
611
  }
578
- return (deps.launch ?? launchTarget)({ kind, cmd, args, session }, agent.binary);
612
+ return (deps.launch ?? launchTarget)({ kind, cmd, args, session, env }, agent.binary);
579
613
  };
580
614
  exports.handleAgentSession = handleAgentSession;
581
615
  /**
@@ -583,7 +617,8 @@ exports.handleAgentSession = handleAgentSession;
583
617
  * hands the terminal over (execve where it can, a waited child where not) and
584
618
  * forwards the exit code. `what` names the program in the detached message.
585
619
  */
586
- const launchTarget = async ({ kind, cmd, args, session }, what) => {
620
+ const launchTarget = async ({ kind, cmd, args, session, env: agentEnv }, what) => {
621
+ const env = agentEnv ? { ...process.env, ...agentEnv } : process.env;
587
622
  if (kind === 'tmux-detached' && session) {
588
623
  const r = (0, child_process_1.spawnSync)(cmd, args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
589
624
  if (r.error || r.status !== 0) {
@@ -601,7 +636,7 @@ const launchTarget = async ({ kind, cmd, args, session }, what) => {
601
636
  // exit code that execve gives us for free by being the same process.
602
637
  const handOff = (0, exports.handOffExec)({
603
638
  execve: process.execve,
604
- env: process.env,
639
+ env,
605
640
  kind,
606
641
  stdinTTY: process.stdin.isTTY,
607
642
  stdoutTTY: process.stdout.isTTY,
@@ -621,7 +656,7 @@ const launchTarget = async ({ kind, cmd, args, session }, what) => {
621
656
  }
622
657
  return new Promise((resolve) => {
623
658
  const start = Date.now();
624
- const child = (0, child_process_1.spawn)(cmd, args, { stdio: 'inherit' });
659
+ const child = (0, child_process_1.spawn)(cmd, args, { stdio: 'inherit', env });
625
660
  child.on('exit', (code) => {
626
661
  const dur = Date.now() - start;
627
662
  // Short-lived non-zero exits are the symptom of "ran from a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hwip/cli",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Hwip CLI + push notification SDK for AI agents",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",