@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.
- package/README.md +27 -12
- package/dist/agent-dirs.d.ts +15 -0
- package/dist/agent-dirs.js +76 -0
- package/dist/agent-rules.default.d.ts +1 -1
- package/dist/agent-rules.default.js +15 -4
- package/dist/blocked-prompt.js +172 -14
- package/dist/listener.d.ts +33 -0
- package/dist/listener.js +76 -19
- package/dist/mcp-command.d.ts +1 -1
- package/dist/remote-agents.d.ts +3 -0
- package/dist/remote-agents.js +40 -7
- package/dist/schedule-command.d.ts +11 -1
- package/dist/schedule-command.js +113 -4
- package/dist/schedule-launch.d.ts +3 -1
- package/dist/schedule-launch.js +4 -1
- package/dist/schedule-remote.d.ts +7 -1
- package/dist/schedule-remote.js +51 -4
- package/dist/schedule-runner.js +5 -2
- package/dist/schedule.d.ts +31 -0
- package/dist/schedule.js +45 -7
- package/dist/session-registry.d.ts +20 -3
- package/dist/session-registry.js +27 -4
- package/dist/usage.js +4 -0
- package/dist/wrapper.d.ts +2 -0
- package/dist/wrapper.js +14 -3
- package/package.json +1 -1
package/dist/schedule-remote.js
CHANGED
|
@@ -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
|
|
56
|
+
...listedPrompt(schedule, promptChars),
|
|
51
57
|
maxRuntimeMin: schedule.maxRuntimeMin,
|
|
52
|
-
nextAt: isoOrNull((0, schedule_js_1.nextOccurrence)(schedule.when, now,
|
|
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`,
|
|
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
|
|
212
|
+
return refuse('schedule_invalid', 'op is not create, update or remove');
|
|
166
213
|
};
|
|
167
214
|
exports.applyPhoneSchedule = applyPhoneSchedule;
|
package/dist/schedule-runner.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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;
|
package/dist/schedule.d.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/dist/session-registry.js
CHANGED
|
@@ -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
|
-
|
|
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 });
|