@hwip/cli 0.1.10 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -645,15 +645,16 @@ went.
645
645
 
646
646
  `hwip schedule add` makes the listener start an agent session by itself — every morning, every two hours — so the phone has something to look at without anyone opening a terminal first. The listener ticks every 30 seconds for as long as it lives, with or without the socket up, so installing it as a login service ([above](#starting-the-listener-at-login)) is what keeps schedules running across restarts.
647
647
 
648
- - **Times** are local. `--at HH:MM` runs on `--days` (daily by default); `--every <N>m|<N>h` counts from the moment the schedule was made, no more often than every 15 minutes. A time that does not exist on the day the clocks go forward runs once at the moved time.
649
- - **A missed run is never started late.** A run is started only if the listener sees it within two minutes of its time. After the computer slept or the listener was down, every time that passed is counted as missed and skipped; the newest one still starts if it is within those two minutes.
648
+ - **Times** are local. `--at HH:MM` runs on `--days` (daily by default); `--every <N>m|<N>h` counts from the moment the schedule was made, no more often than every 15 minutes. A time that does not exist on the day the clocks go forward runs once at the moved time. `--on YYYY-MM-DD --at HH:MM` runs **once**, at that local date and time (stored as an instant, so it does not move with the time zone); a time that has passed, or one the clocks skip that day, is refused. A once schedule removes itself after it has run — when its session has ended and the phone has been told what became of it — or after it was missed.
649
+ - **A missed run is never started late.** A run is started only if the listener sees it within two minutes of its time (thirty minutes for a once schedule: there is no next time to wait for, and "at 3 pm" still means it at 3:20 after the laptop woke up). After the computer slept or the listener was down, every time that passed is counted as missed and skipped; the newest one still starts if it is within those two minutes.
650
650
  - **One session per schedule, three at once.** A run whose session from the last one is still there is skipped (`overlap`), and so is a fourth scheduled session while three run (`busy`). A directory that is gone, an agent command that does not exist, and a launch that fails are skipped too.
651
651
  - **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.
652
652
  - **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`).
653
653
  - **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.
654
- - **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.
654
+ - **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 (or a once schedule's date), `--days` the time, `--on` a once schedule's time of day. A schedule into a session has no `--max-runtime` to change, and its target stays what it was made with. 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.
655
655
  - **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`.
656
- - **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.
656
+ - **Into a running session**: `hwip schedule add --session <name> (--at … | --on … --at … | --every …) --prompt <text>` starts nothing. It types the prompt into a session already running — the run that session holds when the schedule is made (a resume that carries it on, even under another tmux name, is followed; a new run that takes over the name is not). At its time the listener waits **up to 30 minutes** for that run to be idle (its detection rules, as for a scheduled prompt) **and** for no terminal attached to the session to have been used in the last **3 minutes**, so the prompt does not land after words someone is typing. If the wait runs out it is skipped (`session_busy`; `prompt_undelivered` when the prompt would not go in); if the run has ended it fails (`session_ended`, told once while it stays ended — remove or remake the schedule). A tmux that does not answer is waited out, never taken for an ended run. These never count toward the three running sessions, have no `--max-runtime` and are never asked to end: the session is yours, not the schedule's. Agent arguments, `--wt`, `--label` and an agent word are refused with `--session`, and so is an agent without an `idle` rule. **A draft left in the agent's input box for more than 3 minutes is not detected**: the prompt is typed after it and both are submitted together — clear the box, or don't leave one, before a scheduled time.
657
+ - **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. From a session's screen the app makes a schedule **into that session** by default (the computer picks the run the session holds), and offers a one-time date and time; it offers both only to a listener that advertises `schedule-session-v1` (an older one refuses them).
657
658
 
658
659
  ### Session history
659
660
 
@@ -839,7 +840,7 @@ The phone also reads this machine's schedules (see [Scheduled sessions](#schedul
839
840
  { subtype: 'agent.schedule.list.result', requestId, at, schedules | encrypted | error }
840
841
  ```
841
842
 
842
- `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.
843
+ `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. `when` is `{ kind: 'daily', at, days }`, `{ kind: 'every', minutes }` or `{ kind: 'once', at }` (an ISO 8601 instant). A schedule into a session also holds `target: 'session'` and the `runId` it types into, and `sessionEnded: true` when that run is over (absent while it runs, or when tmux could not say). `last.outcome` may be `session_busy` or `session_ended` for those.
843
844
 
844
845
  ### Your API key and redirects
845
846
 
@@ -1211,7 +1212,7 @@ label either, and a request that carries one is refused rather than ignored
1211
1212
  session this machine never recorded; `schedule_failed` when this machine's own
1212
1213
  files would not read or write). A change (`op: 'update'`) carries the schedule's id
1213
1214
  and any of the time, the prompt and the run time; what it leaves out stays as it was,
1214
- 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
1215
+ and a change with none of them is refused. A create with `target: 'session'` types into the session the push names instead of starting one: this machine binds it to the run that session holds now (`unknown_session` when it is not running, `schedule_failed` when tmux or this machine's record of sessions cannot be read) and takes the agent and directory from that run's record; it carries no agent, and a run time it carries is ignored (there is no cap). The target is set on create only — a change that names one is `schedule_invalid`; a run time in a change to a schedule into a session is ignored. Listeners that take `target` and `once` advertise `schedule-session-v1` in their presence `capabilities`. 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
1215
1216
  session, so they cannot reach another. A listener of an older build refuses a change as
1216
1217
  `schedule_invalid`. Applying a request never types into a
1217
1218
  session. The request is sealed like any
@@ -1285,6 +1286,8 @@ hwip worktree clean --dry-run # what clean would remove (no --dry-run: removes i
1285
1286
  # Start an agent session on a timer (the listener starts it, so it has to be running)
1286
1287
  hwip schedule add cc --at 09:00 --days weekdays --prompt "Triage the open issues"
1287
1288
  hwip schedule add codex --every 2h --max-runtime 30m
1289
+ hwip schedule add cc --on 2026-10-24 --at 15:00 --prompt "Rerun the flaky test" # once
1290
+ hwip schedule add --session hwip-api --on 2026-10-24 --at 15:00 --prompt "Carry on with the plan" # into a running session
1288
1291
  hwip schedule # the schedules, with the next run and the last result
1289
1292
  hwip schedule rm k3x9aq # remove one; a session it already started keeps running
1290
1293
 
@@ -1321,7 +1324,7 @@ hwip notify --title "Hello" --json
1321
1324
  | `trust [deviceId]` | Advanced — nothing needs it: every device on the account is pinned the first time hwip seals for it, and a changed key replaces its pin with a warning. Shows this computer's key fingerprint and the devices pinned here — compare each with the fingerprint the app shows on that device's page. It also lists the computers of the account not pinned here yet, with their fingerprints; the first send seals for each and pins it. With a `deviceId`, pin the key the account now lists for that device: prints the pinned and the listed fingerprint and asks first, `--yes` skips the question. `--reset` forgets every pin (each is pinned again as hwip sees it), and until then the listener answers a request for a screen in plaintext. Exit 0 done, 1 refused or declined, 2 not a device id |
1322
1325
  | `cc` · `codex` · `gemini` · `cursor` · `hermes` · `pi` · `opencode` · `kimi` · `grok` · `agy` · `qwen` · `auggie` · `kilo` · `copilot` · `kiro-cli` · `droid` | Run the agent in a `hwip-<project>` tmux session — reattaches a detached session of that project (newest suffix first) when there is one, else auto-suffixes `-2`, `-3`, …. Auto-spawns the background listener on first invocation so the phone picker just works; when that daemon stops within a second and a half (no key, no tmux, a rejected key) it prints the daemon's last log line instead of "autostarted". Once a day it asks npm whether a newer hwip is out and prints the update line (cached in `~/.hwip/update-check.json`; skipped under `CI` or `HWIP_NO_UPDATE_CHECK`). Trailing args pass through to the agent (`hwip cc --resume "..."`). On node 22.15+ the wrapper hands its process to tmux rather than waiting on it, so a running session shows no `hwip cc` of its own in `ps` — older runtimes and Windows keep the waiting wrapper `--detach` / `--label <x>` (front of the args) create without attaching / pin the name; `--wt <name>` runs it in a git worktree made for the session — see "Run agents through the wrapper" above. |
1323
1326
  | `worktree [clean]` | List the git worktrees `--wt` made (path, branch, and `in use` / `clean` / `never used` / what keeps it: uncommitted, untracked or ignored files, commits not pushed). `clean` removes the ones nothing keeps and prints why the others stay; `--dry-run` removes nothing and records nothing. A worktree you made by hand is never listed or removed. Exit 0, also when some stay; `clean` exits 1 when git refused a removal it had cleared or tmux could not be asked (the list still prints, without `in use`); 2 for an unknown subcommand. See "Run agents through the wrapper" for when the listener does the same on its own |
1324
- | `schedule` | Start an agent session on a timer, in the background. The listener starts it, so it has to be running (installing the listener as a login service keeps it up). A run missed while the computer slept or the listener was down is skipped, never started late, and the phone is told. At most 20 schedules and 3 scheduled sessions at once; a session is asked to end after `--max-runtime` (never killed). Schedules live in `~/.local/state/hwip/schedules`. `schedule add <agent> (--at HH:MM \| --every <N>m\|<N>h) [-- <agent args…>]` adds one: `<agent>` is an agent command above (`cc`, `codex`, …) and the project is this one. `schedule [list]` shows them, with the next run and the last result (`--json` for scripts). `schedule rm <id>` removes one; a session it already started keeps running. Options: `--days <days>` (with `--at`: `daily`, `weekdays`, or day names like `mon,wed,fri`), `--prompt <text>` (typed into the session once the agent's status rules say it is idle: Claude Code, Codex, Gemini CLI and pi only), `--label <x>`, `--wt <branch>` (a git worktree for the branch, not with `--label`), `--max-runtime <N>m\|<N>h` (default 2h, at most the `--every` period; 5m to 24h). See *Scheduled sessions* |
1327
+ | `schedule` | Start an agent session on a timer, in the background. The listener starts it, so it has to be running (installing the listener as a login service keeps it up). A run missed while the computer slept or the listener was down is skipped, never started late, and the phone is told. At most 20 schedules and 3 scheduled sessions at once; a session is asked to end after `--max-runtime` (never killed). Schedules live in `~/.local/state/hwip/schedules`. `schedule add <agent> (--at HH:MM \| --on YYYY-MM-DD --at HH:MM \| --every <N>m\|<N>h) [-- <agent args…>]` adds one: `<agent>` is an agent command above (`cc`, `codex`, …) and the project is this one; `--on` runs it once. `schedule add --session <name> (…) --prompt <text>` types the prompt into that running session instead of starting one (waits up to 30 min for it to be idle and its terminal quiet). `schedule [list]` shows them, with the next run and the last result (`--json` for scripts). `schedule rm <id>` removes one; a session it already started keeps running. Options: `--days <days>` (with `--at`: `daily`, `weekdays`, or day names like `mon,wed,fri`), `--prompt <text>` (typed into the session once the agent's status rules say it is idle: Claude Code, Codex, Gemini CLI and pi only), `--label <x>`, `--wt <branch>` (a git worktree for the branch, not with `--label`), `--max-runtime <N>m\|<N>h` (default 2h, at most the `--every` period; 5m to 24h). See *Scheduled sessions* |
1325
1328
  | `sh` | Open (or reattach) `hwip-<project>-sh`, a login shell the phone may type into — the one shell pane the listener does not refuse. `--label <x>` / `--detach` as for the agents; inside tmux it never attaches and prints a `switch-client` hint. Refuses a session of that name it did not open. Input must be addressed to this machine, is audited, and agents (`agent send`, `hwip_agent_send`) skip it — see [Defense](#defense) |
1326
1329
  | `mcp` | Run the MCP server on stdio, in this process. What agent MCP configs launch — `hwip install` registers it and you never type it. Replaces the old `npx -y @hwip/mcp-server` registration, which left an `npm exec` launcher resident alongside the server for the life of the session. `@hwip/mcp-server` ships as a dependency now, so it updates with the CLI rather than being re-fetched by `npx` on every launch — `hwip check-update` reports the version you actually have |
1327
1330
  | `listener` | (Usually unnecessary — `hwip cc` autospawns it.) Resident daemon: subscribes via WebSocket, reports tmux session inventory every 5 s, injects `agent.command` pushes into the matching session. Run in the foreground for SDK development; otherwise let `hwip cc` manage it |
@@ -26,12 +26,15 @@ exports.createDeviceKeyRegistration = exports.LISTENER_PRESENCE = void 0;
26
26
  *
27
27
  * The same frame says what this build opens (`capabilities`): a sender seals
28
28
  * a file in chunks only for a device that lists `file-chunk-v1`, and one over
29
- * the relay's ceiling over LAN only to one that lists `lan-large-v1`. In one frame
29
+ * the relay's ceiling over LAN only to one that lists `lan-large-v1`; an app
30
+ * offers `once` schedules and schedules into a session only to one that lists
31
+ * `schedule-session-v1`. In one frame
30
32
  * with the key, because a server from before `capabilities` answers a frame
31
33
  * that carries nothing else it knows with an error.
32
34
  */
33
35
  const file_crypto_js_1 = require("./file-crypto.js");
34
36
  const lan_receiver_js_1 = require("./lan-receiver.js");
37
+ const schedule_js_1 = require("./schedule.js");
35
38
  /** The one message both halves of presence ride; the server answers
36
39
  * `listener.presence.ack` or `listener.presence.error`. */
37
40
  exports.LISTENER_PRESENCE = 'listener.presence';
@@ -42,7 +45,7 @@ const createDeviceKeyRegistration = (deps) => {
42
45
  const register = async () => {
43
46
  try {
44
47
  const publicKey = await deps.publicKey();
45
- const sent = deps.send({ type: exports.LISTENER_PRESENCE, data: { publicKey, capabilities: [file_crypto_js_1.FILE_CHUNK_CAPABILITY, lan_receiver_js_1.LAN_LARGE_CAPABILITY] } });
48
+ const sent = deps.send({ type: exports.LISTENER_PRESENCE, data: { publicKey, capabilities: [file_crypto_js_1.FILE_CHUNK_CAPABILITY, lan_receiver_js_1.LAN_LARGE_CAPABILITY, schedule_js_1.SCHEDULE_SESSION_CAPABILITY] } });
46
49
  if (!sent)
47
50
  deps.log('device key: not registered — no connection right now, retried at the next open');
48
51
  return sent;
package/dist/listener.js CHANGED
@@ -670,8 +670,10 @@ const worktreeCleanup = (0, listener_worktree_cleanup_js_1.createWorktreeCleanup
670
670
  notify: desktop_notify_js_1.notifyDesktop,
671
671
  tmux: (args, timeoutMs) => (0, worktree_js_1.tmuxRun)((0, tmux_socket_js_1.tmuxArgs)(args), timeoutMs),
672
672
  });
673
+ /** The desktop's key clock (pane-fit.ts): a mouse move over a session's terminal is not someone typing. */
674
+ const desktopKeyClock = (0, pane_fit_js_1.macosLastKeyAt)();
673
675
  /** Sizes a watched window to its viewer while the phone is its latest user (pane-fit.ts). */
674
- const paneFitter = (0, pane_fit_js_1.createPaneFitter)(runTmux, undefined, (0, pane_fit_js_1.macosLastKeyAt)());
676
+ const paneFitter = (0, pane_fit_js_1.createPaneFitter)(runTmux, undefined, desktopKeyClock);
675
677
  /** Test hook. */
676
678
  const resetPaneFitClaims = () => paneFitter.forgetClaims();
677
679
  exports.resetPaneFitClaims = resetPaneFitClaims;
@@ -5819,6 +5821,9 @@ const liveScheduleRunnerDeps = (notify) => ({
5819
5821
  endBySignals(session).then((gone) => (0, listener_log_js_1.log)(`⏻ schedule ${session}: ${gone ? 'ended' : 'still running after the exit signals'}`), (e) => (0, listener_log_js_1.log)(`! schedule ${session}: ending failed: ${e instanceof Error ? e.message : String(e)}`));
5820
5822
  },
5821
5823
  notify,
5824
+ sessionOfRun: (runId, lastName, boundAt) => (0, session_registry_js_1.liveSessionOfRun)(runId, lastName, tmux_liveness_js_1.liveTmuxRuns, Date.now(), boundAt),
5825
+ lastClientActivity: (session) => (0, pane_fit_js_1.lastClientUse)(runTmux, session, desktopKeyClock),
5826
+ removeSchedule: schedule_js_1.removeSchedule,
5822
5827
  });
5823
5828
  exports.liveScheduleRunnerDeps = liveScheduleRunnerDeps;
5824
5829
  const handleListener = async (args) => {
@@ -54,6 +54,12 @@ export type LastKeyAt = (clientPid: number) => number | null;
54
54
  * that came in over SSH types on another machine, so the Mac's clock says nothing about it: null, as off macOS.
55
55
  */
56
56
  export declare const macosLastKeyAt: (now?: () => number, spawn?: typeof spawnSync, platform?: NodeJS.Platform) => LastKeyAt;
57
+ /**
58
+ * When a terminal attached to `session` was last used (`clientUse`), in ms since the epoch; null when none is attached,
59
+ * undefined when tmux could not say. A schedule that types into the session waits while it is recent, so its prompt does
60
+ * not land after someone's half-typed words.
61
+ */
62
+ export declare const lastClientUse: (run: TmuxRun, session: string, lastKeyAt?: LastKeyAt) => number | null | undefined;
57
63
  /** `now` is in ms, as `Date.now`; tmux reports client use in whole seconds. */
58
64
  export declare const createPaneFitter: (run: TmuxRun, now?: () => number, lastKeyAt?: LastKeyAt) => {
59
65
  fit: (session: string, target: string, cols: unknown) => void;
package/dist/pane-fit.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.createPaneFitter = exports.macosLastKeyAt = exports.releaseStaleFits = exports.clampViewerCols = exports.PANE_FIT_MAX_COLS = exports.DESKTOP_IDLE_SECONDS = exports.PANE_FIT_MIN_COLS = void 0;
3
+ exports.createPaneFitter = exports.lastClientUse = exports.macosLastKeyAt = exports.releaseStaleFits = exports.clampViewerCols = exports.PANE_FIT_MAX_COLS = exports.DESKTOP_IDLE_SECONDS = exports.PANE_FIT_MIN_COLS = void 0;
4
4
  const child_process_1 = require("child_process");
5
5
  exports.PANE_FIT_MIN_COLS = 20;
6
6
  /** How long a desktop client must sit idle before opening the mirror takes its window. */
@@ -106,6 +106,20 @@ const clientUse = (client, lastKeyAt) => {
106
106
  const keyAt = lastKeyAt(client.pid);
107
107
  return keyAt === null ? client.activity : Math.max(Math.min(client.activity, keyAt), client.created);
108
108
  };
109
+ /**
110
+ * When a terminal attached to `session` was last used (`clientUse`), in ms since the epoch; null when none is attached,
111
+ * undefined when tmux could not say. A schedule that types into the session waits while it is recent, so its prompt does
112
+ * not land after someone's half-typed words.
113
+ */
114
+ const lastClientUse = (run, session, lastKeyAt = () => null) => {
115
+ const clients = readClients(run, `=${session}`);
116
+ if (clients === null)
117
+ return undefined;
118
+ if (clients.length === 0)
119
+ return null;
120
+ return Math.max(...clients.map((client) => clientUse(client, lastKeyAt))) * 1000;
121
+ };
122
+ exports.lastClientUse = lastClientUse;
109
123
  const readWindow = (run, target) => {
110
124
  const r = run(['display-message', '-p', '-t', target,
111
125
  `#{session_attached}${SEP}#{window_panes}${SEP}#{window_width}`]);
@@ -21,10 +21,17 @@ export interface ScheduleCommandDeps {
21
21
  * False when one still runs after it.
22
22
  */
23
23
  refreshListener: () => Promise<boolean>;
24
+ /** The running session `name` as a schedule types into it: its agent, directory and current run; why not, when it is not running. */
25
+ sessionTarget: (name: string) => Checked<SessionTarget>;
24
26
  out: (line: string) => void;
25
27
  err: (line: string) => void;
26
28
  }
27
- /** `daily 09:00`, `weekdays 09:00`, `mon,wed 09:00`, `every 30m`, `every 2h`. */
29
+ export interface SessionTarget {
30
+ agentKind: string;
31
+ cwd: string;
32
+ runId: string;
33
+ }
34
+ /** `daily 09:00`, `weekdays 09:00`, `mon,wed 09:00`, `every 30m`, `every 2h`, `once 2026-10-24 09:00` (local time). */
28
35
  export declare const describeWhen: (when: ScheduleWhen) => string;
29
36
  interface Built {
30
37
  make: (id: string) => Schedule;
@@ -13,10 +13,13 @@ const config_js_1 = require("./config.js");
13
13
  const listener_process_js_1 = require("./listener-process.js");
14
14
  const remote_agents_js_1 = require("./remote-agents.js");
15
15
  const schedule_ready_js_1 = require("./schedule-ready.js");
16
+ const session_registry_js_1 = require("./session-registry.js");
17
+ const tmux_liveness_js_1 = require("./tmux-liveness.js");
16
18
  const wrapper_js_1 = require("./wrapper.js");
17
19
  const worktree_js_1 = require("./worktree.js");
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'
20
+ const USAGE = "usage: hwip schedule add <agent> (--at HH:MM [--days daily|weekdays|mon,wed,…] | --on YYYY-MM-DD --at HH:MM | --every <N>m|<N>h) [--prompt <text>] [--label <x>] [--wt <branch>] [--max-runtime <N>m|<N>h] [-- <agent args…>]\n"
21
+ + ' hwip schedule add --session <name> (--at HH:MM [--days …] | --on YYYY-MM-DD --at HH:MM | --every <N>m|<N>h) --prompt <text>\n'
22
+ + ' hwip schedule edit <id> [--at HH:MM] [--days …] [--on YYYY-MM-DD] [--every <N>m|<N>h] [--prompt <text>] [--max-runtime <N>m|<N>h]\n'
20
23
  + ' hwip schedule [list] [--json]\n'
21
24
  + ' hwip schedule rm <id>';
22
25
  const LISTENER_NOTE = "hwip: schedules start only while the listener runs ('hwip listener --install-service' keeps it running at login)";
@@ -29,8 +32,17 @@ const flagText = (flags, name) => {
29
32
  return { ok: false, error: `--${name} needs a value` };
30
33
  return { ok: true, value };
31
34
  };
32
- /** `daily 09:00`, `weekdays 09:00`, `mon,wed 09:00`, `every 30m`, `every 2h`. */
35
+ const pad2 = (n) => String(n).padStart(2, '0');
36
+ const formatTime = (ms) => {
37
+ if (!Number.isFinite(new Date(ms).getTime()))
38
+ return '-';
39
+ const d = new Date(ms);
40
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
41
+ };
42
+ /** `daily 09:00`, `weekdays 09:00`, `mon,wed 09:00`, `every 30m`, `every 2h`, `once 2026-10-24 09:00` (local time). */
33
43
  const describeWhen = (when) => {
44
+ if (when.kind === 'once')
45
+ return `once ${formatTime(Date.parse(when.at))}`;
34
46
  if (when.kind === 'every')
35
47
  return when.minutes % 60 === 0 ? `every ${when.minutes / 60}h` : `every ${when.minutes}m`;
36
48
  const days = when.days.join(',');
@@ -38,6 +50,66 @@ const describeWhen = (when) => {
38
50
  return `${label} ${when.at}`;
39
51
  };
40
52
  exports.describeWhen = describeWhen;
53
+ /**
54
+ * `hwip schedule add --session <name> …`: a schedule that types its prompt into a session already running, bound to the
55
+ * run it holds now. Everything that would shape a new session (an agent, `--wt`, `--label`, a runtime cap, agent
56
+ * arguments) has no meaning for it and is refused rather than ignored.
57
+ */
58
+ const buildSessionSchedule = (flags, agentArgs, deps) => {
59
+ const name = flagText(flags, 'session');
60
+ if (!name.ok)
61
+ return name;
62
+ if (flags._command)
63
+ return { ok: false, error: `--session types into a session already running: give no agent ('${String(flags._command)}')` };
64
+ if (agentArgs.length > 0)
65
+ return { ok: false, error: '--session takes no agent arguments: the session is already running' };
66
+ const fixed = ['wt', 'label', 'max-runtime'].find((flag) => flags[flag] !== undefined);
67
+ if (fixed)
68
+ return { ok: false, error: `--${fixed} cannot be combined with --session: the session is already running` };
69
+ const read = (flag) => flagText(flags, flag);
70
+ const [at, days, every, on, prompt] = ['at', 'days', 'every', 'on', 'prompt'].map(read);
71
+ const bad = [at, days, every, on, prompt].find((r) => !r.ok);
72
+ if (bad && !bad.ok)
73
+ return bad;
74
+ const value = (r) => (r.ok ? r.value : undefined);
75
+ const when = (0, schedule_js_1.parseWhen)({ at: value(at), days: value(days), every: value(every), on: value(on) });
76
+ if (!when.ok)
77
+ return when;
78
+ const past = (0, schedule_js_1.pastWhen)(when.value, deps.now());
79
+ if (past)
80
+ return { ok: false, error: `--on ${value(on)} --at ${value(at)}: ${past}` };
81
+ const text = value(prompt);
82
+ if (text === undefined || text.trim() === '')
83
+ return { ok: false, error: '--session needs --prompt: the prompt is what it types into the session' };
84
+ const session = name.value ?? '';
85
+ const target = deps.sessionTarget(session);
86
+ if (!target.ok)
87
+ return target;
88
+ const { agentKind, cwd, runId } = target.value;
89
+ if (!deps.promptKinds().includes(agentKind)) {
90
+ return { ok: false, error: `${agentKind} cannot take a scheduled --prompt: hwip has no rule that tells when it is idle, and a prompt typed into a dialog would answer it` };
91
+ }
92
+ const createdAt = new Date(deps.now()).toISOString();
93
+ const make = (id) => ({
94
+ v: 1,
95
+ id,
96
+ createdAt,
97
+ source: 'cli',
98
+ agentKind,
99
+ cwd,
100
+ label: `sched-${id}`,
101
+ session,
102
+ target: 'session',
103
+ runId,
104
+ prompt: text,
105
+ when: when.value,
106
+ // Kept for the file's shape; a session target has no runtime cap (the session is not the schedule's).
107
+ maxRuntimeMin: (0, schedule_js_1.defaultMaxRuntimeMin)(when.value),
108
+ });
109
+ const probe = (0, schedule_js_1.validateSchedule)(make('aaaaaa'));
110
+ const agentName = remote_agents_js_1.REMOTE_AGENTS.find((a) => a.kind === agentKind)?.displayName ?? agentKind;
111
+ return probe.ok ? { ok: true, value: { make, agentName } } : probe;
112
+ };
41
113
  /** The schedule `hwip schedule add <words>` asks for, before it has an id. */
42
114
  const buildSchedule = async (words, deps) => {
43
115
  const cut = words.indexOf('--');
@@ -45,6 +117,8 @@ const buildSchedule = async (words, deps) => {
45
117
  const agentArgs = cut < 0 ? [] : words.slice(cut + 1);
46
118
  // `parseArgs` reads the words after the program name and script; the first word is the agent.
47
119
  const flags = (0, args_js_1.parseArgs)(['node', 'hwip', ...own]);
120
+ if (flags.session !== undefined)
121
+ return buildSessionSchedule(flags, agentArgs, deps);
48
122
  const agentWord = String(flags._command);
49
123
  if (!agentWord)
50
124
  return { ok: false, error: 'schedule add needs an agent (hwip cc, codex, …)' };
@@ -54,14 +128,17 @@ const buildSchedule = async (words, deps) => {
54
128
  if (flags._arg1)
55
129
  return { ok: false, error: `unexpected word '${String(flags._arg1)}'` };
56
130
  const read = (name) => flagText(flags, name);
57
- const [at, days, every, prompt, label, wt, maxRuntime] = ['at', 'days', 'every', 'prompt', 'label', 'wt', 'max-runtime'].map(read);
58
- const bad = [at, days, every, prompt, label, wt, maxRuntime].find((r) => !r.ok);
131
+ const [at, days, every, on, prompt, label, wt, maxRuntime] = ['at', 'days', 'every', 'on', 'prompt', 'label', 'wt', 'max-runtime'].map(read);
132
+ const bad = [at, days, every, on, prompt, label, wt, maxRuntime].find((r) => !r.ok);
59
133
  if (bad && !bad.ok)
60
134
  return bad;
61
135
  const value = (r) => (r.ok ? r.value : undefined);
62
- const when = (0, schedule_js_1.parseWhen)({ at: value(at), days: value(days), every: value(every) });
136
+ const when = (0, schedule_js_1.parseWhen)({ at: value(at), days: value(days), every: value(every), on: value(on) });
63
137
  if (!when.ok)
64
138
  return when;
139
+ const past = (0, schedule_js_1.pastWhen)(when.value, deps.now());
140
+ if (past)
141
+ return { ok: false, error: `--on ${value(on)} --at ${value(at)}: ${past}` };
65
142
  if (value(prompt) !== undefined && value(prompt).trim() === '')
66
143
  return { ok: false, error: '--prompt must not be empty' };
67
144
  if (value(prompt) !== undefined && !deps.promptKinds().includes(agent.kind)) {
@@ -124,19 +201,19 @@ const buildSchedule = async (words, deps) => {
124
201
  exports.buildSchedule = buildSchedule;
125
202
  /** An ISO date, or null for a time outside what a Date holds (a damaged or hand-edited file). */
126
203
  const isoOrNull = (ms) => (ms !== null && Number.isFinite(new Date(ms).getTime()) ? new Date(ms).toISOString() : null);
127
- const formatTime = (ms) => {
128
- if (!Number.isFinite(new Date(ms).getTime()))
129
- return '-';
130
- const d = new Date(ms);
131
- const pad = (n) => String(n).padStart(2, '0');
132
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
133
- };
134
204
  const handleAdd = async (words, json, deps) => {
135
205
  const built = await (0, exports.buildSchedule)(words, deps);
136
206
  if (!built.ok) {
137
207
  deps.err(`hwip schedule: ${built.error}`);
138
208
  return 2;
139
209
  }
210
+ // A listener older than `once` and session targets would skip the file, and the schedule would never run.
211
+ const probe = built.value.make('aaaaaa');
212
+ const needsNewListener = probe.when.kind === 'once' || probe.target === 'session';
213
+ if (needsNewListener && deps.listenerRunning() && !(await deps.refreshListener())) {
214
+ deps.err('hwip schedule: the listener running is an older hwip that could not be replaced, and it would not run this schedule: restart it, then add again');
215
+ return 1;
216
+ }
140
217
  const added = (0, schedule_js_1.addSchedule)(built.value.make);
141
218
  if (!added.ok) {
142
219
  deps.err(`hwip schedule: ${added.error}`);
@@ -147,6 +224,10 @@ const handleAdd = async (words, json, deps) => {
147
224
  if (json) {
148
225
  deps.out(JSON.stringify({ ...schedule, nextAt: isoOrNull(next) }));
149
226
  }
227
+ else if (schedule.target === 'session') {
228
+ deps.out(`scheduled ${schedule.id}: a prompt into session ${schedule.session} (${built.value.agentName}), ${(0, exports.describeWhen)(schedule.when)}`
229
+ + `${next === null ? '' : `, next ${formatTime(next)}`}. It waits up to 30 min for the session to be idle and nobody typing at its terminal.`);
230
+ }
150
231
  else {
151
232
  deps.out(`scheduled ${schedule.id}: ${built.value.agentName} in ${(0, wrapper_js_1.safeBasename)(schedule.cwd)}, ${(0, exports.describeWhen)(schedule.when)}`
152
233
  + `${next === null ? '' : `, next ${formatTime(next)}`} (session ${schedule.session}, ends after ${schedule.maxRuntimeMin}m)`);
@@ -171,6 +252,7 @@ const handleList = (json, deps) => {
171
252
  cwd: schedule.cwd,
172
253
  label: schedule.label,
173
254
  session: schedule.session,
255
+ ...(schedule.target === 'session' ? { target: schedule.target, runId: schedule.runId } : {}),
174
256
  when: schedule.when,
175
257
  maxRuntimeMin: schedule.maxRuntimeMin,
176
258
  source: schedule.source,
@@ -191,6 +273,7 @@ const handleList = (json, deps) => {
191
273
  (0, exports.describeWhen)(schedule.when),
192
274
  `next ${next === null ? '-' : formatTime(next)}`,
193
275
  `last ${last ? `${last.outcome} ${formatTime(last.at)}` : '-'}${running ? ' (running)' : ''}`,
276
+ ...(schedule.target === 'session' ? [`into ${schedule.session}`] : []),
194
277
  ].join(' '));
195
278
  }
196
279
  }
@@ -232,16 +315,30 @@ const onStderr = async (work) => {
232
315
  }
233
316
  };
234
317
  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)
318
+ /** The local date (`YYYY-MM-DD`) and time (`HH:MM`) of a `once` schedule, as `--on` and `--at` take them. */
319
+ const onceParts = (when) => {
320
+ const d = new Date(when.at);
321
+ return { on: `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`, at: `${pad2(d.getHours())}:${pad2(d.getMinutes())}` };
322
+ };
323
+ /**
324
+ * The schedule's new `when` from the flags given: what is not given is kept, so `--at` keeps the days (or, for a
325
+ * `once`, the date), `--days` the time and `--on` the time of day.
326
+ */
327
+ const editedWhen = (current, input) => {
328
+ const { at, days, every, on } = input;
329
+ if (at === undefined && days === undefined && every === undefined && on === undefined)
238
330
  return { ok: true, value: undefined };
331
+ const keptOnce = current.kind === 'once' && days === undefined && every === undefined;
332
+ if (on !== undefined || keptOnce) {
333
+ const kept = current.kind === 'once' ? onceParts(current) : undefined;
334
+ return (0, schedule_js_1.parseWhen)({ on: on ?? kept?.on, at: at ?? kept?.at, days, every });
335
+ }
239
336
  if (every !== undefined || at !== undefined) {
240
337
  const keptDays = current.kind === 'daily' ? current.days.map((day) => schedule_js_1.DAY_NAMES[day]).join(',') : undefined;
241
338
  return (0, schedule_js_1.parseWhen)({ at, every, days: days ?? (every === undefined ? keptDays : undefined) });
242
339
  }
243
340
  if (current.kind !== 'daily')
244
- return { ok: false, error: '--days needs --at HH:MM for a schedule that runs --every' };
341
+ return { ok: false, error: `--days needs --at HH:MM for a schedule that runs ${current.kind === 'once' ? 'once' : '--every'}` };
245
342
  return (0, schedule_js_1.parseWhen)({ at: current.at, days });
246
343
  };
247
344
  const buildChange = (current, flags, deps) => {
@@ -251,12 +348,12 @@ const buildChange = (current, flags, deps) => {
251
348
  if (fixed)
252
349
  return { ok: false, error: `--${fixed} cannot be edited: the session is named for it; remove the schedule and add it again` };
253
350
  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);
351
+ const [at, days, every, on, prompt, maxRuntime] = ['at', 'days', 'every', 'on', 'prompt', 'max-runtime'].map(read);
352
+ const bad = [at, days, every, on, prompt, maxRuntime].find((r) => !r.ok);
256
353
  if (bad && !bad.ok)
257
354
  return bad;
258
355
  const value = (r) => (r.ok ? r.value : undefined);
259
- const when = editedWhen(current.when, value(at), value(days), value(every));
356
+ const when = editedWhen(current.when, { at: value(at), days: value(days), every: value(every), on: value(on) });
260
357
  if (!when.ok)
261
358
  return when;
262
359
  const change = when.value === undefined ? {} : { when: when.value };
@@ -270,13 +367,15 @@ const buildChange = (current, flags, deps) => {
270
367
  change.prompt = text;
271
368
  }
272
369
  if (value(maxRuntime) !== undefined) {
370
+ if (current.target === 'session')
371
+ return { ok: false, error: '--max-runtime does not apply to a schedule into a session: it has no runtime cap' };
273
372
  const parsed = (0, schedule_js_1.parseDuration)(value(maxRuntime), '--max-runtime', '90m or 2h');
274
373
  if (!parsed.ok)
275
374
  return parsed;
276
375
  change.maxRuntimeMin = parsed.value;
277
376
  }
278
377
  if (Object.keys(change).length === 0)
279
- return { ok: false, error: 'edit needs --at, --days, --every, --prompt or --max-runtime' };
378
+ return { ok: false, error: 'edit needs --at, --days, --on, --every, --prompt or --max-runtime' };
280
379
  return { ok: true, value: change };
281
380
  };
282
381
  const handleEdit = async (words, json, deps) => {
@@ -361,6 +460,18 @@ const liveScheduleDeps = () => ({
361
460
  await (0, exports.onStderr)(() => (0, wrapper_js_1.ensureListenerRunning)(false));
362
461
  return (0, listener_process_js_1.runningListenerPid)() === null || !(0, wrapper_js_1.listenerVersionDrifted)((0, listener_process_js_1.runningListenerVersion)(), config_js_1.VERSION);
363
462
  },
463
+ sessionTarget: (name) => {
464
+ const runId = (0, tmux_liveness_js_1.liveRunOf)(name);
465
+ if (runId === undefined)
466
+ return { ok: false, error: 'tmux did not answer (or gave an answer hwip could not read), so the session cannot be looked up' };
467
+ if (runId === null)
468
+ return { ok: false, error: `${name} is not running ('hwip agent list' shows the sessions)` };
469
+ // The run as the listener wrote it down: its agent and directory, never a guess from an older run of the name.
470
+ const known = (0, session_registry_js_1.recallSession)(name, Date.now(), runId);
471
+ if (!known)
472
+ return { ok: false, error: `${name} is not a session the listener has seen: one hwip did not start, or one started moments ago (try again in a minute)` };
473
+ return { ok: true, value: { agentKind: known.agentKind, cwd: known.cwd, runId } };
474
+ },
364
475
  out: (line) => console.log(line),
365
476
  err: (line) => console.error(line),
366
477
  });
@@ -4,7 +4,7 @@
4
4
  * takes the phone's words at face value for nothing.
5
5
  */
6
6
  import { type ScheduleOutcome, type ScheduleWhen } from './schedule.js';
7
- import { type KnownSession } from './session-registry.js';
7
+ import { type KnownSession, type TmuxRuns } from './session-registry.js';
8
8
  /** One schedule as the app lists it. No directory path and no agent arguments: the phone never names or reads either. */
9
9
  export interface PhoneScheduleItem {
10
10
  id: string;
@@ -27,6 +27,11 @@ export interface PhoneScheduleItem {
27
27
  outcome: ScheduleOutcome;
28
28
  at: string;
29
29
  };
30
+ /** `session`: it types into the run `runId` instead of starting a session (`Schedule.target`). */
31
+ target?: 'session';
32
+ runId?: string;
33
+ /** The run a session target types into is over: nothing will be typed until it is removed. Absent when it is not, or tmux could not say. */
34
+ sessionEnded?: true;
30
35
  }
31
36
  /** The longest prompt the list carries; the full text is in the file on this machine. */
32
37
  export declare const LIST_PROMPT_CHARS = 500;
@@ -38,8 +43,11 @@ export interface ScheduleListing {
38
43
  /** Files that were there but unreadable or invalid (`hwip schedule` says so on stderr). */
39
44
  unreadable: number;
40
45
  }
41
- /** The schedules on this machine for the app, oldest first, prompts cut to `promptChars`. Null when the directory could not be listed or holds more than a machine may: an empty list would then be a lie. */
42
- export declare const phoneScheduleList: (now: number, promptChars?: number) => ScheduleListing | null;
46
+ /**
47
+ * The schedules on this machine for the app, oldest first, prompts cut to `promptChars`. Null when the directory could
48
+ * not be listed or holds more than a machine may: an empty list would then be a lie.
49
+ */
50
+ export declare const phoneScheduleList: (now: number, promptChars?: number, tmux?: TmuxRuns) => ScheduleListing | null;
43
51
  /** The answer to a list request: what the app reads (the sealed text) and the list in it, or why there is none. */
44
52
  export type ScheduleAnswer = {
45
53
  ok: true;
@@ -54,7 +62,7 @@ export type ScheduleAnswer = {
54
62
  * cannot hand an older sealed list back as the answer to a newer request. Prompts are cut shorter, step by
55
63
  * step, until the answer fits a frame; it is an error only when even the list without prompts does not.
56
64
  */
57
- export declare const scheduleAnswer: (now: number, requestId: string, at: string) => ScheduleAnswer;
65
+ export declare const scheduleAnswer: (now: number, requestId: string, at: string, tmux?: TmuxRuns) => ScheduleAnswer;
58
66
  /** The reasons a phone's schedule request is not carried out; they are `agent.command.refused` codes (`command-refusal.ts`). */
59
67
  export type PhoneScheduleRefusal = 'schedule_invalid' | 'schedule_limit' | 'schedule_failed' | 'unknown_session';
60
68
  export type PhoneScheduleResult = {
@@ -71,11 +79,15 @@ export interface PhoneScheduleDeps {
71
79
  directoryExists: (entry: KnownSession) => boolean;
72
80
  /** Whether the agent can be typed a prompt by a schedule (`schedule-ready.ts`). */
73
81
  canPrompt: (agentKind: string) => boolean;
82
+ /** The run tmux session `session` holds now; null when it is not running, undefined when tmux did not answer. */
83
+ currentRun: (session: string) => string | null | undefined;
84
+ /** This machine's record of run `runId` of `session` at `now`; `unreadable` when the record file would not read. */
85
+ recallRunOf: (session: string, runId: string, now: number) => KnownSession | null | 'unreadable';
74
86
  }
75
87
  export declare const livePhoneScheduleDeps: PhoneScheduleDeps;
76
88
  /**
77
89
  * Carry out one change the phone asked for: `{ op: 'create', when, prompt, agentKind?, maxRuntimeMin? }` for the
78
- * recorded session `session`, `{ op: 'update', id, when?, prompt?, maxRuntimeMin? }` or `{ op: 'remove', id }` for the
90
+ * recorded session `session` (or `{ op: 'create', target: 'session', when, prompt }` to type into it), `{ op: 'update', id, when?, prompt?, maxRuntimeMin? }` or `{ op: 'remove', id }` for the
79
91
  * schedule whose session that is. Nothing here
80
92
  * types into a session. `request` is whatever the phone sealed, so every field is checked as it is used.
81
93
  */