@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.
@@ -10,6 +10,7 @@ const node_path_1 = require("node:path");
10
10
  const schedule_js_1 = require("./schedule.js");
11
11
  const schedule_ready_js_1 = require("./schedule-ready.js");
12
12
  const session_registry_js_1 = require("./session-registry.js");
13
+ const tmux_liveness_js_1 = require("./tmux-liveness.js");
13
14
  const wrapper_js_1 = require("./wrapper.js");
14
15
  /** The longest prompt the list carries; the full text is in the file on this machine. */
15
16
  exports.LIST_PROMPT_CHARS = 500;
@@ -37,9 +38,33 @@ const isoOrNull = (ms) => {
37
38
  const date = new Date(ms);
38
39
  return Number.isNaN(date.getTime()) ? null : date.toISOString();
39
40
  };
40
- /** The schedules on this machine for the app, oldest first, prompts cut to `promptChars`. Null when the directory could not be listed or holds more than a machine may: an empty list would then be a lie. */
41
- const phoneScheduleList = (now, promptChars = exports.LIST_PROMPT_CHARS) => {
41
+ /** tmux as `liveSessionOfRun` asks it, each question answered once: one list answer asks for the names once, and each name once. */
42
+ const askingOnce = (tmux) => {
43
+ let names;
44
+ const runs = new Map();
45
+ return {
46
+ names: () => (names === undefined ? (names = tmux.names()) : names),
47
+ runIdOf: (name) => {
48
+ if (!runs.has(name))
49
+ runs.set(name, tmux.runIdOf(name));
50
+ return runs.get(name);
51
+ },
52
+ };
53
+ };
54
+ /** What the list says about a session target's run: only `ended` is news (the app shows "session ended"). */
55
+ const targetFields = (schedule, now, tmux) => {
56
+ if (schedule.target !== 'session' || schedule.runId === undefined)
57
+ return {};
58
+ const where = (0, session_registry_js_1.liveSessionOfRun)(schedule.runId, schedule.session, tmux, now, Date.parse(schedule.createdAt));
59
+ return { target: 'session', runId: schedule.runId, ...(where === 'ended' ? { sessionEnded: true } : {}) };
60
+ };
61
+ /**
62
+ * The schedules on this machine for the app, oldest first, prompts cut to `promptChars`. Null when the directory could
63
+ * not be listed or holds more than a machine may: an empty list would then be a lie.
64
+ */
65
+ const phoneScheduleList = (now, promptChars = exports.LIST_PROMPT_CHARS, tmux = tmux_liveness_js_1.liveTmuxRuns) => {
42
66
  const read = (0, schedule_js_1.listSchedules)();
67
+ const asked = askingOnce(tmux);
43
68
  if (read.incomplete)
44
69
  return null;
45
70
  const state = (0, schedule_js_1.readScheduleState)().schedules;
@@ -57,6 +82,7 @@ const phoneScheduleList = (now, promptChars = exports.LIST_PROMPT_CHARS) => {
57
82
  maxRuntimeMin: schedule.maxRuntimeMin,
58
83
  nextAt: isoOrNull((0, schedule_js_1.nextOccurrence)(schedule.when, now, (0, schedule_js_1.scheduleAnchor)(schedule))),
59
84
  ...(last && lastAt ? { last: { outcome: last.outcome, at: lastAt } } : {}),
85
+ ...targetFields(schedule, now, asked),
60
86
  };
61
87
  });
62
88
  return { schedules, unreadable: read.unreadable };
@@ -67,9 +93,10 @@ exports.phoneScheduleList = phoneScheduleList;
67
93
  * cannot hand an older sealed list back as the answer to a newer request. Prompts are cut shorter, step by
68
94
  * step, until the answer fits a frame; it is an error only when even the list without prompts does not.
69
95
  */
70
- const scheduleAnswer = (now, requestId, at) => {
96
+ const scheduleAnswer = (now, requestId, at, tmux = tmux_liveness_js_1.liveTmuxRuns) => {
97
+ const once = askingOnce(tmux);
71
98
  for (const promptChars of PROMPT_CHARS_STEPS) {
72
- const listing = (0, exports.phoneScheduleList)(now, promptChars);
99
+ const listing = (0, exports.phoneScheduleList)(now, promptChars, once);
73
100
  if (!listing)
74
101
  return { ok: false, error: 'read_failed' };
75
102
  const { schedules, unreadable } = listing;
@@ -85,10 +112,12 @@ exports.livePhoneScheduleDeps = {
85
112
  recall: (session, agentKind) => (agentKind === undefined ? (0, session_registry_js_1.recallSession)(session) : (0, session_registry_js_1.recallRun)(session, agentKind)),
86
113
  directoryExists: session_registry_js_1.sessionDirectoryExists,
87
114
  canPrompt: schedule_ready_js_1.canPromptKind,
115
+ currentRun: tmux_liveness_js_1.liveRunOf,
116
+ recallRunOf: (session, runId, now) => (0, session_registry_js_1.recallSession)(session, now, runId) ?? ((0, session_registry_js_1.registryUnreadable)() ? 'unreadable' : null),
88
117
  };
89
118
  const refuse = (reason, why) => ({ ok: false, reason, why });
90
119
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
91
- const CREATE_KEYS = new Set(['op', 'when', 'prompt', 'agentKind', 'maxRuntimeMin']);
120
+ const CREATE_KEYS = new Set(['op', 'when', 'prompt', 'agentKind', 'maxRuntimeMin', 'target']);
92
121
  /** Stands in for the run time while the rest of a request is checked; the shortest `validateSchedule` takes. */
93
122
  const SHORTEST_RUN_MIN = 5;
94
123
  /** The fields every schedule made from a phone request has, whatever the request says: all but `id` come from this machine's record. */
@@ -102,11 +131,50 @@ const fromRecord = (entry, id, now) => ({
102
131
  label: `sched-${id}`,
103
132
  session: `${(0, wrapper_js_1.tmuxSessionName)((0, wrapper_js_1.safeBasename)(entry.cwd))}-sched-${id}`,
104
133
  });
134
+ /**
135
+ * A create with `target: 'session'`: the schedule types into the addressed session instead of starting one. The run is
136
+ * the one the session holds now — this machine's word, not the phone's — and the agent and directory are that run's
137
+ * record. It has no runtime cap: a run time in the request (the apps always send one) is ignored.
138
+ */
139
+ const createIntoSession = (request, session, deps) => {
140
+ const { when, prompt, agentKind } = request;
141
+ if (agentKind !== undefined)
142
+ return refuse('schedule_invalid', 'a create into a session takes the agent the session runs');
143
+ const runId = deps.currentRun(session);
144
+ if (runId === undefined)
145
+ return refuse('schedule_failed', 'tmux did not give the run of that session');
146
+ if (runId === null)
147
+ return refuse('unknown_session', 'the session is not running');
148
+ const now = deps.now();
149
+ const entry = deps.recallRunOf(session, runId, now);
150
+ if (entry === 'unreadable')
151
+ return refuse('schedule_failed', 'the record of sessions would not read');
152
+ if (!entry)
153
+ return refuse('unknown_session', 'no record of the run that session holds');
154
+ if (!deps.canPrompt(entry.agentKind))
155
+ return refuse('schedule_invalid', `${entry.agentKind} cannot take a scheduled prompt`);
156
+ const checked = (0, schedule_js_1.validateSchedule)({
157
+ v: 1, id: 'aaaaaa', createdAt: new Date(now).toISOString(), source: 'phone', agentKind: entry.agentKind, cwd: entry.cwd,
158
+ label: 'sched-aaaaaa', session, target: 'session', runId, when, prompt, maxRuntimeMin: SHORTEST_RUN_MIN,
159
+ });
160
+ if (!checked.ok)
161
+ return refuse('schedule_invalid', checked.error);
162
+ // Kept for the file's shape: a session target's run time bounds nothing.
163
+ const runtime = (0, schedule_js_1.defaultMaxRuntimeMin)(checked.value.when);
164
+ const added = (0, schedule_js_1.addSchedule)((id) => ({ ...checked.value, id, label: `sched-${id}`, maxRuntimeMin: runtime }));
165
+ if (added.ok)
166
+ return { ok: true };
167
+ return refuse(added.failure === 'limit' ? 'schedule_limit' : added.failure === 'io' ? 'schedule_failed' : 'schedule_invalid', added.error);
168
+ };
105
169
  const create = (request, session, deps) => {
106
170
  // Whatever else the request carries — a directory, arguments, a label — is refused, not ignored: it was meant to be used.
107
171
  const extra = Object.keys(request).find((k) => !CREATE_KEYS.has(k));
108
172
  if (extra !== undefined)
109
173
  return refuse('schedule_invalid', `a create carries ${extra}`);
174
+ if (request.target !== undefined && request.target !== 'session')
175
+ return refuse('schedule_invalid', "target is 'session' or absent");
176
+ if (request.target === 'session')
177
+ return createIntoSession(request, session, deps);
110
178
  const { when, prompt, agentKind, maxRuntimeMin } = request;
111
179
  if (agentKind !== undefined && typeof agentKind !== 'string')
112
180
  return refuse('schedule_invalid', 'agentKind is not text');
@@ -180,7 +248,8 @@ const update = (request, session, deps) => {
180
248
  change.when = when;
181
249
  if (prompt !== undefined)
182
250
  change.prompt = prompt;
183
- if (maxRuntimeMin !== undefined)
251
+ // The apps always send a run time; a session target has none to change, and keeps the one in its file.
252
+ if (maxRuntimeMin !== undefined && target.target !== 'session')
184
253
  change.maxRuntimeMin = maxRuntimeMin;
185
254
  const updated = (0, schedule_js_1.updateSchedule)(id, change, deps.now());
186
255
  if (updated.ok)
@@ -190,7 +259,7 @@ const update = (request, session, deps) => {
190
259
  };
191
260
  /**
192
261
  * Carry out one change the phone asked for: `{ op: 'create', when, prompt, agentKind?, maxRuntimeMin? }` for the
193
- * recorded session `session`, `{ op: 'update', id, when?, prompt?, maxRuntimeMin? }` or `{ op: 'remove', id }` for the
262
+ * recorded session `session` (or `{ op: 'create', target: 'session', when, prompt }` to type into it), `{ op: 'update', id, when?, prompt?, maxRuntimeMin? }` or `{ op: 'remove', id }` for the
194
263
  * schedule whose session that is. Nothing here
195
264
  * types into a session. `request` is whatever the phone sealed, so every field is checked as it is used.
196
265
  */
@@ -5,14 +5,25 @@
5
5
  *
6
6
  * What a tick decides, per schedule: how many due times passed since its cursor, whether the newest is still
7
7
  * worth running (`GRACE_MS`; older ones are `missed` and never run late), and whether the session may start.
8
+ * A `once` schedule that has run (or was missed), and whose session and report are done with, has its file removed.
9
+ *
10
+ * A session target (`target: 'session'`) starts nothing: at its time it waits (`delivering`) for the run it is bound to
11
+ * to be idle with nobody typing at its terminal, and types the prompt in. It never counts as running, has no runtime
12
+ * cap and is never asked to end — the session is the person's, not the schedule's.
8
13
  * The cursor is written before anything is launched, so a listener killed in between skips that run instead of
9
14
  * starting it twice.
10
15
  */
11
- import { type Schedule, type ScheduleList, type ScheduleStateFile } from './schedule.js';
16
+ import type { RunWhereabouts } from './session-registry.js';
17
+ import { type RemoveResult, type Schedule, type ScheduleList, type ScheduleStateFile } from './schedule.js';
12
18
  /** How often the listener ticks. */
13
19
  export declare const TICK_MS = 30000;
14
20
  /** A due time older than this when the tick sees it (the machine slept, the listener was down) is missed, not run. */
15
21
  export declare const GRACE_MS = 120000;
22
+ /**
23
+ * The same for a `once` schedule: there is no next time to wait for, and a person who asked for "at 3 pm" still wants
24
+ * it at 3:20 after the laptop woke up.
25
+ */
26
+ export declare const ONCE_GRACE_MS: number;
16
27
  /** After a launch exits 0, how long before the session has to be there. */
17
28
  export declare const START_CHECK_MS = 2000;
18
29
  /** A launch that has not exited by then is given up on. */
@@ -30,6 +41,10 @@ export declare const PROMPT_POLL_MS = 5000;
30
41
  * the session is idle, and a retry after a failed inject two polls later.
31
42
  */
32
43
  export declare const PROMPT_WAIT_MS = 90000;
44
+ /** How long a session target's prompt waits for its session to be idle (and its terminal quiet) before it is `session_busy`. */
45
+ export declare const SESSION_WAIT_MS: number;
46
+ /** A terminal attached to the session that had a key pressed this recently is someone typing: the prompt is not typed after their words. */
47
+ export declare const SESSION_QUIET_MS: number;
33
48
  /** Scheduled sessions running at once. */
34
49
  export declare const MAX_RUNNING = 3;
35
50
  export type LaunchResult = {
@@ -71,6 +86,15 @@ export interface ScheduleRunnerDeps {
71
86
  endSession: (session: string) => void;
72
87
  /** Tells the person (title, body). */
73
88
  notify: (title: string, body: string) => Promise<NotifyOutcome>;
89
+ /** Where the run `runId` (last seen in session `lastName`, tied to at `boundAt`) goes on now (`liveSessionOfRun`). */
90
+ sessionOfRun: (runId: string, lastName: string, boundAt: number) => RunWhereabouts;
91
+ /**
92
+ * When a terminal attached to `session` was last used (ms since the epoch); null when none is attached, undefined
93
+ * when tmux could not say.
94
+ */
95
+ lastClientActivity: (session: string) => number | null | undefined;
96
+ /** Deletes a schedule's file (`removeSchedule`): a `once` that is done. It may throw when the file cannot be removed. */
97
+ removeSchedule: (id: string) => RemoveResult;
74
98
  /** The wall clock (default `Date.now`); a launch takes seconds, and the schedules after it are judged by the time it ends. */
75
99
  clock?: () => number;
76
100
  }
@@ -6,16 +6,26 @@
6
6
  *
7
7
  * What a tick decides, per schedule: how many due times passed since its cursor, whether the newest is still
8
8
  * worth running (`GRACE_MS`; older ones are `missed` and never run late), and whether the session may start.
9
+ * A `once` schedule that has run (or was missed), and whose session and report are done with, has its file removed.
10
+ *
11
+ * A session target (`target: 'session'`) starts nothing: at its time it waits (`delivering`) for the run it is bound to
12
+ * to be idle with nobody typing at its terminal, and types the prompt in. It never counts as running, has no runtime
13
+ * cap and is never asked to end — the session is the person's, not the schedule's.
9
14
  * The cursor is written before anything is launched, so a listener killed in between skips that run instead of
10
15
  * starting it twice.
11
16
  */
12
17
  Object.defineProperty(exports, "__esModule", { value: true });
13
- exports.createScheduleRunner = exports.launchArgs = exports.notifyFailureOutcome = exports.MAX_RUNNING = exports.PROMPT_WAIT_MS = exports.PROMPT_POLL_MS = exports.PUSH_RETRY_MAX_MS = exports.PUSH_RETRY_MS = exports.LAUNCH_WT_TIMEOUT_MS = exports.LAUNCH_TIMEOUT_MS = exports.START_CHECK_MS = exports.GRACE_MS = exports.TICK_MS = void 0;
18
+ exports.createScheduleRunner = exports.launchArgs = exports.notifyFailureOutcome = exports.MAX_RUNNING = exports.SESSION_QUIET_MS = exports.SESSION_WAIT_MS = exports.PROMPT_WAIT_MS = exports.PROMPT_POLL_MS = exports.PUSH_RETRY_MAX_MS = exports.PUSH_RETRY_MS = exports.LAUNCH_WT_TIMEOUT_MS = exports.LAUNCH_TIMEOUT_MS = exports.START_CHECK_MS = exports.ONCE_GRACE_MS = exports.GRACE_MS = exports.TICK_MS = void 0;
14
19
  const schedule_js_1 = require("./schedule.js");
15
20
  /** How often the listener ticks. */
16
21
  exports.TICK_MS = 30_000;
17
22
  /** A due time older than this when the tick sees it (the machine slept, the listener was down) is missed, not run. */
18
23
  exports.GRACE_MS = 120_000;
24
+ /**
25
+ * The same for a `once` schedule: there is no next time to wait for, and a person who asked for "at 3 pm" still wants
26
+ * it at 3:20 after the laptop woke up.
27
+ */
28
+ exports.ONCE_GRACE_MS = 30 * 60_000;
19
29
  /** After a launch exits 0, how long before the session has to be there. */
20
30
  exports.START_CHECK_MS = 2_000;
21
31
  /** A launch that has not exited by then is given up on. */
@@ -33,6 +43,10 @@ exports.PROMPT_POLL_MS = 5_000;
33
43
  * the session is idle, and a retry after a failed inject two polls later.
34
44
  */
35
45
  exports.PROMPT_WAIT_MS = 90_000;
46
+ /** How long a session target's prompt waits for its session to be idle (and its terminal quiet) before it is `session_busy`. */
47
+ exports.SESSION_WAIT_MS = 30 * 60_000;
48
+ /** A terminal attached to the session that had a key pressed this recently is someone typing: the prompt is not typed after their words. */
49
+ exports.SESSION_QUIET_MS = 3 * 60_000;
36
50
  /** A report that has waited this long for a push that would not go through is dropped, not sent as news. */
37
51
  const PENDING_MAX_MS = 24 * 60 * 60 * 1000;
38
52
  /** Scheduled sessions running at once. */
@@ -70,6 +84,8 @@ const REASON_TEXT = {
70
84
  prompt_undelivered: '할 일 입력 못 함',
71
85
  prompt_unsupported: '할 일 입력 불가(이 에이전트는 입력 준비를 판정할 규칙이 없음)',
72
86
  capped: '실행 시간 초과',
87
+ session_busy: '세션이 계속 작업 중이라 건너뜀',
88
+ session_ended: '세션 종료됨',
73
89
  };
74
90
  /** The push title for each result: what kind of news it is. A report that mixes kinds gets the general one. */
75
91
  const SKIPPED = '예약 건너뜀';
@@ -84,6 +100,8 @@ const TITLE_OF = {
84
100
  prompt_undelivered: PROMPT_NOT_TYPED,
85
101
  prompt_unsupported: PROMPT_NOT_TYPED,
86
102
  capped: '예약 실행 시간 초과',
103
+ session_busy: SKIPPED,
104
+ session_ended: '예약 세션 종료됨',
87
105
  };
88
106
  const MIXED_TITLE = '예약 알림';
89
107
  /** Adds `count` of `outcome` to what the person has not been told yet. */
@@ -125,6 +143,50 @@ const createScheduleRunner = (deps) => {
125
143
  const launchedBefore = new Map();
126
144
  // Sessions already asked to end for their runtime cap, so a state file that cannot be written does not ask again every tick.
127
145
  const cappedAsked = new Set();
146
+ // `once` schedules whose file could not be removed, so the log says it once, not every tick.
147
+ const unremovable = new Set();
148
+ /**
149
+ * Removes the `once` schedules that are done: their time has been dealt with (run, failed or missed), their session is
150
+ * no longer running and their report has gone out. Until then the schedule stays, so its prompt, its runtime cap and
151
+ * its push are still looked after. Its cursor is past its time, so one that cannot be removed is never run again.
152
+ * A cursor can also pass the time without a run judging it (a damaged state file starts from now): that is told as
153
+ * missed first, never removed in silence.
154
+ */
155
+ const removeFinished = (schedules, state, now) => {
156
+ let changed = false;
157
+ const live = schedules.filter((schedule) => {
158
+ const st = state[schedule.id];
159
+ const passed = schedule.when.kind === 'once' && st.cursor >= Date.parse(schedule.when.at);
160
+ if (!passed || st.running || st.delivering || st.pending)
161
+ return true;
162
+ if (!st.last) {
163
+ st.last = { outcome: 'missed', at: now };
164
+ addPending(st, 'missed', 1, now);
165
+ changed = true;
166
+ deps.log(`schedule ${schedule.id} (${schedule.label}): its time went by without a record; missed`);
167
+ return true;
168
+ }
169
+ let removed;
170
+ try {
171
+ removed = deps.removeSchedule(schedule.id);
172
+ }
173
+ catch (e) {
174
+ removed = e instanceof Error ? e : new Error(String(e));
175
+ }
176
+ if (removed instanceof Error || removed === 'invalid') {
177
+ if (!unremovable.has(schedule.id))
178
+ deps.log(`! schedule ${schedule.id} (${schedule.label}): ran once, but could not remove it: ${removed instanceof Error ? removed.message : 'not a schedule id'}`);
179
+ unremovable.add(schedule.id);
180
+ return true;
181
+ }
182
+ unremovable.delete(schedule.id);
183
+ delete state[schedule.id];
184
+ deps.log(`schedule ${schedule.id} (${schedule.label}): ran once, removed`);
185
+ changed = true;
186
+ return false;
187
+ });
188
+ return { live, changed };
189
+ };
128
190
  const load = (tickNow) => {
129
191
  const { schedules, unreadable, incomplete } = deps.listSchedules();
130
192
  const file = deps.readState();
@@ -191,13 +253,41 @@ const createScheduleRunner = (deps) => {
191
253
  sentReports = [];
192
254
  pushing = false;
193
255
  }
256
+ const { live, changed } = removeFinished(schedules, state, tickNow);
257
+ if (changed)
258
+ dirty = true;
194
259
  const save = () => {
195
260
  const saved = deps.writeState({ v: 1, schedules: state });
196
261
  if (!saved)
197
262
  deps.log('! schedules: could not write the state file');
198
263
  return saved;
199
264
  };
200
- return { schedules, state, dirty, save };
265
+ return { schedules: live, state, dirty, save };
266
+ };
267
+ /** The end of a session target's time: what became of it, told unless it is the same as the last time (a streak). */
268
+ const settle = (st, outcome, now) => {
269
+ delete st.delivering;
270
+ // As with overlap and busy: a run that is over, or never idle, stays so for every time until someone acts.
271
+ const repeat = (outcome === 'session_busy' || outcome === 'session_ended') && st.last?.outcome === outcome;
272
+ st.last = { outcome, at: now };
273
+ if (outcome !== 'started' && !repeat)
274
+ addPending(st, outcome, 1, now);
275
+ };
276
+ /** A session target's time came: its wait begins, or (when it cannot) what that means. Returns what to log. */
277
+ const beginDelivery = (schedule, st, now) => {
278
+ if (!deps.canPrompt(schedule.agentKind)) {
279
+ settle(st, 'prompt_unsupported', now);
280
+ return `${schedule.agentKind} has no rule that says idle, so its prompt is not typed`;
281
+ }
282
+ // The last time's prompt is still waiting: this one is not queued behind it.
283
+ if (st.delivering) {
284
+ const { delivering } = st;
285
+ settle(st, 'session_busy', now);
286
+ st.delivering = delivering;
287
+ return 'session_busy (the last prompt is still waiting)';
288
+ }
289
+ st.delivering = { session: schedule.session, since: now };
290
+ return `waiting for ${schedule.session} to be idle`;
201
291
  };
202
292
  const run = async (tickNow) => {
203
293
  const tickStart = clock();
@@ -212,7 +302,7 @@ const createScheduleRunner = (deps) => {
212
302
  const due = (0, schedule_js_1.occurrencesBetween)(schedule.when, st.cursor, now, (0, schedule_js_1.scheduleAnchor)(schedule));
213
303
  if (due.count === 0 || due.latest === null)
214
304
  continue;
215
- const runs = now - due.latest <= exports.GRACE_MS;
305
+ const runs = now - due.latest <= (schedule.when.kind === 'once' ? exports.ONCE_GRACE_MS : exports.GRACE_MS);
216
306
  const missed = runs ? due.count - 1 : due.count;
217
307
  const before = structuredClone(st);
218
308
  st.cursor = due.latest;
@@ -221,6 +311,9 @@ const createScheduleRunner = (deps) => {
221
311
  st.last = { outcome: 'missed', at: now };
222
312
  addPending(st, 'missed', missed, now);
223
313
  }
314
+ const intoSession = runs && schedule.target === 'session';
315
+ // Saved with the cursor: a wait that is on disk is never begun twice, nor lost.
316
+ const begun = intoSession ? beginDelivery(schedule, st, now) : null;
224
317
  if (!save()) {
225
318
  state[schedule.id] = before;
226
319
  continue;
@@ -228,7 +321,9 @@ const createScheduleRunner = (deps) => {
228
321
  loaded.dirty = false;
229
322
  if (missed > 0)
230
323
  deps.log(`schedule ${schedule.id} (${schedule.label}): missed ${missed} run${missed === 1 ? '' : 's'}`);
231
- if (runs)
324
+ if (begun)
325
+ deps.log(`schedule ${schedule.id} (${schedule.label}): ${begun}`);
326
+ if (runs && !intoSession)
232
327
  toStart.push({ schedule, st, before, at: now });
233
328
  }
234
329
  for (const { schedule, st, before, at: now } of toStart) {
@@ -302,11 +397,95 @@ const createScheduleRunner = (deps) => {
302
397
  };
303
398
  // Prompts typed whose "done" could not be written (`session@startedAt`); in memory, so only for this listener's life.
304
399
  const delivered = new Set();
305
- // Whether any running session is still waiting for its prompt: `poll` costs nothing while none is.
400
+ // Whether any prompt is still waiting (a started session's or a session target's): `poll` costs nothing while none is.
306
401
  let watching = true;
307
- /** Delivers the prompts of running sessions and ends those past their runtime cap, then saves what changed. */
402
+ // Why each session target's wait has not typed yet (`<id>@<since>`), for the log and outcome when it runs out. In
403
+ // memory: after a restart the reason starts over, which only changes the log line.
404
+ const waitNotes = new Map();
405
+ /**
406
+ * Types the prompts of session targets into their runs: when the run is idle and nobody has used a terminal attached
407
+ * to it for `SESSION_QUIET_MS`. One prompt per session per pass, the earliest due first — the next waits for the
408
+ * session to be idle again. A run that is over fails the wait; one tmux cannot find right now is waited for.
409
+ */
410
+ const deliver = (loaded, now) => {
411
+ const { schedules, state } = loaded;
412
+ const typedInto = new Set();
413
+ const waiting = schedules
414
+ .filter((schedule) => state[schedule.id].delivering)
415
+ .sort((a, b) => state[a.id].cursor - state[b.id].cursor || a.id.localeCompare(b.id));
416
+ for (const schedule of waiting) {
417
+ const st = state[schedule.id];
418
+ const delivering = st.delivering;
419
+ const label = `schedule ${schedule.id} (${schedule.label})`;
420
+ const deliveredKey = `${schedule.id}@${delivering.since}`;
421
+ if (delivered.has(deliveredKey)) {
422
+ delete st.delivering;
423
+ loaded.dirty = true;
424
+ continue;
425
+ }
426
+ if (schedule.prompt === undefined || schedule.runId === undefined) {
427
+ // `validateSchedule` gives every session target both; a wait without them has nothing to type.
428
+ deps.log(`! ${label}: a session target without a prompt or a run; its wait is dropped`);
429
+ delete st.delivering;
430
+ loaded.dirty = true;
431
+ continue;
432
+ }
433
+ const note = waitNotes.get(deliveredKey) ?? { reason: 'it was not idle' };
434
+ waitNotes.set(deliveredKey, note);
435
+ // Asked before the deadline: a run that ended while its prompt waited is over, not busy.
436
+ const where = deps.sessionOfRun(schedule.runId, delivering.session, Date.parse(schedule.createdAt));
437
+ if (where === 'ended') {
438
+ waitNotes.delete(deliveredKey);
439
+ settle(st, 'session_ended', now);
440
+ deps.log(`${label}: the session it types into has ended`);
441
+ saveNow(loaded);
442
+ continue;
443
+ }
444
+ if (now - delivering.since > exports.SESSION_WAIT_MS) {
445
+ waitNotes.delete(deliveredKey);
446
+ settle(st, note.injectFailed ? 'prompt_undelivered' : 'session_busy', now);
447
+ deps.log(`${label}: not typed within ${exports.SESSION_WAIT_MS / 60_000} min (${note.reason}); skipped`);
448
+ saveNow(loaded);
449
+ continue;
450
+ }
451
+ if (where === 'unknown') {
452
+ note.reason = 'tmux could not say where the run is';
453
+ continue;
454
+ }
455
+ if (where.name !== delivering.session) {
456
+ delivering.session = where.name;
457
+ loaded.dirty = true;
458
+ }
459
+ if (typedInto.has(where.name))
460
+ continue;
461
+ const used = deps.lastClientActivity(where.name);
462
+ if (used === undefined || (used !== null && now - used < exports.SESSION_QUIET_MS)) {
463
+ note.reason = used === undefined ? 'tmux could not say who uses its terminal' : 'someone was typing at its terminal';
464
+ continue;
465
+ }
466
+ if (!deps.ready(where.name, schedule.agentKind)) {
467
+ note.reason = 'it was not idle';
468
+ continue;
469
+ }
470
+ if (!deps.inject(where.name, schedule.prompt)) {
471
+ // Rate limit, no pane …: the next poll tries again, and the wait ends as undelivered, not busy.
472
+ note.reason = 'the prompt would not go in';
473
+ note.injectFailed = true;
474
+ continue;
475
+ }
476
+ waitNotes.delete(deliveredKey);
477
+ typedInto.add(where.name);
478
+ settle(st, 'started', now);
479
+ deps.log(`${label}: prompt typed into ${where.name}`);
480
+ // On disk right away, as for a started session's prompt: a restart must not type it again.
481
+ if (!saveNow(loaded))
482
+ delivered.add(deliveredKey);
483
+ }
484
+ };
485
+ /** Delivers the prompts of running sessions and session targets, ends sessions past their runtime cap, then saves what changed. */
308
486
  const supervise = (loaded, now) => {
309
487
  const { schedules, state } = loaded;
488
+ deliver(loaded, now);
310
489
  for (const schedule of schedules) {
311
490
  const st = state[schedule.id];
312
491
  const running = st.running;
@@ -370,7 +549,7 @@ const createScheduleRunner = (deps) => {
370
549
  saveNow(loaded);
371
550
  }
372
551
  }
373
- watching = Object.values(state).some((st) => st.running?.promptPending);
552
+ watching = Object.values(state).some((st) => st.running?.promptPending || st.delivering);
374
553
  if (loaded.dirty)
375
554
  loaded.save();
376
555
  };
@@ -2,8 +2,8 @@
2
2
  * Scheduled sessions (F8): what a schedule is, when it comes due, and the files it lives in.
3
3
  *
4
4
  * A schedule is one file, `<stateDir>/schedules/<id>.json` (0600). `hwip schedule` writes and
5
- * deletes them, the listener only reads them, and nothing reads-then-rewrites a file, so the two
6
- * never lose each other's change. What the listener has done about each one lives in its own file
5
+ * deletes them, the listener only reads them — except a `once` schedule that has run, which the listener
6
+ * 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
7
7
  * (`schedule-state.json`, `schedule-runner.ts`).
8
8
  *
9
9
  * Everything here is pure or touches only that directory. The clock is a parameter.
@@ -15,6 +15,11 @@ export type ScheduleWhen = {
15
15
  } | {
16
16
  kind: 'every';
17
17
  minutes: number;
18
+ }
19
+ /** Runs one time, at `at` (an ISO 8601 instant), and is removed after (`schedule-runner.ts`). */
20
+ | {
21
+ kind: 'once';
22
+ at: string;
18
23
  };
19
24
  export interface Schedule {
20
25
  v: 1;
@@ -29,8 +34,18 @@ export interface Schedule {
29
34
  cwd: string;
30
35
  /** Passed to `hwip <agent> --label`; the default is `sched-<id>`. */
31
36
  label: string;
32
- /** The tmux session the launch will create, worked out when the schedule is made. */
37
+ /**
38
+ * The tmux session the launch will create, worked out when the schedule is made. For a `target: 'session'` schedule,
39
+ * the session the run was in when the schedule was made: where the runner looks for it first.
40
+ */
33
41
  session: string;
42
+ /**
43
+ * `session`: no session is started; at its time the prompt is typed into the run `runId` is, wherever it goes on
44
+ * (`liveSessionOfRun`). Absent: a new session is started (`hwip <agent> --detach`). Fixed once made.
45
+ */
46
+ target?: 'session';
47
+ /** The run (hwip docs/session-runs.md) a `target: 'session'` schedule types into, and only with it. */
48
+ runId?: string;
34
49
  /** `--wt <branch>`. From the command line only. */
35
50
  wt?: string;
36
51
  /** Arguments after `--`. From the command line only. */
@@ -39,6 +54,12 @@ export interface Schedule {
39
54
  when: ScheduleWhen;
40
55
  maxRuntimeMin: number;
41
56
  }
57
+ /**
58
+ * Advertised in the listener's presence (`device-presence.ts`): this build runs `once` schedules and schedules into a
59
+ * session (`target: 'session'`). An older listener refuses both as fields it does not know, so the apps offer them only
60
+ * to a computer that lists this.
61
+ */
62
+ export declare const SCHEDULE_SESSION_CAPABILITY = "schedule-session-v1";
42
63
  export type Checked<T> = {
43
64
  ok: true;
44
65
  value: T;
@@ -54,11 +75,12 @@ export declare const ALL_DAYS: readonly number[];
54
75
  export declare const WEEKDAYS: readonly number[];
55
76
  /** `<N>m` or `<N>h` as minutes; `example` is what the error shows for a value that is neither. */
56
77
  export declare const parseDuration: (raw: string, flag: string, example: string) => Checked<number>;
57
- /** `--at HH:MM [--days …]` or `--every <N>m|<N>h`, not both and not neither. */
78
+ /** `--at HH:MM [--days …]`, `--on YYYY-MM-DD --at HH:MM` or `--every <N>m|<N>h`, one of them and not neither. */
58
79
  export declare const parseWhen: (input: {
59
80
  at?: string;
60
81
  days?: string;
61
82
  every?: string;
83
+ on?: string;
62
84
  }) => Checked<ScheduleWhen>;
63
85
  export interface Occurrences {
64
86
  count: number;
@@ -75,7 +97,7 @@ export interface Occurrences {
75
97
  * the clock repeats (fall) happens once, the first time round.
76
98
  */
77
99
  export declare const occurrencesBetween: (when: ScheduleWhen, after: number, until: number, anchor: number) => Occurrences;
78
- /** The first time after `after` the schedule comes due; null for a `daily` with no days (a file `validateSchedule` would refuse). */
100
+ /** 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). */
79
101
  export declare const nextOccurrence: (when: ScheduleWhen, after: number, anchor: number) => number | null;
80
102
  export declare const isScheduleId: (value: unknown) => value is string;
81
103
  /**
@@ -134,12 +156,14 @@ export type UpdateResult = {
134
156
  * missed. A change of the prompt or the run time only keeps the count where it was — an hourly run stays on its hour.
135
157
  */
136
158
  export declare const updateSchedule: (id: string, change: UncheckedScheduleChange, now: number) => UpdateResult;
159
+ /** Why `when` cannot be given to a schedule at `now`: a `once` whose time has passed would never run. Null when it can. */
160
+ export declare const pastWhen: (when: ScheduleWhen, now: number) => string | null;
137
161
  /** The time a schedule's runs are counted from: its last edit, else its creation. */
138
162
  export declare const scheduleAnchor: (schedule: Schedule) => number;
139
163
  /** `invalid` means the id could not name a schedule file; nothing was touched. */
140
164
  export type RemoveResult = 'removed' | 'missing' | 'invalid';
141
165
  export declare const removeSchedule: (id: string) => RemoveResult;
142
- declare const SCHEDULE_OUTCOMES: readonly ["started", "missed", "overlap", "busy", "start_failed", "missing_directory", "unknown_agent", "prompt_undelivered", "prompt_unsupported", "capped"];
166
+ declare const SCHEDULE_OUTCOMES: readonly ["started", "missed", "overlap", "busy", "start_failed", "missing_directory", "unknown_agent", "prompt_undelivered", "prompt_unsupported", "capped", "session_busy", "session_ended"];
143
167
  export type ScheduleOutcome = (typeof SCHEDULE_OUTCOMES)[number];
144
168
  /** Per schedule, in `schedule-state.json`. Only the listener writes it (`schedule-runner.ts`); `hwip schedule list` reads it. */
145
169
  export interface ScheduleRunState {
@@ -152,6 +176,14 @@ export interface ScheduleRunState {
152
176
  promptPending: boolean;
153
177
  capped?: true;
154
178
  };
179
+ /**
180
+ * A session target whose time came and whose prompt waits for its session to be idle. `session` is where the run was
181
+ * last seen; `since` is when the wait began, and the wait is given up on 30 minutes after it (`schedule-runner.ts`).
182
+ */
183
+ delivering?: {
184
+ session: string;
185
+ since: number;
186
+ };
155
187
  last?: {
156
188
  outcome: ScheduleOutcome;
157
189
  at: number;