@hwip/cli 0.1.4 → 0.1.6

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.
@@ -25,6 +25,12 @@ const clip = (text, max) => {
25
25
  const last = cut.charCodeAt(cut.length - 1);
26
26
  return `${last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut}…`;
27
27
  };
28
+ const listedPrompt = (schedule, max) => {
29
+ const { prompt } = schedule;
30
+ const listed = prompt !== undefined && max > 0 ? clip(prompt, max) : undefined;
31
+ const locked = listed !== prompt || schedule.agentArgs !== undefined;
32
+ return { ...(listed === undefined ? {} : { prompt: listed }), ...(locked ? { promptLocked: true } : {}) };
33
+ };
28
34
  const isoOrNull = (ms) => {
29
35
  if (ms === null)
30
36
  return null;
@@ -47,9 +53,9 @@ const phoneScheduleList = (now, promptChars = exports.LIST_PROMPT_CHARS) => {
47
53
  project: (0, node_path_1.basename)(schedule.cwd),
48
54
  session: schedule.session,
49
55
  when: schedule.when,
50
- ...(schedule.prompt !== undefined && promptChars > 0 ? { prompt: clip(schedule.prompt, promptChars) } : {}),
56
+ ...listedPrompt(schedule, promptChars),
51
57
  maxRuntimeMin: schedule.maxRuntimeMin,
52
- nextAt: isoOrNull((0, schedule_js_1.nextOccurrence)(schedule.when, now, Date.parse(schedule.createdAt))),
58
+ nextAt: isoOrNull((0, schedule_js_1.nextOccurrence)(schedule.when, now, (0, schedule_js_1.scheduleAnchor)(schedule))),
53
59
  ...(last && lastAt ? { last: { outcome: last.outcome, at: lastAt } } : {}),
54
60
  };
55
61
  });
@@ -144,9 +150,48 @@ const remove = (request, session) => {
144
150
  const removed = (0, schedule_js_1.removeSchedule)(id);
145
151
  return removed === 'invalid' ? refuse('schedule_invalid', 'not a schedule id') : { ok: true };
146
152
  };
153
+ const UPDATE_KEYS = new Set(['op', 'id', 'when', 'prompt', 'maxRuntimeMin']);
154
+ const update = (request, session, deps) => {
155
+ const extra = Object.keys(request).find((k) => !UPDATE_KEYS.has(k));
156
+ if (extra !== undefined)
157
+ return refuse('schedule_invalid', `an update carries ${extra}`);
158
+ const { id, when, prompt, maxRuntimeMin } = request;
159
+ if (typeof id !== 'string')
160
+ return refuse('schedule_invalid', 'an update needs an id');
161
+ const read = (0, schedule_js_1.listSchedules)();
162
+ if (read.incomplete)
163
+ return refuse('schedule_failed', 'the schedules could not be listed');
164
+ // As for a remove: the push names the schedule's own session, so it cannot change another one.
165
+ const target = read.schedules.find((schedule) => schedule.id === id);
166
+ if (!target || target.session !== session)
167
+ return refuse('schedule_invalid', 'no such schedule for that session');
168
+ // A prompt the list cut is left out by the app, so a missing one keeps the file's whole text.
169
+ // Arguments the computer gave (a permissions bypass, say) were chosen for its own prompt: the phone cannot put another under them.
170
+ if (prompt !== undefined && target.agentArgs !== undefined)
171
+ return refuse('schedule_invalid', 'a schedule with agent arguments takes its prompt from hwip schedule edit');
172
+ // As `hwip schedule edit`: a prompt of spaces would be typed as nothing.
173
+ if (typeof prompt === 'string' && prompt.trim() === '')
174
+ return refuse('schedule_invalid', 'the prompt is empty');
175
+ if (prompt !== undefined && !deps.canPrompt(target.agentKind))
176
+ return refuse('schedule_invalid', 'that agent cannot take a scheduled prompt');
177
+ // Unchecked here: `updateSchedule` validates the whole schedule they make before it writes.
178
+ const change = {};
179
+ if (when !== undefined)
180
+ change.when = when;
181
+ if (prompt !== undefined)
182
+ change.prompt = prompt;
183
+ if (maxRuntimeMin !== undefined)
184
+ change.maxRuntimeMin = maxRuntimeMin;
185
+ const updated = (0, schedule_js_1.updateSchedule)(id, change, deps.now());
186
+ if (updated.ok)
187
+ return { ok: true };
188
+ // Gone between the list and the write is the same as never there for this session.
189
+ return refuse(updated.failure === 'io' ? 'schedule_failed' : 'schedule_invalid', updated.error);
190
+ };
147
191
  /**
148
192
  * Carry out one change the phone asked for: `{ op: 'create', when, prompt, agentKind?, maxRuntimeMin? }` for the
149
- * recorded session `session`, or `{ op: 'remove', id }` for the schedule whose session that is. Nothing here
193
+ * recorded session `session`, `{ op: 'update', id, when?, prompt?, maxRuntimeMin? }` or `{ op: 'remove', id }` for the
194
+ * schedule whose session that is. Nothing here
150
195
  * types into a session. `request` is whatever the phone sealed, so every field is checked as it is used.
151
196
  */
152
197
  const applyPhoneSchedule = (request, session, deps = exports.livePhoneScheduleDeps) => {
@@ -155,6 +200,8 @@ const applyPhoneSchedule = (request, session, deps = exports.livePhoneScheduleDe
155
200
  try {
156
201
  if (request.op === 'create')
157
202
  return create(request, session, deps);
203
+ if (request.op === 'update')
204
+ return update(request, session, deps);
158
205
  if (request.op === 'remove')
159
206
  return remove(request, session);
160
207
  }
@@ -162,6 +209,6 @@ const applyPhoneSchedule = (request, session, deps = exports.livePhoneScheduleDe
162
209
  // A file that would not read or write is this machine's trouble, not a mistake in the request.
163
210
  return refuse('schedule_failed', err instanceof Error ? err.message : String(err));
164
211
  }
165
- return refuse('schedule_invalid', 'op is neither create nor remove');
212
+ return refuse('schedule_invalid', 'op is not create, update or remove');
166
213
  };
167
214
  exports.applyPhoneSchedule = applyPhoneSchedule;
@@ -142,7 +142,10 @@ const createScheduleRunner = (deps) => {
142
142
  for (const schedule of schedules) {
143
143
  // No record of a schedule in a damaged file may mean "nothing ran" or "the record was lost": start from now, so
144
144
  // neither a pile of old runs is counted as missed nor a run that already happened is started again.
145
- state[schedule.id] = previous[schedule.id] ?? { cursor: file.damaged ? tickNow : Date.parse(schedule.createdAt) };
145
+ // An edit restarts the count: the times before it belong to the schedule as it was.
146
+ const prior = previous[schedule.id];
147
+ const anchor = (0, schedule_js_1.scheduleAnchor)(schedule);
148
+ state[schedule.id] = { ...prior, cursor: Math.max(prior?.cursor ?? (file.damaged ? tickNow : anchor), anchor) };
146
149
  }
147
150
  // A state entry whose schedule file is gone is dropped — but only when the list is whole: a file that could not be
148
151
  // read this time is still there, and losing its cursor would repeat or miscount runs. A session that ended on its own is no longer running.
@@ -206,7 +209,7 @@ const createScheduleRunner = (deps) => {
206
209
  for (const schedule of schedules) {
207
210
  const st = state[schedule.id];
208
211
  const now = tickNow + (clock() - tickStart);
209
- const due = (0, schedule_js_1.occurrencesBetween)(schedule.when, st.cursor, now, Date.parse(schedule.createdAt));
212
+ const due = (0, schedule_js_1.occurrencesBetween)(schedule.when, st.cursor, now, (0, schedule_js_1.scheduleAnchor)(schedule));
210
213
  if (due.count === 0 || due.latest === null)
211
214
  continue;
212
215
  const runs = now - due.latest <= exports.GRACE_MS;
@@ -21,6 +21,8 @@ export interface Schedule {
21
21
  /** Lowercase base36, 6 characters. It becomes a file name, so nothing else is accepted. */
22
22
  id: string;
23
23
  createdAt: string;
24
+ /** Set by `updateSchedule` when an edit changes `when`: the schedule counts its runs from here, not from `createdAt` (see `scheduleAnchor`). */
25
+ updatedAt?: string;
24
26
  source: 'cli' | 'phone';
25
27
  /** `REMOTE_AGENTS` kind. Checked against the table when it runs, not here: a hand-edited file is the runner's to refuse. */
26
28
  agentKind: string;
@@ -75,6 +77,7 @@ export interface Occurrences {
75
77
  export declare const occurrencesBetween: (when: ScheduleWhen, after: number, until: number, anchor: number) => Occurrences;
76
78
  /** The first time after `after` the schedule comes due; null for a `daily` with no days (a file `validateSchedule` would refuse). */
77
79
  export declare const nextOccurrence: (when: ScheduleWhen, after: number, anchor: number) => number | null;
80
+ export declare const isScheduleId: (value: unknown) => value is string;
78
81
  /**
79
82
  * Whether `input` is a schedule this machine will run. The file reader, `hwip schedule add` and (in
80
83
  * the remote PR) the phone's request all pass through here, so a limit lives in one place. Unknown
@@ -105,6 +108,34 @@ export type AddResult = {
105
108
  * is drawn.
106
109
  */
107
110
  export declare const addSchedule: (make: (id: string) => Schedule, newId?: () => string) => AddResult;
111
+ /** What an edit may change. Everything else — the session, the directory, the agent — is what the schedule is. */
112
+ export interface ScheduleChange {
113
+ when?: ScheduleWhen;
114
+ prompt?: string;
115
+ maxRuntimeMin?: number;
116
+ }
117
+ /** A change as it arrives from outside (the phone): its values are checked with the schedule they make. */
118
+ export type UncheckedScheduleChange = {
119
+ [K in keyof ScheduleChange]?: unknown;
120
+ };
121
+ /** `missing` — no such schedule; `invalid` — the id or the changed schedule was refused; `io` — the file could not be written. */
122
+ export type UpdateResult = {
123
+ ok: true;
124
+ schedule: Schedule;
125
+ } | {
126
+ ok: false;
127
+ error: string;
128
+ failure: 'missing' | 'invalid' | 'io';
129
+ };
130
+ /**
131
+ * Change a schedule in place. The result passes `validateSchedule` before it is written, and is written whole (a
132
+ * listener reading it at the same time sees the old file or the new one). A new `when` sets `updatedAt` to `now`:
133
+ * the runner counts the edited schedule's runs from then, so the times it had before are neither run nor counted as
134
+ * 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
+ */
136
+ export declare const updateSchedule: (id: string, change: UncheckedScheduleChange, now: number) => UpdateResult;
137
+ /** The time a schedule's runs are counted from: its last edit, else its creation. */
138
+ export declare const scheduleAnchor: (schedule: Schedule) => number;
108
139
  /** `invalid` means the id could not name a schedule file; nothing was touched. */
109
140
  export type RemoveResult = 'removed' | 'missing' | 'invalid';
110
141
  export declare const removeSchedule: (id: string) => RemoveResult;
package/dist/schedule.js CHANGED
@@ -10,7 +10,7 @@
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.addSchedule = exports.listSchedules = exports.schedulesDir = exports.validateSchedule = 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.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;
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");
@@ -163,8 +163,9 @@ exports.nextOccurrence = nextOccurrence;
163
163
  const SCHEDULE_ID = /^[a-z0-9]{6}$/;
164
164
  const LABEL = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
165
165
  const AGENT_KIND = /^[A-Za-z0-9_-]{1,32}$/;
166
- const KEYS = new Set(['v', 'id', 'createdAt', 'source', 'agentKind', 'cwd', 'label', 'session', 'wt', 'agentArgs', 'prompt', 'when', 'maxRuntimeMin']);
166
+ const KEYS = new Set(['v', 'id', 'createdAt', 'updatedAt', 'source', 'agentKind', 'cwd', 'label', 'session', 'wt', 'agentArgs', 'prompt', 'when', 'maxRuntimeMin']);
167
167
  const isScheduleId = (value) => typeof value === 'string' && SCHEDULE_ID.test(value);
168
+ exports.isScheduleId = isScheduleId;
168
169
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
169
170
  const noControl = (text) => !/[\u0000-\u001f\u007f]/.test(text);
170
171
  /**
@@ -202,13 +203,18 @@ const validateSchedule = (input) => {
202
203
  const unknownKey = Object.keys(input).find((k) => !KEYS.has(k));
203
204
  if (unknownKey !== undefined)
204
205
  return fail(`schedule has a field it does not know: ${unknownKey}`);
205
- const { v, id, createdAt, source, agentKind, cwd, label, session, wt, agentArgs, prompt, when, maxRuntimeMin } = input;
206
+ const { v, id, createdAt, updatedAt, source, agentKind, cwd, label, session, wt, agentArgs, prompt, when, maxRuntimeMin } = input;
206
207
  if (v !== 1)
207
208
  return fail('schedule.v must be 1');
208
- if (!isScheduleId(id))
209
+ if (!(0, exports.isScheduleId)(id))
209
210
  return fail('schedule.id must be 6 lowercase letters or digits');
210
211
  if (typeof createdAt !== 'string' || createdAt.length > 40 || !Number.isFinite(Date.parse(createdAt)))
211
212
  return fail('schedule.createdAt must be a date');
213
+ if (updatedAt !== undefined && (typeof updatedAt !== 'string' || updatedAt.length > 40 || !Number.isFinite(Date.parse(updatedAt))))
214
+ return fail('schedule.updatedAt must be a date');
215
+ // Before the schedule existed, it would count runs from a time it was not there for.
216
+ if (typeof updatedAt === 'string' && Date.parse(updatedAt) < Date.parse(createdAt))
217
+ return fail('schedule.updatedAt cannot be before createdAt');
212
218
  if (source !== 'cli' && source !== 'phone')
213
219
  return fail("schedule.source must be 'cli' or 'phone'");
214
220
  if (typeof agentKind !== 'string' || !AGENT_KIND.test(agentKind))
@@ -287,7 +293,7 @@ const listSchedules = () => {
287
293
  if (!name.endsWith('.json'))
288
294
  continue;
289
295
  const id = name.slice(0, -'.json'.length);
290
- const schedule = isScheduleId(id) ? readOne(id) : null;
296
+ const schedule = (0, exports.isScheduleId)(id) ? readOne(id) : null;
291
297
  if (schedule)
292
298
  schedules.push(schedule);
293
299
  else
@@ -343,8 +349,40 @@ const addSchedule = (make, newId = newScheduleId) => {
343
349
  return { ok: false, error: 'could not find a free schedule id', failure: 'io' };
344
350
  };
345
351
  exports.addSchedule = addSchedule;
352
+ /**
353
+ * Change a schedule in place. The result passes `validateSchedule` before it is written, and is written whole (a
354
+ * listener reading it at the same time sees the old file or the new one). A new `when` sets `updatedAt` to `now`:
355
+ * the runner counts the edited schedule's runs from then, so the times it had before are neither run nor counted as
356
+ * missed. A change of the prompt or the run time only keeps the count where it was — an hourly run stays on its hour.
357
+ */
358
+ const updateSchedule = (id, change, now) => {
359
+ if (!(0, exports.isScheduleId)(id))
360
+ return { ok: false, error: 'not a schedule id', failure: 'invalid' };
361
+ if (change.when === undefined && change.prompt === undefined && change.maxRuntimeMin === undefined) {
362
+ return { ok: false, error: 'nothing to change', failure: 'invalid' };
363
+ }
364
+ const current = readOne(id);
365
+ if (!current)
366
+ return { ok: false, error: `no schedule ${id}`, failure: 'missing' };
367
+ // The same time sent again (an app sends the sheet's) is not a new one: the count stays where it was.
368
+ const retimed = change.when !== undefined && JSON.stringify(change.when) !== JSON.stringify(current.when);
369
+ const checked = (0, exports.validateSchedule)({ ...current, ...change, ...(retimed ? { updatedAt: new Date(now).toISOString() } : {}) });
370
+ if (!checked.ok)
371
+ return { ok: false, error: checked.error, failure: 'invalid' };
372
+ try {
373
+ (0, replace_file_js_1.replaceFile)(schedulePath(id), `${JSON.stringify(checked.value, null, 2)}\n`, undefined, 0o600);
374
+ return { ok: true, schedule: checked.value };
375
+ }
376
+ catch (err) {
377
+ return { ok: false, error: `could not write the schedule: ${err instanceof Error ? err.message : String(err)}`, failure: 'io' };
378
+ }
379
+ };
380
+ exports.updateSchedule = updateSchedule;
381
+ /** The time a schedule's runs are counted from: its last edit, else its creation. */
382
+ const scheduleAnchor = (schedule) => Date.parse(schedule.updatedAt ?? schedule.createdAt);
383
+ exports.scheduleAnchor = scheduleAnchor;
346
384
  const removeSchedule = (id) => {
347
- if (!isScheduleId(id))
385
+ if (!(0, exports.isScheduleId)(id))
348
386
  return 'invalid';
349
387
  try {
350
388
  (0, node_fs_1.unlinkSync)(schedulePath(id));
@@ -401,7 +439,7 @@ const readScheduleState = () => {
401
439
  if (!isRecord(raw) || !isRecord(raw.schedules))
402
440
  return { ...state, damaged: true };
403
441
  for (const [id, entry] of Object.entries(raw.schedules)) {
404
- const runState = isScheduleId(id) ? readRunState(entry) : null;
442
+ const runState = (0, exports.isScheduleId)(id) ? readRunState(entry) : null;
405
443
  if (runState)
406
444
  state.schedules[id] = runState;
407
445
  else
@@ -14,6 +14,7 @@
14
14
  * relay posing as one) sends can point the daemon at another directory or
15
15
  * another program.
16
16
  */
17
+ import type { ProviderNameSource } from './session-title.js';
17
18
  export interface KnownSession {
18
19
  /** tmux session name — the key the phone addresses. */
19
20
  name: string;
@@ -43,6 +44,14 @@ export interface KnownSession {
43
44
  * run (`continuedRunId`). Absent for a run that has had one tmux session.
44
45
  */
45
46
  tmuxRunIds?: string[];
47
+ /**
48
+ * The last name the agent gave this run (Claude's ai-title or `/rename`,
49
+ * …) and who gave it, as the sweep read them: what the phone calls the run
50
+ * once it has ended (hwip docs/session-runs.md Q39). Absent for a run never
51
+ * named, and in files written before it.
52
+ */
53
+ providerSessionName?: string;
54
+ providerSessionNameSource?: ProviderNameSource;
46
55
  /** Last time this machine saw the session running. */
47
56
  lastSeenAt: string;
48
57
  }
@@ -80,8 +89,9 @@ export declare const runKey: (name: string, agentKind: string, runId?: string) =
80
89
  /** Sessions this machine has seen, newest first, expired ones dropped. */
81
90
  export declare const knownSessions: (now?: number) => KnownSession[];
82
91
  /** One remembered session, or null when this machine never saw that name.
83
- * The newest run under that name — which is the one a bare resume means. */
84
- export declare const recallSession: (name: string, now?: number) => KnownSession | null;
92
+ * The newest run under that name — which is the one a bare resume means.
93
+ * With `runId`, that run of the name and nothing else (Q46). */
94
+ export declare const recallSession: (name: string, now?: number, runId?: string) => KnownSession | null;
85
95
  /**
86
96
  * One specific run: the newest under this name that ran THIS agent.
87
97
  *
@@ -90,8 +100,12 @@ export declare const recallSession: (name: string, now?: number) => KnownSession
90
100
  * which row was tapped and says so; this is how that answer is honoured.
91
101
  * Null when this machine never saw that agent under that name — the caller
92
102
  * decides whether to fall back to the newest run or refuse.
103
+ *
104
+ * With `runId`, that run of the name and nothing else (docs/session-runs.md
105
+ * Q46): the earlier-runs fold asks for one conversation, and the newest run is
106
+ * a different one.
93
107
  */
94
- export declare const recallRun: (name: string, agentKind: string, now?: number) => KnownSession | null;
108
+ export declare const recallRun: (name: string, agentKind: string, now?: number, runId?: string) => KnownSession | null;
95
109
  /** tmux sessions remembered per run: an `exit` and `-c` every few minutes all day stays a short list. */
96
110
  export declare const MAX_TMUX_RUNS_PER_RUN = 10;
97
111
  /**
@@ -138,6 +152,9 @@ export declare const rememberSessions: (live: Array<{
138
152
  * run's alone to resume — the run forgets it.
139
153
  */
140
154
  agentSessionId?: string | null;
155
+ /** The name the agent gives the run now, and who gave it. None this sweep: the run keeps the one it had. */
156
+ providerSessionName?: string | null;
157
+ providerSessionNameSource?: ProviderNameSource;
141
158
  }>, now?: number) => void;
142
159
  /**
143
160
  * Forget one session, by name. Returns false when this machine never knew it.
@@ -35,6 +35,14 @@ exports.MAX_RUNS_PER_NAME = 10;
35
35
  * likely to have moved than to be what the user meant. */
36
36
  exports.KNOWN_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
37
37
  const registryPath = () => (0, path_1.join)((0, gate_js_1.stateDir)(), 'known-sessions.json');
38
+ const isNameSource = (v) => v === 'user' || v === 'auto';
39
+ /** A run's name as the file holds it: a name that is not a string goes, and with it its source; a source that is not one goes alone. */
40
+ const withReadableName = (e) => {
41
+ const { providerSessionName: name, providerSessionNameSource: source, ...rest } = e;
42
+ if (typeof name !== 'string' || name === '')
43
+ return rest;
44
+ return { ...rest, providerSessionName: name, ...(isNameSource(source) ? { providerSessionNameSource: source } : {}) };
45
+ };
38
46
  const isTmuxRunIds = (v) => v === undefined || (Array.isArray(v) && v.every((id) => typeof id === 'string'));
39
47
  const readAll = () => {
40
48
  try {
@@ -51,7 +59,7 @@ const readAll = () => {
51
59
  typeof e.agentKind === 'string' &&
52
60
  typeof e.lastSeenAt === 'string' &&
53
61
  (e.runId === undefined || typeof e.runId === 'string') &&
54
- isTmuxRunIds(e.tmuxRunIds));
62
+ isTmuxRunIds(e.tmuxRunIds)).map(withReadableName);
55
63
  }
56
64
  catch {
57
65
  return [];
@@ -96,9 +104,12 @@ const knownSessions = (now = Date.now()) => readAll()
96
104
  .filter((e) => now - Date.parse(e.lastSeenAt) < exports.KNOWN_SESSION_TTL_MS)
97
105
  .sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt));
98
106
  exports.knownSessions = knownSessions;
107
+ /** No run asked for, or that run. */
108
+ const ofRun = (e, runId) => runId === undefined || e.runId === runId;
99
109
  /** One remembered session, or null when this machine never saw that name.
100
- * The newest run under that name — which is the one a bare resume means. */
101
- const recallSession = (name, now = Date.now()) => (0, exports.knownSessions)(now).find((e) => e.name === name) ?? null;
110
+ * The newest run under that name — which is the one a bare resume means.
111
+ * With `runId`, that run of the name and nothing else (Q46). */
112
+ const recallSession = (name, now = Date.now(), runId) => (0, exports.knownSessions)(now).find((e) => e.name === name && ofRun(e, runId)) ?? null;
102
113
  exports.recallSession = recallSession;
103
114
  /**
104
115
  * One specific run: the newest under this name that ran THIS agent.
@@ -108,8 +119,12 @@ exports.recallSession = recallSession;
108
119
  * which row was tapped and says so; this is how that answer is honoured.
109
120
  * Null when this machine never saw that agent under that name — the caller
110
121
  * decides whether to fall back to the newest run or refuse.
122
+ *
123
+ * With `runId`, that run of the name and nothing else (docs/session-runs.md
124
+ * Q46): the earlier-runs fold asks for one conversation, and the newest run is
125
+ * a different one.
111
126
  */
112
- const recallRun = (name, agentKind, now = Date.now()) => (0, exports.knownSessions)(now).find((e) => e.name === name && e.agentKind === agentKind) ?? null;
127
+ const recallRun = (name, agentKind, now = Date.now(), runId) => (0, exports.knownSessions)(now).find((e) => e.name === name && e.agentKind === agentKind && ofRun(e, runId)) ?? null;
113
128
  exports.recallRun = recallRun;
114
129
  /** tmux sessions remembered per run: an `exit` and `-c` every few minutes all day stays a short list. */
115
130
  exports.MAX_TMUX_RUNS_PER_RUN = 10;
@@ -200,6 +215,12 @@ const rememberSessions = (live, now = Date.now()) => {
200
215
  const tmuxRunIds = continued && s.tmuxRunId
201
216
  ? [...(prev?.tmuxRunIds ?? []).filter((id) => id !== s.tmuxRunId), s.tmuxRunId].slice(-exports.MAX_TMUX_RUNS_PER_RUN)
202
217
  : prev?.tmuxRunIds;
218
+ // The run's name is the last one read: a sweep that read none (the
219
+ // agent has not titled it yet, or its record was unreadable) does not
220
+ // take it away.
221
+ const named = s.providerSessionName
222
+ ? { providerSessionName: s.providerSessionName, providerSessionNameSource: s.providerSessionNameSource }
223
+ : prev;
203
224
  byRun.set(key, {
204
225
  name: s.name,
205
226
  cwd: s.cwd,
@@ -210,6 +231,8 @@ const rememberSessions = (live, now = Date.now()) => {
210
231
  ...(startedAt ? { startedAt } : {}),
211
232
  ...(agentSessionId ? { agentSessionId } : {}),
212
233
  ...(tmuxRunIds?.length ? { tmuxRunIds } : {}),
234
+ ...(named?.providerSessionName ? { providerSessionName: named.providerSessionName } : {}),
235
+ ...(named?.providerSessionName && named.providerSessionNameSource ? { providerSessionNameSource: named.providerSessionNameSource } : {}),
213
236
  lastSeenAt: seenAt,
214
237
  });
215
238
  }
package/dist/usage.js CHANGED
@@ -172,6 +172,10 @@ ${usageAgentLines()}
172
172
  (cc, codex, …) and the project is this one
173
173
  schedule [list] show them, with the next run and the last
174
174
  result (--json for scripts)
175
+ schedule edit <id> [--at HH:MM] [--days …] [--every <N>m|<N>h]
176
+ [--prompt <text>] [--max-runtime <N>m|<N>h]
177
+ change one in place; a new time counts
178
+ from the edit, a running session keeps going
175
179
  schedule rm <id> remove one; a session it already started
176
180
  keeps running
177
181
  sh Open (or reattach) 'hwip-<project>-sh', a login shell the
package/dist/wrapper.d.ts CHANGED
@@ -44,6 +44,8 @@ export declare const installedTmuxVersion: () => string;
44
44
  export declare const tmuxSessionName: (project: string) => string;
45
45
  /** The first name in the family no live session holds — for a launch that can only create. */
46
46
  export declare const firstFreeSession: (base: string) => string;
47
+ /** `<base>`, `<base>-2`, `<base>-3`, … — the session family for one project. */
48
+ export declare const familyNames: (base: string) => string[];
47
49
  /**
48
50
  * Pick a tmux session name that won't steal focus from another live
49
51
  * `hwip cc`. Strategy:
package/dist/wrapper.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.handleShellSession = exports.shellTargets = exports.readShellSessionState = exports.planShellSession = exports.handleAgentSession = exports.tmuxMissingMessage = exports.inPaneNote = exports.handOffExec = exports.ensureListenerRunning = exports.takeListenerLaunchMarker = exports.listenerVersionDrifted = exports.targetForAgent = exports.splitAgentOptions = exports.sanitizeLabel = exports.findAvailableSession = exports.firstFreeSession = exports.tmuxSessionName = exports.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.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
@@ -14,6 +14,7 @@ exports.handleShellSession = exports.shellTargets = exports.readShellSessionStat
14
14
  */
15
15
  const child_process_1 = require("child_process");
16
16
  const path_1 = require("path");
17
+ const agent_dirs_js_1 = require("./agent-dirs.js");
17
18
  const agents_js_1 = require("./agents.js");
18
19
  const tmux_install_js_1 = require("./tmux-install.js");
19
20
  const check_update_js_1 = require("./check-update.js");
@@ -171,11 +172,12 @@ const MAX_SUFFIX_ATTEMPTS = 20;
171
172
  /** The first name in the family no live session holds — for a launch that can only create. */
172
173
  const firstFreeSession = (base) => {
173
174
  const live = liveSessions();
174
- return familyNames(base).find((name) => !live.has(name)) ?? `${base}-${MAX_SUFFIX_ATTEMPTS + 1}`;
175
+ return (0, exports.familyNames)(base).find((name) => !live.has(name)) ?? `${base}-${MAX_SUFFIX_ATTEMPTS + 1}`;
175
176
  };
176
177
  exports.firstFreeSession = firstFreeSession;
177
178
  /** `<base>`, `<base>-2`, `<base>-3`, … — the session family for one project. */
178
179
  const familyNames = (base) => Array.from({ length: MAX_SUFFIX_ATTEMPTS }, (_, i) => (i === 0 ? base : `${base}-${i + 1}`));
180
+ exports.familyNames = familyNames;
179
181
  /**
180
182
  * Pick a tmux session name that won't steal focus from another live
181
183
  * `hwip cc`. Strategy:
@@ -207,7 +209,7 @@ agentKind,
207
209
  /** Test seam: what this machine recorded about a session name. */
208
210
  recall = session_registry_js_1.recallSession) => {
209
211
  const live = liveSessions();
210
- const family = familyNames(base);
212
+ const family = (0, exports.familyNames)(base);
211
213
  /**
212
214
  * Whether a detached session can host this agent.
213
215
  *
@@ -530,6 +532,15 @@ const handleAgentSession = async (agent, extra = [], opts = {}, deps = {}) => {
530
532
  console.error(missing);
531
533
  return 127;
532
534
  }
535
+ // Where this shell finds the agent: a schedule launch, which has only the listener's PATH, looks there too (agent-dirs.ts).
536
+ const agentFile = (0, agents_js_1.findOnPath)(agent.binary);
537
+ if (agentFile && !launchedByListener)
538
+ (0, agent_dirs_js_1.rememberAgentDir)(agentFile);
539
+ // tmux would create the session anyway and its pane would die at once, leaving the caller a session that is just gone.
540
+ if (opts.detach && !agentFile) {
541
+ console.error(`hwip: '${agent.binary}' not found on PATH`);
542
+ return 127;
543
+ }
533
544
  // Before the listener starts: a refused `--wt` should cost nothing.
534
545
  if (opts.worktree) {
535
546
  const made = await (0, worktree_js_1.ensureWorktree)({ dir: (0, exports.detectProjectDir)(), name: opts.worktree });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hwip/cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Hwip CLI + push notification SDK for AI agents",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",