@hwip/cli 0.1.4 → 0.1.5

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 CHANGED
@@ -645,8 +645,9 @@ went.
645
645
  - **The prompt** (`--prompt`) is typed once, and only when the agent is the foreground program of the pane and its detection rules read the screen as `idle` — not working, not a dialog. If that does not happen within 90 seconds the prompt is not typed, the session stays, and the phone is told. **Only agents that have an `idle` rule can take a prompt** (today Claude Code, Codex, Gemini CLI and pi): for any other agent nothing separates "ready" from a trust or permission dialog, and the Enter that submits a prompt would answer the dialog's default with nobody there to read it. `hwip schedule add … --prompt` refuses those agents and says which ones work; a schedule without `--prompt` starts any agent. The rules that decide this are the ones shipped in the `hwip` package, not the detection rules the listener updates over the air: a phone's input is typed by someone who has seen the screen, a schedule by nobody, and a rule set that arrives over the air is not signed — it must not be able to call a dialog `idle` and have your prompt and Enter typed into it.
646
646
  - **The cap**: a session that runs longer than `--max-runtime` is asked to end the way the phone's *end session* does — the agent's own quit command, then signals. It is never killed; one that stays keeps the schedule from starting its next run (`overlap`).
647
647
  - **The phone is told** with one push per listener tick, listing each schedule with how many times and why (`<label> · 놓침 31회` — the push is in Korean). A run skipped because the last one is still going counts once until the result changes; a push that does not go out is tried again after 30 seconds, then 1, 2, 4 … minutes up to 30 (only a refusal by the server, such as the daily limit, is not tried again; a timeout, a proxy or a captive portal in the way is), and is dropped after 24 hours. The push has one title per kind of news — skipped or missed, over its runtime, or a prompt not typed — and a general one when a push holds several kinds. The prompt is never in it. A free account sends at most 100 pushes a day (`pushPerDay`), which is why these are batched.
648
- - **State** is files under `~/.local/state/hwip`: one `schedules/<id>.json` per schedule (limit 20) and `schedule-state.json`, written only by the listener, with where each schedule has got to.
649
- - **From the phone**: the app lists the schedules of a computer, makes one for a session this computer has run, and removes one. It names the session, a time and what to do; the directory, the agent's command and its arguments are never the phone's (see *Defense*). A schedule made there needs an agent that can take a prompt, and shows in `hwip schedule` like any other.
648
+ - **Editing**: `hwip schedule edit <id>` changes the time (`--at`, `--days`, `--every`), `--prompt` and `--max-runtime` of a schedule in place; the id, the session, the directory and the agent stay. What a flag does not name is kept — `--at` keeps the days, `--days` the time. A changed time counts from the edit: the times before it are neither started nor counted as missed, and an `--every` schedule runs one period after the edit; changing only the prompt or the run time keeps the schedule on its times. A session already running keeps going. A listener of an older build is restarted first, because it would skip the edited file as unreadable; when it cannot be replaced, a new time is not written (exit 1) and the schedule keeps running as it was.
649
+ - **State** is files under `~/.local/state/hwip`: one `schedules/<id>.json` per schedule (limit 20) and `schedule-state.json`, written only by the listener, with where each schedule has got to. `agent-dirs.json` holds the directories `hwip <agent>` typed in a shell found its agent in: a listener run as a login service has only the PATH its service file baked, so a scheduled launch adds these to it — run an agent from a shell once (`hwip cc`) before scheduling it, or the run is skipped with `'claude' not found on PATH` in `~/.hwip/listener.log`.
650
+ - **From the phone**: the app lists the schedules of a computer, makes one for a session this computer has run, edits one, and removes one. It names the session, a time and what to do; the directory, the agent's command and its arguments are never the phone's (see *Defense*). A schedule made there needs an agent that can take a prompt, and shows in `hwip schedule` like any other.
650
651
 
651
652
  ### Session history
652
653
 
@@ -832,7 +833,7 @@ The phone also reads this machine's schedules (see [Scheduled sessions](#schedul
832
833
  { subtype: 'agent.schedule.list.result', requestId, at, schedules | encrypted | error }
833
834
  ```
834
835
 
835
- `schedules` is the list as a JSON string; `encrypted` is the list sealed for the asking device (purpose `schedule.list`, bound to this machine's device id) as `{ v: 1, requestId, at, schedules: [...], unreadable? }` — the app checks that the `requestId` inside is the one it asked, so an older sealed list is not taken for a newer answer. `unreadable` is how many schedule files could not be read; a directory that cannot be listed at all, or that holds more than 20 files, is the error `read_failed`, never an empty list (and a remove then fails as `schedule_failed`; `hwip schedule rm` still works on the computer). A list too long for one frame has its prompts shortened, step by step (`too_large` only when it does not fit even without them). Each entry holds `id`, `source`, `agentKind`, `project` (the directory's name, not its path), `session`, `when`, `prompt` (cut to 500 characters, shorter when the list would not fit), `maxRuntimeMin`, `nextAt` and the `last` result — never a directory path or the agent's arguments.
836
+ `schedules` is the list as a JSON string; `encrypted` is the list sealed for the asking device (purpose `schedule.list`, bound to this machine's device id) as `{ v: 1, requestId, at, schedules: [...], unreadable? }` — the app checks that the `requestId` inside is the one it asked, so an older sealed list is not taken for a newer answer. `unreadable` is how many schedule files could not be read; a directory that cannot be listed at all, or that holds more than 20 files, is the error `read_failed`, never an empty list (and a remove then fails as `schedule_failed`; `hwip schedule rm` still works on the computer). A list too long for one frame has its prompts shortened, step by step (`too_large` only when it does not fit even without them). Each entry holds `id`, `source`, `agentKind`, `project` (the directory's name, not its path), `session`, `when`, `prompt` (cut to 500 characters, shorter when the list would not fit; `promptLocked: true` says the phone cannot change it — it is not the whole text, or the schedule has agent arguments — so the app leaves it to `hwip schedule edit`), `maxRuntimeMin`, `nextAt` and the `last` result — never a directory path or the agent's arguments.
836
837
 
837
838
  ### Your API key and redirects
838
839
 
@@ -972,7 +973,12 @@ handing the listener a key:
972
973
  session name, its `seq`/`epoch` and that nonce, so a relay that cannot
973
974
  swap in a key of its own (below) can't move one to another stream,
974
975
  replay one from an earlier subscription, or hand one back as input —
975
- or as anything else this listener sealed for the phone. Your
976
+ or as anything else this listener sealed for the phone. A history page
977
+ (scrolling up past the live window) holds the same nonce and `epoch`,
978
+ the request id it answers and where it sits in the scrollback, so a
979
+ relay can't hand an earlier page back under a new request or move
980
+ where the next one is asked for; only the request id rides beside it
981
+ in the clear. Your
976
982
  keystrokes are sealed for this listener as `stream.input` with their
977
983
  `seq`/`epoch` stamps, the stream's start epoch and the phone's device
978
984
  id inside, so a forged restart can't replay them. The start answer (`agent.stream.start.ok`)
@@ -1188,7 +1194,7 @@ device, takes plain commands as before; an unreadable
1188
1194
  forgets the pins, and with them goes back to taking plain commands until a
1189
1195
  device is pinned again.
1190
1196
 
1191
- **A schedule request** — the phone making or removing a scheduled session — rides
1197
+ **A schedule request** — the phone making, changing or removing a scheduled session — rides
1192
1198
  the same `agent.command` push, with a `schedule` field and no `body`. It carries
1193
1199
  a time, a prompt and, if the phone says so, an agent and a run time — and nothing
1194
1200
  else. The directory and the agent come from what this machine recorded for the
@@ -1197,8 +1203,11 @@ schedule at another directory; it cannot send agent arguments, a worktree or a
1197
1203
  label either, and a request that carries one is refused rather than ignored
1198
1204
  (`schedule_invalid`; `schedule_limit` at 20 schedules; `unknown_session` for a
1199
1205
  session this machine never recorded; `schedule_failed` when this machine's own
1200
- files would not read or write). A remove names the schedule's own
1201
- session, so it cannot reach another. Applying a request never types into a
1206
+ files would not read or write). A change (`op: 'update'`) carries the schedule's id
1207
+ and any of the time, the prompt and the run time; what it leaves out stays as it was,
1208
+ and a change with none of them is refused. A schedule made on the computer with agent arguments keeps its prompt: the phone may change its time and run time, not what is typed under those arguments. A change and a remove name the schedule's own
1209
+ session, so they cannot reach another. A listener of an older build refuses a change as
1210
+ `schedule_invalid`. Applying a request never types into a
1202
1211
  session. The request is sealed like any
1203
1212
  command — the whole of it inside the body, only `op` beside it, and the sender,
1204
1213
  label and counter checks above apply, so a replay or a swapped `op` changes
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Where the person's own shell finds their agents (F8). A schedule is started by the listener, and the listener run by
3
+ * launchd or systemd has only the PATH its service file baked: tmux's and node's directories and the system ones. An
4
+ * agent installed anywhere else — `~/.local/bin/claude` — is not on it, and the scheduled session's pane dies at once
5
+ * with "command not found". So every `hwip <agent>` typed in a shell writes down the directory its agent was found in,
6
+ * and a schedule launch adds those directories to its PATH.
7
+ *
8
+ * Builtins only: wrapper.ts writes this on the `hwip cc` hot path.
9
+ */
10
+ export declare const agentDirsPath: () => string;
11
+ /** Writes down the directory of `binary`, an absolute path the shell resolved. Best-effort: a launch never fails on it. */
12
+ export declare const rememberAgentDir: (binary: string) => void;
13
+ /** `path` with every written-down directory it lacks appended: what the listener has first, so nothing it runs changes. */
14
+ export declare const withAgentDirs: (path: string | undefined) => string;
15
+ //# sourceMappingURL=agent-dirs.d.ts.map
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+ /**
3
+ * Where the person's own shell finds their agents (F8). A schedule is started by the listener, and the listener run by
4
+ * launchd or systemd has only the PATH its service file baked: tmux's and node's directories and the system ones. An
5
+ * agent installed anywhere else — `~/.local/bin/claude` — is not on it, and the scheduled session's pane dies at once
6
+ * with "command not found". So every `hwip <agent>` typed in a shell writes down the directory its agent was found in,
7
+ * and a schedule launch adds those directories to its PATH.
8
+ *
9
+ * Builtins only: wrapper.ts writes this on the `hwip cc` hot path.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.withAgentDirs = exports.rememberAgentDir = exports.agentDirsPath = void 0;
13
+ const node_fs_1 = require("node:fs");
14
+ const node_path_1 = require("node:path");
15
+ const gate_js_1 = require("./gate.js");
16
+ const private_files_js_1 = require("./private-files.js");
17
+ /** More than anyone has agents in; bounds what a schedule launch puts on PATH. */
18
+ const MAX_DIRS = 16;
19
+ const agentDirsPath = () => (0, node_path_1.join)((0, gate_js_1.stateDir)(), 'agent-dirs.json');
20
+ exports.agentDirsPath = agentDirsPath;
21
+ /**
22
+ * Whether the file is the user's own regular file that only they can write: a directory put on PATH by someone else runs
23
+ * their programs as the agent. 'missing' when there is none yet.
24
+ */
25
+ const fileState = (path) => {
26
+ try {
27
+ const info = (0, node_fs_1.lstatSync)(path);
28
+ const mine = typeof process.getuid !== 'function' || info.uid === process.getuid();
29
+ return info.isFile() && mine && (info.mode & 0o022) === 0 ? 'ok' : 'refused';
30
+ }
31
+ catch {
32
+ return 'missing';
33
+ }
34
+ };
35
+ const readDirs = () => {
36
+ try {
37
+ const path = (0, exports.agentDirsPath)();
38
+ if (fileState(path) !== 'ok')
39
+ return [];
40
+ const parsed = JSON.parse((0, node_fs_1.readFileSync)(path, 'utf-8'));
41
+ if (!Array.isArray(parsed))
42
+ return [];
43
+ return parsed.filter((dir) => typeof dir === 'string' && (0, node_path_1.isAbsolute)(dir)).slice(0, MAX_DIRS);
44
+ }
45
+ catch {
46
+ return [];
47
+ }
48
+ };
49
+ /** Writes down the directory of `binary`, an absolute path the shell resolved. Best-effort: a launch never fails on it. */
50
+ const rememberAgentDir = (binary) => {
51
+ if (!(0, node_path_1.isAbsolute)(binary))
52
+ return;
53
+ const dir = (0, node_path_1.dirname)(binary);
54
+ const path = (0, exports.agentDirsPath)();
55
+ // Never written through a link, nor into a file that is not read back: either would be writing for nothing, or elsewhere.
56
+ if (fileState(path) === 'refused')
57
+ return;
58
+ const dirs = readDirs();
59
+ if (dirs.includes(dir))
60
+ return;
61
+ try {
62
+ (0, private_files_js_1.mkdirHandedBack)((0, gate_js_1.stateDir)(), 0o700);
63
+ (0, node_fs_1.writeFileSync)(path, JSON.stringify([...dirs, dir].slice(-MAX_DIRS)), { mode: 0o600 });
64
+ (0, private_files_js_1.handBackToSudoUser)(path);
65
+ }
66
+ catch {
67
+ // The schedule launch then says the agent is not on PATH, which is the truth.
68
+ }
69
+ };
70
+ exports.rememberAgentDir = rememberAgentDir;
71
+ /** `path` with every written-down directory it lacks appended: what the listener has first, so nothing it runs changes. */
72
+ const withAgentDirs = (path) => {
73
+ const have = (path ?? '').split(node_path_1.delimiter).filter(Boolean);
74
+ return [...have, ...readDirs().filter((dir) => !have.includes(dir))].join(node_path_1.delimiter);
75
+ };
76
+ exports.withAgentDirs = withAgentDirs;
@@ -229,6 +229,9 @@ export interface ReportedKnownSession {
229
229
  lastSeq?: number;
230
230
  /** The later tmux sessions the run went on in (Q35): the server ends another run's push window at their start. */
231
231
  tmuxRunIds?: string[];
232
+ /** The last name the agent gave the run, and who gave it (hwip docs/session-runs.md Q39): what the phone calls it once ended. */
233
+ providerSessionName?: string;
234
+ providerSessionNameSource?: ProviderNameSource;
232
235
  lastSeenAt: string;
233
236
  }
234
237
  /** Each live run's first seq, for the report (Q24). A run that has recorded nothing yet has none. */
@@ -640,6 +643,27 @@ export interface ScreenHistorySnapshot {
640
643
  error?: string;
641
644
  capturedAt: string;
642
645
  }
646
+ /**
647
+ * What a sealed history page's envelope holds (as JSON): the page and every
648
+ * number a viewer pages by, bound to the request it answers and to the
649
+ * subscription it was sealed for. On a sealed stream the snapshot carries
650
+ * only `requestId` (for routing) beside this — a relay that hands an earlier
651
+ * page back under a new request, or rewrites where it sits, is caught on open.
652
+ */
653
+ export type SealedHistoryPage = {
654
+ sessionName: string;
655
+ requestId: string;
656
+ nonce?: string;
657
+ epoch: number;
658
+ before: number;
659
+ lines: number;
660
+ hasMore: boolean;
661
+ truncated: boolean;
662
+ /** The pane's history size when this page was captured, so the viewer can
663
+ * tell how much the history grew between its request and the capture. */
664
+ historySize: number;
665
+ content: string;
666
+ };
643
667
  /**
644
668
  * Answer a phone's request for one page of scrollback above the live window.
645
669
  * Returns true when the message was ours to answer, so the caller stops routing
package/dist/listener.js CHANGED
@@ -319,11 +319,13 @@ const knownSessionsToReport = (live, now = Date.now(), seqs = (0, run_seqs_js_1.
319
319
  ...(e.startedAt ? { startedAt: e.startedAt } : {}),
320
320
  ...(e.runId ? seqs.get((0, run_seqs_js_1.runSeqsKey)(e.name, e.runId)) : undefined),
321
321
  ...(e.tmuxRunIds?.length ? { tmuxRunIds: e.tmuxRunIds } : {}),
322
+ ...(e.providerSessionName ? { providerSessionName: e.providerSessionName } : {}),
323
+ ...(e.providerSessionName && e.providerSessionNameSource ? { providerSessionNameSource: e.providerSessionNameSource } : {}),
322
324
  lastSeenAt: e.lastSeenAt,
323
325
  }));
324
326
  };
325
327
  exports.knownSessionsToReport = knownSessionsToReport;
326
- const knownSessionsFingerprint = (known) => known.map((k) => `${k.name}${k.agentKind}${k.runId ?? ''}${k.lastSeenAt}${k.firstSeq ?? ''}${k.lastSeq ?? ''}${k.tmuxRunIds?.join(',') ?? ''}`).sort().join('|');
328
+ const knownSessionsFingerprint = (known) => known.map((k) => `${k.name}${k.agentKind}${k.runId ?? ''}${k.lastSeenAt}${k.firstSeq ?? ''}${k.lastSeq ?? ''}${k.tmuxRunIds?.join(',') ?? ''} ${k.providerSessionNameSource ?? ''} ${k.providerSessionName ?? ''}`).sort().join('|');
327
329
  exports.knownSessionsFingerprint = knownSessionsFingerprint;
328
330
  const sessionsFingerprint = (sessions) => sessions
329
331
  .map((s) => JSON.stringify([
@@ -1750,11 +1752,11 @@ const handleScreenHistoryRequest = (req, send) => {
1750
1752
  return reply({ error: 'capture_failed' });
1751
1753
  // Everything below this line the caller already has.
1752
1754
  const held = before;
1753
- // Scrolled past the oldest line. An empty page rather than an error — the
1754
- // view stops, and stopping is not a failure. Nothing to seal either.
1755
- if (depth <= held)
1756
- return reply({ before, content: '', lines: 0, hasMore: false });
1757
- const captured = capturePaneRange(req.sessionName, -(held + lines), -(held + 1));
1755
+ // Scrolled past the oldest line: an empty page rather than an error — the
1756
+ // view stops, and stopping is not a failure.
1757
+ const captured = depth <= held
1758
+ ? { content: '', truncated: false }
1759
+ : capturePaneRange(req.sessionName, -(held + lines), -(held + 1));
1758
1760
  if (!captured)
1759
1761
  return reply({ error: 'capture_failed' });
1760
1762
  const returned = countLines(captured.content);
@@ -1766,11 +1768,21 @@ const handleScreenHistoryRequest = (req, send) => {
1766
1768
  };
1767
1769
  if (!stream.subscriberPublicKey)
1768
1770
  return reply({ ...page, content: captured.content });
1769
- // Encrypted stream: the page rides ONLY inside the envelope, and a failed
1770
- // seal is an error rather than a plaintext page — the same rule the frame
1771
- // path follows when its encrypt fails.
1772
- (0, crypto_js_1.sealV2)('screen.history', captured.content, stream.subscriberPublicKey, req.sessionName)
1773
- .then((encrypted) => send({ ...base, ...page, encrypted }))
1771
+ // Encrypted stream: the page and its numbers ride ONLY inside the envelope
1772
+ // — the empty last page too, or a relay could end the paging with one — and
1773
+ // a failed seal is an error rather than a plaintext page, the same rule the
1774
+ // frame path follows when its encrypt fails.
1775
+ const sealed = {
1776
+ sessionName: req.sessionName,
1777
+ requestId: req.requestId,
1778
+ ...(stream.nonce ? { nonce: stream.nonce } : {}),
1779
+ epoch: stream.epoch,
1780
+ ...page,
1781
+ historySize: depth,
1782
+ content: captured.content,
1783
+ };
1784
+ (0, crypto_js_1.sealV2)('screen.history', JSON.stringify(sealed), stream.subscriberPublicKey, req.sessionName)
1785
+ .then((encrypted) => send({ ...base, encrypted }))
1774
1786
  .catch((err) => {
1775
1787
  (0, listener_log_js_1.log)(`⧉ history ${req.sessionName}: seal failed (${err instanceof Error ? err.message : err})`);
1776
1788
  send({ ...base, error: 'encrypt_failed' });
@@ -3639,6 +3651,8 @@ const collectSessionsVerbose = () => {
3639
3651
  startedAt: s.createdAt,
3640
3652
  tmuxRunId: runIdOfIso(s.createdAt),
3641
3653
  agentSessionId: ownResumeId(s.name),
3654
+ providerSessionName: s.providerSessionName,
3655
+ providerSessionNameSource: s.providerSessionNameSource,
3642
3656
  })));
3643
3657
  return { sessions, rejected, targets, subagentTranscripts, recordable };
3644
3658
  };
@@ -4617,7 +4631,7 @@ const openSealedCommand = async (push, deps) => {
4617
4631
  return { text: opened.body ?? '', sender: senderKey, files: named, keys, ifPrompt, schedule };
4618
4632
  };
4619
4633
  /**
4620
- * A phone's request to make or remove a schedule (F8). The decisions are `applyPhoneSchedule`'s; this spends
4634
+ * A phone's request to make, change or remove a schedule (F8). The decisions are `applyPhoneSchedule`'s; this spends
4621
4635
  * the session's rate budget the way a typed command does and reports a refusal the way every command does.
4622
4636
  * Never types anything. A push no one addressed to this machine is not acted on: it changes files, and a
4623
4637
  * push.new reaches every listener of the account.
@@ -45,7 +45,7 @@ export declare const MCP_SERVERS_ENTRY: {
45
45
  */
46
46
  export declare const OPENCODE_MCP_ENTRY: {
47
47
  type: string;
48
- command: ("mcp" | "hwip")[];
48
+ command: ("hwip" | "mcp")[];
49
49
  enabled: boolean;
50
50
  };
51
51
  export declare const GEMINI_MCP_ADD: string;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `hwip schedule` — add, list and remove scheduled sessions (F8).
2
+ * `hwip schedule` — add, list, edit and remove scheduled sessions (F8).
3
3
  *
4
4
  * This only writes and reads the files in `schedule.ts`. The listener starts the sessions
5
5
  * (`schedule-runner.ts`), so nothing here needs a running listener except the sessions firing.
@@ -16,6 +16,11 @@ export interface ScheduleCommandDeps {
16
16
  listenerRunning: () => boolean;
17
17
  /** The agent kinds a prompt can be scheduled for: those whose detection rules can say `idle` (`schedule-ready.ts`). */
18
18
  promptKinds: () => readonly string[];
19
+ /**
20
+ * Has a running listener of an older build replaced (`ensureListenerRunning`): an old one would skip a file it cannot read.
21
+ * False when one still runs after it.
22
+ */
23
+ refreshListener: () => Promise<boolean>;
19
24
  out: (line: string) => void;
20
25
  err: (line: string) => void;
21
26
  }
@@ -27,6 +32,11 @@ interface Built {
27
32
  }
28
33
  /** The schedule `hwip schedule add <words>` asks for, before it has an id. */
29
34
  export declare const buildSchedule: (words: string[], deps: ScheduleCommandDeps) => Promise<Checked<Built>>;
35
+ /**
36
+ * Runs `work` with what it prints going to stderr: the listener refresh reports on stdout (`ensureListenerRunning`),
37
+ * and `hwip schedule edit --json` promises one JSON line there.
38
+ */
39
+ export declare const onStderr: (work: () => Promise<void>) => Promise<void>;
30
40
  /** `words`: argv after `schedule`. Returns the exit code. */
31
41
  export declare const runSchedule: (words: string[], deps: ScheduleCommandDeps) => Promise<number>;
32
42
  export declare const liveScheduleDeps: () => ScheduleCommandDeps;
@@ -1,12 +1,12 @@
1
1
  "use strict";
2
2
  /**
3
- * `hwip schedule` — add, list and remove scheduled sessions (F8).
3
+ * `hwip schedule` — add, list, edit and remove scheduled sessions (F8).
4
4
  *
5
5
  * This only writes and reads the files in `schedule.ts`. The listener starts the sessions
6
6
  * (`schedule-runner.ts`), so nothing here needs a running listener except the sessions firing.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.handleSchedule = exports.liveScheduleDeps = exports.runSchedule = exports.buildSchedule = exports.describeWhen = void 0;
9
+ exports.handleSchedule = exports.liveScheduleDeps = exports.runSchedule = exports.onStderr = exports.buildSchedule = exports.describeWhen = void 0;
10
10
  const args_js_1 = require("./args.js");
11
11
  const schedule_js_1 = require("./schedule.js");
12
12
  const config_js_1 = require("./config.js");
@@ -16,6 +16,7 @@ const schedule_ready_js_1 = require("./schedule-ready.js");
16
16
  const wrapper_js_1 = require("./wrapper.js");
17
17
  const worktree_js_1 = require("./worktree.js");
18
18
  const USAGE = "usage: hwip schedule add <agent> (--at HH:MM [--days daily|weekdays|mon,wed,…] | --every <N>m|<N>h) [--prompt <text>] [--label <x>] [--wt <branch>] [--max-runtime <N>m|<N>h] [-- <agent args…>]\n"
19
+ + ' hwip schedule edit <id> [--at HH:MM] [--days …] [--every <N>m|<N>h] [--prompt <text>] [--max-runtime <N>m|<N>h]\n'
19
20
  + ' hwip schedule [list] [--json]\n'
20
21
  + ' hwip schedule rm <id>';
21
22
  const LISTENER_NOTE = "hwip: schedules start only while the listener runs ('hwip listener --install-service' keeps it running at login)";
@@ -142,7 +143,7 @@ const handleAdd = async (words, json, deps) => {
142
143
  return 2;
143
144
  }
144
145
  const { schedule } = added;
145
- const next = (0, schedule_js_1.nextOccurrence)(schedule.when, deps.now(), Date.parse(schedule.createdAt));
146
+ const next = (0, schedule_js_1.nextOccurrence)(schedule.when, deps.now(), (0, schedule_js_1.scheduleAnchor)(schedule));
146
147
  if (json) {
147
148
  deps.out(JSON.stringify({ ...schedule, nextAt: isoOrNull(next) }));
148
149
  }
@@ -159,7 +160,7 @@ const handleList = (json, deps) => {
159
160
  const state = (0, schedule_js_1.readScheduleState)().schedules;
160
161
  const now = deps.now();
161
162
  const rows = schedules.map((schedule) => {
162
- const next = (0, schedule_js_1.nextOccurrence)(schedule.when, now, Date.parse(schedule.createdAt));
163
+ const next = (0, schedule_js_1.nextOccurrence)(schedule.when, now, (0, schedule_js_1.scheduleAnchor)(schedule));
163
164
  const last = state[schedule.id]?.last;
164
165
  return { schedule, next, last, running: state[schedule.id]?.running !== undefined };
165
166
  });
@@ -216,6 +217,108 @@ const handleRemove = (id, deps) => {
216
217
  deps.out(`removed ${id}. A session it already started keeps running; end it yourself if you want it gone.`);
217
218
  return 0;
218
219
  };
220
+ /**
221
+ * Runs `work` with what it prints going to stderr: the listener refresh reports on stdout (`ensureListenerRunning`),
222
+ * and `hwip schedule edit --json` promises one JSON line there.
223
+ */
224
+ const onStderr = async (work) => {
225
+ const log = console.log;
226
+ console.log = console.error;
227
+ try {
228
+ await work();
229
+ }
230
+ finally {
231
+ console.log = log;
232
+ }
233
+ };
234
+ exports.onStderr = onStderr;
235
+ /** The schedule's new `when` from the flags given: what is not given is kept, so `--at` keeps the days and `--days` the time. */
236
+ const editedWhen = (current, at, days, every) => {
237
+ if (at === undefined && days === undefined && every === undefined)
238
+ return { ok: true, value: undefined };
239
+ if (every !== undefined || at !== undefined) {
240
+ const keptDays = current.kind === 'daily' ? current.days.map((day) => schedule_js_1.DAY_NAMES[day]).join(',') : undefined;
241
+ return (0, schedule_js_1.parseWhen)({ at, every, days: days ?? (every === undefined ? keptDays : undefined) });
242
+ }
243
+ if (current.kind !== 'daily')
244
+ return { ok: false, error: '--days needs --at HH:MM for a schedule that runs --every' };
245
+ return (0, schedule_js_1.parseWhen)({ at: current.at, days });
246
+ };
247
+ const buildChange = (current, flags, deps) => {
248
+ if (flags._arg1)
249
+ return { ok: false, error: `unexpected word '${String(flags._arg1)}'` };
250
+ const fixed = ['label', 'wt'].find((name) => flags[name] !== undefined);
251
+ if (fixed)
252
+ return { ok: false, error: `--${fixed} cannot be edited: the session is named for it; remove the schedule and add it again` };
253
+ const read = (name) => flagText(flags, name);
254
+ const [at, days, every, prompt, maxRuntime] = ['at', 'days', 'every', 'prompt', 'max-runtime'].map(read);
255
+ const bad = [at, days, every, prompt, maxRuntime].find((r) => !r.ok);
256
+ if (bad && !bad.ok)
257
+ return bad;
258
+ const value = (r) => (r.ok ? r.value : undefined);
259
+ const when = editedWhen(current.when, value(at), value(days), value(every));
260
+ if (!when.ok)
261
+ return when;
262
+ const change = when.value === undefined ? {} : { when: when.value };
263
+ const text = value(prompt);
264
+ if (text !== undefined) {
265
+ if (text.trim() === '')
266
+ return { ok: false, error: '--prompt must not be empty' };
267
+ if (!deps.promptKinds().includes(current.agentKind)) {
268
+ return { ok: false, error: `${current.agentKind} cannot take a scheduled --prompt: hwip has no rule that tells when it is idle` };
269
+ }
270
+ change.prompt = text;
271
+ }
272
+ if (value(maxRuntime) !== undefined) {
273
+ const parsed = (0, schedule_js_1.parseDuration)(value(maxRuntime), '--max-runtime', '90m or 2h');
274
+ if (!parsed.ok)
275
+ return parsed;
276
+ change.maxRuntimeMin = parsed.value;
277
+ }
278
+ if (Object.keys(change).length === 0)
279
+ return { ok: false, error: 'edit needs --at, --days, --every, --prompt or --max-runtime' };
280
+ return { ok: true, value: change };
281
+ };
282
+ const handleEdit = async (words, json, deps) => {
283
+ // `parseArgs` reads the words after the program name and script; the first word is the id.
284
+ const flags = (0, args_js_1.parseArgs)(['node', 'hwip', ...words]);
285
+ const id = flags._command === undefined ? '' : String(flags._command);
286
+ if (!(0, schedule_js_1.isScheduleId)(id)) {
287
+ deps.err(id ? `hwip schedule: '${id}' is not a schedule id` : 'hwip schedule: edit needs a schedule id');
288
+ return 2;
289
+ }
290
+ const current = (0, schedule_js_1.listSchedules)().schedules.find((schedule) => schedule.id === id);
291
+ if (!current) {
292
+ deps.err(`hwip schedule: no schedule ${id}`);
293
+ return 1;
294
+ }
295
+ const change = buildChange(current, flags, deps);
296
+ if (!change.ok) {
297
+ deps.err(`hwip schedule: ${change.error}`);
298
+ return 2;
299
+ }
300
+ // A new time writes `updatedAt`, which an older listener does not know: it would skip the file and the schedule would stop.
301
+ if (deps.listenerRunning() && !(await deps.refreshListener()) && change.value.when !== undefined) {
302
+ deps.err('hwip schedule: the listener running is an older hwip that could not be replaced, and it would stop running this schedule: restart it, then edit again');
303
+ return 1;
304
+ }
305
+ const updated = (0, schedule_js_1.updateSchedule)(id, change.value, deps.now());
306
+ if (!updated.ok) {
307
+ deps.err(`hwip schedule: ${updated.error}`);
308
+ return updated.failure === 'missing' ? 1 : 2;
309
+ }
310
+ const { schedule } = updated;
311
+ const next = (0, schedule_js_1.nextOccurrence)(schedule.when, deps.now(), (0, schedule_js_1.scheduleAnchor)(schedule));
312
+ if (json) {
313
+ deps.out(JSON.stringify({ ...schedule, nextAt: isoOrNull(next) }));
314
+ }
315
+ else {
316
+ deps.out(`updated ${schedule.id}: ${(0, exports.describeWhen)(schedule.when)}${next === null ? '' : `, next ${formatTime(next)}`} (ends after ${schedule.maxRuntimeMin}m)`);
317
+ }
318
+ if (!deps.listenerRunning())
319
+ deps.err(LISTENER_NOTE);
320
+ return 0;
321
+ };
219
322
  const JSON_ON = new Set(['--json', '--json=true', '--json=1', '--json=yes']);
220
323
  /** `words`: argv after `schedule`. Returns the exit code. */
221
324
  const runSchedule = async (words, deps) => {
@@ -229,6 +332,8 @@ const runSchedule = async (words, deps) => {
229
332
  switch (sub) {
230
333
  case 'add':
231
334
  return handleAdd(rest, json, deps);
335
+ case 'edit':
336
+ return handleEdit(rest, json, deps);
232
337
  case 'list':
233
338
  if (rest.length > 0)
234
339
  break;
@@ -252,6 +357,10 @@ const liveScheduleDeps = () => ({
252
357
  validBranch: (root, name) => (0, worktree_js_1.isValidBranchName)(root, name),
253
358
  listenerRunning: () => (0, listener_process_js_1.runningListenerPid)() !== null,
254
359
  promptKinds: () => (0, schedule_ready_js_1.promptableScheduleKinds)(remote_agents_js_1.REMOTE_AGENTS.map((a) => a.kind)),
360
+ refreshListener: async () => {
361
+ await (0, exports.onStderr)(() => (0, wrapper_js_1.ensureListenerRunning)(false));
362
+ return (0, listener_process_js_1.runningListenerPid)() === null || !(0, wrapper_js_1.listenerVersionDrifted)((0, listener_process_js_1.runningListenerVersion)(), config_js_1.VERSION);
363
+ },
255
364
  out: (line) => console.log(line),
256
365
  err: (line) => console.error(line),
257
366
  });
@@ -2,7 +2,8 @@
2
2
  * Runs `hwip <agent> --detach …` for a scheduled session, as a child of the listener (F8). The child gets the schedule's
3
3
  * directory as its cwd and none of the project-dir env vars, so it resolves its project from that directory and not from
4
4
  * whatever shell once started the listener; and it is told it is the listener's own, so it does not "repair" the listener
5
- * that is running it (see `LISTENER_LAUNCH_ENV`).
5
+ * that is running it (see `LISTENER_LAUNCH_ENV`). Its PATH also gets the directories the person's shell found agents in
6
+ * (agent-dirs.ts): the service's own PATH has none of them.
6
7
  */
7
8
  import { execFile as nodeExecFile } from 'child_process';
8
9
  import type { LaunchResult } from './schedule-runner.js';
@@ -10,6 +11,7 @@ export interface LaunchDeps {
10
11
  execFile?: typeof nodeExecFile;
11
12
  cliPath?: () => string | null;
12
13
  env?: NodeJS.ProcessEnv;
14
+ agentPath?: (path: string | undefined) => string;
13
15
  }
14
16
  export declare const launchHwip: (argv: string[], cwd: string, timeoutMs: number, deps?: LaunchDeps) => Promise<LaunchResult>;
15
17
  //# sourceMappingURL=schedule-launch.d.ts.map
@@ -3,11 +3,13 @@
3
3
  * Runs `hwip <agent> --detach …` for a scheduled session, as a child of the listener (F8). The child gets the schedule's
4
4
  * directory as its cwd and none of the project-dir env vars, so it resolves its project from that directory and not from
5
5
  * whatever shell once started the listener; and it is told it is the listener's own, so it does not "repair" the listener
6
- * that is running it (see `LISTENER_LAUNCH_ENV`).
6
+ * that is running it (see `LISTENER_LAUNCH_ENV`). Its PATH also gets the directories the person's shell found agents in
7
+ * (agent-dirs.ts): the service's own PATH has none of them.
7
8
  */
8
9
  Object.defineProperty(exports, "__esModule", { value: true });
9
10
  exports.launchHwip = void 0;
10
11
  const child_process_1 = require("child_process");
12
+ const agent_dirs_js_1 = require("./agent-dirs.js");
11
13
  const config_js_1 = require("./config.js");
12
14
  const listener_process_js_1 = require("./listener-process.js");
13
15
  const launchHwip = (argv, cwd, timeoutMs, deps = {}) => new Promise((resolve) => {
@@ -17,6 +19,7 @@ const launchHwip = (argv, cwd, timeoutMs, deps = {}) => new Promise((resolve) =>
17
19
  const env = { ...(deps.env ?? process.env), [listener_process_js_1.LISTENER_LAUNCH_ENV]: '1' };
18
20
  for (const key of config_js_1.PROJECT_DIR_ENV_VARS)
19
21
  delete env[key];
22
+ env.PATH = (deps.agentPath ?? agent_dirs_js_1.withAgentDirs)(env.PATH);
20
23
  (deps.execFile ?? child_process_1.execFile)(process.execPath, [cliPath, ...argv], { cwd, env, encoding: 'utf-8', timeout: timeoutMs }, (err, _stdout, stderr) => {
21
24
  resolve(err ? { ok: false, error: stderr || err.message } : { ok: true });
22
25
  });
@@ -15,6 +15,11 @@ export interface PhoneScheduleItem {
15
15
  session: string;
16
16
  when: ScheduleWhen;
17
17
  prompt?: string;
18
+ /**
19
+ * The phone cannot change the prompt: what is here is not all of it (cut, or left out for room), or the schedule has
20
+ * agent arguments the computer gave. The app shows it and leaves it to `hwip schedule edit`.
21
+ */
22
+ promptLocked?: true;
18
23
  maxRuntimeMin: number;
19
24
  /** ISO time of the next run; null when there is none. */
20
25
  nextAt: string | null;
@@ -70,7 +75,8 @@ export interface PhoneScheduleDeps {
70
75
  export declare const livePhoneScheduleDeps: PhoneScheduleDeps;
71
76
  /**
72
77
  * Carry out one change the phone asked for: `{ op: 'create', when, prompt, agentKind?, maxRuntimeMin? }` for the
73
- * recorded session `session`, or `{ op: 'remove', id }` for the schedule whose session that is. Nothing here
78
+ * recorded session `session`, `{ op: 'update', id, when?, prompt?, maxRuntimeMin? }` or `{ op: 'remove', id }` for the
79
+ * schedule whose session that is. Nothing here
74
80
  * types into a session. `request` is whatever the phone sealed, so every field is checked as it is used.
75
81
  */
76
82
  export declare const applyPhoneSchedule: (request: unknown, session: string, deps?: PhoneScheduleDeps) => PhoneScheduleResult;
@@ -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
  }
@@ -138,6 +147,9 @@ export declare const rememberSessions: (live: Array<{
138
147
  * run's alone to resume — the run forgets it.
139
148
  */
140
149
  agentSessionId?: string | null;
150
+ /** The name the agent gives the run now, and who gave it. None this sweep: the run keeps the one it had. */
151
+ providerSessionName?: string | null;
152
+ providerSessionNameSource?: ProviderNameSource;
141
153
  }>, now?: number) => void;
142
154
  /**
143
155
  * 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 [];
@@ -200,6 +208,12 @@ const rememberSessions = (live, now = Date.now()) => {
200
208
  const tmuxRunIds = continued && s.tmuxRunId
201
209
  ? [...(prev?.tmuxRunIds ?? []).filter((id) => id !== s.tmuxRunId), s.tmuxRunId].slice(-exports.MAX_TMUX_RUNS_PER_RUN)
202
210
  : prev?.tmuxRunIds;
211
+ // The run's name is the last one read: a sweep that read none (the
212
+ // agent has not titled it yet, or its record was unreadable) does not
213
+ // take it away.
214
+ const named = s.providerSessionName
215
+ ? { providerSessionName: s.providerSessionName, providerSessionNameSource: s.providerSessionNameSource }
216
+ : prev;
203
217
  byRun.set(key, {
204
218
  name: s.name,
205
219
  cwd: s.cwd,
@@ -210,6 +224,8 @@ const rememberSessions = (live, now = Date.now()) => {
210
224
  ...(startedAt ? { startedAt } : {}),
211
225
  ...(agentSessionId ? { agentSessionId } : {}),
212
226
  ...(tmuxRunIds?.length ? { tmuxRunIds } : {}),
227
+ ...(named?.providerSessionName ? { providerSessionName: named.providerSessionName } : {}),
228
+ ...(named?.providerSessionName && named.providerSessionNameSource ? { providerSessionNameSource: named.providerSessionNameSource } : {}),
213
229
  lastSeenAt: seenAt,
214
230
  });
215
231
  }
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.js CHANGED
@@ -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");
@@ -530,6 +531,15 @@ const handleAgentSession = async (agent, extra = [], opts = {}, deps = {}) => {
530
531
  console.error(missing);
531
532
  return 127;
532
533
  }
534
+ // Where this shell finds the agent: a schedule launch, which has only the listener's PATH, looks there too (agent-dirs.ts).
535
+ const agentFile = (0, agents_js_1.findOnPath)(agent.binary);
536
+ if (agentFile && !launchedByListener)
537
+ (0, agent_dirs_js_1.rememberAgentDir)(agentFile);
538
+ // tmux would create the session anyway and its pane would die at once, leaving the caller a session that is just gone.
539
+ if (opts.detach && !agentFile) {
540
+ console.error(`hwip: '${agent.binary}' not found on PATH`);
541
+ return 127;
542
+ }
533
543
  // Before the listener starts: a refused `--wt` should cost nothing.
534
544
  if (opts.worktree) {
535
545
  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.5",
4
4
  "description": "Hwip CLI + push notification SDK for AI agents",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",