@hwip/cli 0.1.10 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -10
- package/dist/device-presence.js +5 -2
- package/dist/listener.js +6 -1
- package/dist/pane-fit.d.ts +6 -0
- package/dist/pane-fit.js +15 -1
- package/dist/schedule-command.d.ts +8 -1
- package/dist/schedule-command.js +132 -21
- package/dist/schedule-remote.d.ts +17 -5
- package/dist/schedule-remote.js +76 -7
- package/dist/schedule-runner.d.ts +25 -1
- package/dist/schedule-runner.js +186 -7
- package/dist/schedule.d.ts +38 -6
- package/dist/schedule.js +91 -11
- package/dist/session-registry.d.ts +33 -2
- package/dist/session-registry.js +52 -3
- package/dist/templates.d.ts +10 -1
- package/dist/templates.js +13 -4
- package/dist/tmux-liveness.d.ts +5 -1
- package/dist/tmux-liveness.js +10 -1
- package/dist/turn-recorder-wire.js +1 -1
- package/dist/turn-recorder.d.ts +2 -2
- package/dist/turn-recorder.js +10 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -239,13 +239,16 @@ back when it writes again.
|
|
|
239
239
|
Detection is exact-match: a terminal keystroke racing a phone message
|
|
240
240
|
can never false-flag. Muted projects are never flagged.
|
|
241
241
|
|
|
242
|
-
Agents without that hook (Cursor, Windsurf, Copilot, Cline, Aider
|
|
242
|
+
Agents without that hook (Cursor, Windsurf, Copilot, Cline, Aider) have one
|
|
243
243
|
other way in: a `hwip_ask` answer reporting `hwipState: "REMOTE"`. Their rule
|
|
244
244
|
files therefore keep the after-real-work `hwip_ask` — the "Entering REMOTE
|
|
245
245
|
without a prompt hook" preamble in `src/templates.ts` — so the phone always
|
|
246
246
|
has a button to tap that starts the loop. The one exception is an answer sent
|
|
247
247
|
with the phone's "send and exit": that turn carries out the instruction and
|
|
248
|
-
ends without an ask.
|
|
248
|
+
ends without an ask. OpenCode has no prompt hook either but leaves the
|
|
249
|
+
preamble out: its after-work ask reached the phone on turns typed at the
|
|
250
|
+
terminal, so it owes no ask in NORMAL, and a phone message to an OpenCode
|
|
251
|
+
session gets the completion push rather than buttons. Agents with the hook drop that
|
|
249
252
|
obligation while at the terminal — and their rule file carries only the
|
|
250
253
|
NORMAL branch of the core (about a quarter of it), since it is read on every
|
|
251
254
|
turn; the hook injects the REMOTE sections in full on the turn a phone
|
|
@@ -645,15 +648,16 @@ went.
|
|
|
645
648
|
|
|
646
649
|
`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
650
|
|
|
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.
|
|
651
|
+
- **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.
|
|
652
|
+
- **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
653
|
- **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
654
|
- **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
655
|
- **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
656
|
- **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.
|
|
657
|
+
- **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
658
|
- **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
|
-
- **
|
|
659
|
+
- **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.
|
|
660
|
+
- **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
661
|
|
|
658
662
|
### Session history
|
|
659
663
|
|
|
@@ -684,7 +688,9 @@ A tmux name is used again when an agent starts in the same folder, so each
|
|
|
684
688
|
chunk also names its **run** — the tmux session it was recorded in (`runId`,
|
|
685
689
|
tmux's `session_created` in epoch seconds). The app uses it to keep one run's
|
|
686
690
|
conversation apart from the one before it under the same name; a chunk read
|
|
687
|
-
after a restart keeps the run it was recorded in.
|
|
691
|
+
after a restart keeps the run it was recorded in. What a session writes with no
|
|
692
|
+
run to name (a name whose tmux session has ended while its file is still written
|
|
693
|
+
to) is not recorded: the server takes no chunk without a run. The listener's record of past
|
|
688
694
|
sessions keeps runs apart the same way: closing `hwip cc` and starting it again
|
|
689
695
|
in the same folder leaves the first run as a past session of its own (the
|
|
690
696
|
newest ten runs of a name are kept, 100 in all, and up to 50 are reported to
|
|
@@ -839,7 +845,7 @@ The phone also reads this machine's schedules (see [Scheduled sessions](#schedul
|
|
|
839
845
|
{ subtype: 'agent.schedule.list.result', requestId, at, schedules | encrypted | error }
|
|
840
846
|
```
|
|
841
847
|
|
|
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.
|
|
848
|
+
`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
849
|
|
|
844
850
|
### Your API key and redirects
|
|
845
851
|
|
|
@@ -1211,7 +1217,7 @@ label either, and a request that carries one is refused rather than ignored
|
|
|
1211
1217
|
session this machine never recorded; `schedule_failed` when this machine's own
|
|
1212
1218
|
files would not read or write). A change (`op: 'update'`) carries the schedule's id
|
|
1213
1219
|
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
|
|
1220
|
+
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
1221
|
session, so they cannot reach another. A listener of an older build refuses a change as
|
|
1216
1222
|
`schedule_invalid`. Applying a request never types into a
|
|
1217
1223
|
session. The request is sealed like any
|
|
@@ -1285,6 +1291,8 @@ hwip worktree clean --dry-run # what clean would remove (no --dry-run: removes i
|
|
|
1285
1291
|
# Start an agent session on a timer (the listener starts it, so it has to be running)
|
|
1286
1292
|
hwip schedule add cc --at 09:00 --days weekdays --prompt "Triage the open issues"
|
|
1287
1293
|
hwip schedule add codex --every 2h --max-runtime 30m
|
|
1294
|
+
hwip schedule add cc --on 2026-10-24 --at 15:00 --prompt "Rerun the flaky test" # once
|
|
1295
|
+
hwip schedule add --session hwip-api --on 2026-10-24 --at 15:00 --prompt "Carry on with the plan" # into a running session
|
|
1288
1296
|
hwip schedule # the schedules, with the next run and the last result
|
|
1289
1297
|
hwip schedule rm k3x9aq # remove one; a session it already started keeps running
|
|
1290
1298
|
|
|
@@ -1321,7 +1329,7 @@ hwip notify --title "Hello" --json
|
|
|
1321
1329
|
| `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
1330
|
| `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
1331
|
| `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* |
|
|
1332
|
+
| `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
1333
|
| `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
1334
|
| `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
1335
|
| `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 |
|
package/dist/device-presence.js
CHANGED
|
@@ -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
|
|
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,
|
|
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) => {
|
package/dist/pane-fit.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/schedule-command.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
236
|
-
const
|
|
237
|
-
|
|
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:
|
|
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
|
-
/**
|
|
42
|
-
|
|
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
|
|
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
|
*/
|