@hwip/cli 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -7
- package/dist/agent-dirs.d.ts +15 -0
- package/dist/agent-dirs.js +76 -0
- package/dist/listener.d.ts +24 -0
- package/dist/listener.js +26 -12
- package/dist/mcp-command.d.ts +1 -1
- package/dist/schedule-command.d.ts +11 -1
- package/dist/schedule-command.js +113 -4
- package/dist/schedule-launch.d.ts +3 -1
- package/dist/schedule-launch.js +4 -1
- package/dist/schedule-remote.d.ts +7 -1
- package/dist/schedule-remote.js +51 -4
- package/dist/schedule-runner.js +5 -2
- package/dist/schedule.d.ts +31 -0
- package/dist/schedule.js +45 -7
- package/dist/session-registry.d.ts +12 -0
- package/dist/session-registry.js +17 -1
- package/dist/usage.js +4 -0
- package/dist/wrapper.js +10 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -645,8 +645,9 @@ went.
|
|
|
645
645
|
- **The prompt** (`--prompt`) is typed once, and only when the agent is the foreground program of the pane and its detection rules read the screen as `idle` — not working, not a dialog. If that does not happen within 90 seconds the prompt is not typed, the session stays, and the phone is told. **Only agents that have an `idle` rule can take a prompt** (today Claude Code, Codex, Gemini CLI and pi): for any other agent nothing separates "ready" from a trust or permission dialog, and the Enter that submits a prompt would answer the dialog's default with nobody there to read it. `hwip schedule add … --prompt` refuses those agents and says which ones work; a schedule without `--prompt` starts any agent. The rules that decide this are the ones shipped in the `hwip` package, not the detection rules the listener updates over the air: a phone's input is typed by someone who has seen the screen, a schedule by nobody, and a rule set that arrives over the air is not signed — it must not be able to call a dialog `idle` and have your prompt and Enter typed into it.
|
|
646
646
|
- **The cap**: a session that runs longer than `--max-runtime` is asked to end the way the phone's *end session* does — the agent's own quit command, then signals. It is never killed; one that stays keeps the schedule from starting its next run (`overlap`).
|
|
647
647
|
- **The phone is told** with one push per listener tick, listing each schedule with how many times and why (`<label> · 놓침 31회` — the push is in Korean). A run skipped because the last one is still going counts once until the result changes; a push that does not go out is tried again after 30 seconds, then 1, 2, 4 … minutes up to 30 (only a refusal by the server, such as the daily limit, is not tried again; a timeout, a proxy or a captive portal in the way is), and is dropped after 24 hours. The push has one title per kind of news — skipped or missed, over its runtime, or a prompt not typed — and a general one when a push holds several kinds. The prompt is never in it. A free account sends at most 100 pushes a day (`pushPerDay`), which is why these are batched.
|
|
648
|
-
- **
|
|
649
|
-
- **
|
|
648
|
+
- **Editing**: `hwip schedule edit <id>` changes the time (`--at`, `--days`, `--every`), `--prompt` and `--max-runtime` of a schedule in place; the id, the session, the directory and the agent stay. What a flag does not name is kept — `--at` keeps the days, `--days` the time. A changed time counts from the edit: the times before it are neither started nor counted as missed, and an `--every` schedule runs one period after the edit; changing only the prompt or the run time keeps the schedule on its times. A session already running keeps going. A listener of an older build is restarted first, because it would skip the edited file as unreadable; when it cannot be replaced, a new time is not written (exit 1) and the schedule keeps running as it was.
|
|
649
|
+
- **State** is files under `~/.local/state/hwip`: one `schedules/<id>.json` per schedule (limit 20) and `schedule-state.json`, written only by the listener, with where each schedule has got to. `agent-dirs.json` holds the directories `hwip <agent>` typed in a shell found its agent in: a listener run as a login service has only the PATH its service file baked, so a scheduled launch adds these to it — run an agent from a shell once (`hwip cc`) before scheduling it, or the run is skipped with `'claude' not found on PATH` in `~/.hwip/listener.log`.
|
|
650
|
+
- **From the phone**: the app lists the schedules of a computer, makes one for a session this computer has run, edits one, and removes one. It names the session, a time and what to do; the directory, the agent's command and its arguments are never the phone's (see *Defense*). A schedule made there needs an agent that can take a prompt, and shows in `hwip schedule` like any other.
|
|
650
651
|
|
|
651
652
|
### Session history
|
|
652
653
|
|
|
@@ -832,7 +833,7 @@ The phone also reads this machine's schedules (see [Scheduled sessions](#schedul
|
|
|
832
833
|
{ subtype: 'agent.schedule.list.result', requestId, at, schedules | encrypted | error }
|
|
833
834
|
```
|
|
834
835
|
|
|
835
|
-
`schedules` is the list as a JSON string; `encrypted` is the list sealed for the asking device (purpose `schedule.list`, bound to this machine's device id) as `{ v: 1, requestId, at, schedules: [...], unreadable? }` — the app checks that the `requestId` inside is the one it asked, so an older sealed list is not taken for a newer answer. `unreadable` is how many schedule files could not be read; a directory that cannot be listed at all, or that holds more than 20 files, is the error `read_failed`, never an empty list (and a remove then fails as `schedule_failed`; `hwip schedule rm` still works on the computer). A list too long for one frame has its prompts shortened, step by step (`too_large` only when it does not fit even without them). Each entry holds `id`, `source`, `agentKind`, `project` (the directory's name, not its path), `session`, `when`, `prompt` (cut to 500 characters, shorter when the list would not fit), `maxRuntimeMin`, `nextAt` and the `last` result — never a directory path or the agent's arguments.
|
|
836
|
+
`schedules` is the list as a JSON string; `encrypted` is the list sealed for the asking device (purpose `schedule.list`, bound to this machine's device id) as `{ v: 1, requestId, at, schedules: [...], unreadable? }` — the app checks that the `requestId` inside is the one it asked, so an older sealed list is not taken for a newer answer. `unreadable` is how many schedule files could not be read; a directory that cannot be listed at all, or that holds more than 20 files, is the error `read_failed`, never an empty list (and a remove then fails as `schedule_failed`; `hwip schedule rm` still works on the computer). A list too long for one frame has its prompts shortened, step by step (`too_large` only when it does not fit even without them). Each entry holds `id`, `source`, `agentKind`, `project` (the directory's name, not its path), `session`, `when`, `prompt` (cut to 500 characters, shorter when the list would not fit; `promptLocked: true` says the phone cannot change it — it is not the whole text, or the schedule has agent arguments — so the app leaves it to `hwip schedule edit`), `maxRuntimeMin`, `nextAt` and the `last` result — never a directory path or the agent's arguments.
|
|
836
837
|
|
|
837
838
|
### Your API key and redirects
|
|
838
839
|
|
|
@@ -972,7 +973,12 @@ handing the listener a key:
|
|
|
972
973
|
session name, its `seq`/`epoch` and that nonce, so a relay that cannot
|
|
973
974
|
swap in a key of its own (below) can't move one to another stream,
|
|
974
975
|
replay one from an earlier subscription, or hand one back as input —
|
|
975
|
-
or as anything else this listener sealed for the phone.
|
|
976
|
+
or as anything else this listener sealed for the phone. A history page
|
|
977
|
+
(scrolling up past the live window) holds the same nonce and `epoch`,
|
|
978
|
+
the request id it answers and where it sits in the scrollback, so a
|
|
979
|
+
relay can't hand an earlier page back under a new request or move
|
|
980
|
+
where the next one is asked for; only the request id rides beside it
|
|
981
|
+
in the clear. Your
|
|
976
982
|
keystrokes are sealed for this listener as `stream.input` with their
|
|
977
983
|
`seq`/`epoch` stamps, the stream's start epoch and the phone's device
|
|
978
984
|
id inside, so a forged restart can't replay them. The start answer (`agent.stream.start.ok`)
|
|
@@ -1188,7 +1194,7 @@ device, takes plain commands as before; an unreadable
|
|
|
1188
1194
|
forgets the pins, and with them goes back to taking plain commands until a
|
|
1189
1195
|
device is pinned again.
|
|
1190
1196
|
|
|
1191
|
-
**A schedule request** — the phone making or removing a scheduled session — rides
|
|
1197
|
+
**A schedule request** — the phone making, changing or removing a scheduled session — rides
|
|
1192
1198
|
the same `agent.command` push, with a `schedule` field and no `body`. It carries
|
|
1193
1199
|
a time, a prompt and, if the phone says so, an agent and a run time — and nothing
|
|
1194
1200
|
else. The directory and the agent come from what this machine recorded for the
|
|
@@ -1197,8 +1203,11 @@ schedule at another directory; it cannot send agent arguments, a worktree or a
|
|
|
1197
1203
|
label either, and a request that carries one is refused rather than ignored
|
|
1198
1204
|
(`schedule_invalid`; `schedule_limit` at 20 schedules; `unknown_session` for a
|
|
1199
1205
|
session this machine never recorded; `schedule_failed` when this machine's own
|
|
1200
|
-
files would not read or write). A
|
|
1201
|
-
|
|
1206
|
+
files would not read or write). A change (`op: 'update'`) carries the schedule's id
|
|
1207
|
+
and any of the time, the prompt and the run time; what it leaves out stays as it was,
|
|
1208
|
+
and a change with none of them is refused. A schedule made on the computer with agent arguments keeps its prompt: the phone may change its time and run time, not what is typed under those arguments. A change and a remove name the schedule's own
|
|
1209
|
+
session, so they cannot reach another. A listener of an older build refuses a change as
|
|
1210
|
+
`schedule_invalid`. Applying a request never types into a
|
|
1202
1211
|
session. The request is sealed like any
|
|
1203
1212
|
command — the whole of it inside the body, only `op` beside it, and the sender,
|
|
1204
1213
|
label and counter checks above apply, so a replay or a swapped `op` changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the person's own shell finds their agents (F8). A schedule is started by the listener, and the listener run by
|
|
3
|
+
* launchd or systemd has only the PATH its service file baked: tmux's and node's directories and the system ones. An
|
|
4
|
+
* agent installed anywhere else — `~/.local/bin/claude` — is not on it, and the scheduled session's pane dies at once
|
|
5
|
+
* with "command not found". So every `hwip <agent>` typed in a shell writes down the directory its agent was found in,
|
|
6
|
+
* and a schedule launch adds those directories to its PATH.
|
|
7
|
+
*
|
|
8
|
+
* Builtins only: wrapper.ts writes this on the `hwip cc` hot path.
|
|
9
|
+
*/
|
|
10
|
+
export declare const agentDirsPath: () => string;
|
|
11
|
+
/** Writes down the directory of `binary`, an absolute path the shell resolved. Best-effort: a launch never fails on it. */
|
|
12
|
+
export declare const rememberAgentDir: (binary: string) => void;
|
|
13
|
+
/** `path` with every written-down directory it lacks appended: what the listener has first, so nothing it runs changes. */
|
|
14
|
+
export declare const withAgentDirs: (path: string | undefined) => string;
|
|
15
|
+
//# sourceMappingURL=agent-dirs.d.ts.map
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Where the person's own shell finds their agents (F8). A schedule is started by the listener, and the listener run by
|
|
4
|
+
* launchd or systemd has only the PATH its service file baked: tmux's and node's directories and the system ones. An
|
|
5
|
+
* agent installed anywhere else — `~/.local/bin/claude` — is not on it, and the scheduled session's pane dies at once
|
|
6
|
+
* with "command not found". So every `hwip <agent>` typed in a shell writes down the directory its agent was found in,
|
|
7
|
+
* and a schedule launch adds those directories to its PATH.
|
|
8
|
+
*
|
|
9
|
+
* Builtins only: wrapper.ts writes this on the `hwip cc` hot path.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.withAgentDirs = exports.rememberAgentDir = exports.agentDirsPath = void 0;
|
|
13
|
+
const node_fs_1 = require("node:fs");
|
|
14
|
+
const node_path_1 = require("node:path");
|
|
15
|
+
const gate_js_1 = require("./gate.js");
|
|
16
|
+
const private_files_js_1 = require("./private-files.js");
|
|
17
|
+
/** More than anyone has agents in; bounds what a schedule launch puts on PATH. */
|
|
18
|
+
const MAX_DIRS = 16;
|
|
19
|
+
const agentDirsPath = () => (0, node_path_1.join)((0, gate_js_1.stateDir)(), 'agent-dirs.json');
|
|
20
|
+
exports.agentDirsPath = agentDirsPath;
|
|
21
|
+
/**
|
|
22
|
+
* Whether the file is the user's own regular file that only they can write: a directory put on PATH by someone else runs
|
|
23
|
+
* their programs as the agent. 'missing' when there is none yet.
|
|
24
|
+
*/
|
|
25
|
+
const fileState = (path) => {
|
|
26
|
+
try {
|
|
27
|
+
const info = (0, node_fs_1.lstatSync)(path);
|
|
28
|
+
const mine = typeof process.getuid !== 'function' || info.uid === process.getuid();
|
|
29
|
+
return info.isFile() && mine && (info.mode & 0o022) === 0 ? 'ok' : 'refused';
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return 'missing';
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const readDirs = () => {
|
|
36
|
+
try {
|
|
37
|
+
const path = (0, exports.agentDirsPath)();
|
|
38
|
+
if (fileState(path) !== 'ok')
|
|
39
|
+
return [];
|
|
40
|
+
const parsed = JSON.parse((0, node_fs_1.readFileSync)(path, 'utf-8'));
|
|
41
|
+
if (!Array.isArray(parsed))
|
|
42
|
+
return [];
|
|
43
|
+
return parsed.filter((dir) => typeof dir === 'string' && (0, node_path_1.isAbsolute)(dir)).slice(0, MAX_DIRS);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
/** Writes down the directory of `binary`, an absolute path the shell resolved. Best-effort: a launch never fails on it. */
|
|
50
|
+
const rememberAgentDir = (binary) => {
|
|
51
|
+
if (!(0, node_path_1.isAbsolute)(binary))
|
|
52
|
+
return;
|
|
53
|
+
const dir = (0, node_path_1.dirname)(binary);
|
|
54
|
+
const path = (0, exports.agentDirsPath)();
|
|
55
|
+
// Never written through a link, nor into a file that is not read back: either would be writing for nothing, or elsewhere.
|
|
56
|
+
if (fileState(path) === 'refused')
|
|
57
|
+
return;
|
|
58
|
+
const dirs = readDirs();
|
|
59
|
+
if (dirs.includes(dir))
|
|
60
|
+
return;
|
|
61
|
+
try {
|
|
62
|
+
(0, private_files_js_1.mkdirHandedBack)((0, gate_js_1.stateDir)(), 0o700);
|
|
63
|
+
(0, node_fs_1.writeFileSync)(path, JSON.stringify([...dirs, dir].slice(-MAX_DIRS)), { mode: 0o600 });
|
|
64
|
+
(0, private_files_js_1.handBackToSudoUser)(path);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// The schedule launch then says the agent is not on PATH, which is the truth.
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
exports.rememberAgentDir = rememberAgentDir;
|
|
71
|
+
/** `path` with every written-down directory it lacks appended: what the listener has first, so nothing it runs changes. */
|
|
72
|
+
const withAgentDirs = (path) => {
|
|
73
|
+
const have = (path ?? '').split(node_path_1.delimiter).filter(Boolean);
|
|
74
|
+
return [...have, ...readDirs().filter((dir) => !have.includes(dir))].join(node_path_1.delimiter);
|
|
75
|
+
};
|
|
76
|
+
exports.withAgentDirs = withAgentDirs;
|
package/dist/listener.d.ts
CHANGED
|
@@ -229,6 +229,9 @@ export interface ReportedKnownSession {
|
|
|
229
229
|
lastSeq?: number;
|
|
230
230
|
/** The later tmux sessions the run went on in (Q35): the server ends another run's push window at their start. */
|
|
231
231
|
tmuxRunIds?: string[];
|
|
232
|
+
/** The last name the agent gave the run, and who gave it (hwip docs/session-runs.md Q39): what the phone calls it once ended. */
|
|
233
|
+
providerSessionName?: string;
|
|
234
|
+
providerSessionNameSource?: ProviderNameSource;
|
|
232
235
|
lastSeenAt: string;
|
|
233
236
|
}
|
|
234
237
|
/** Each live run's first seq, for the report (Q24). A run that has recorded nothing yet has none. */
|
|
@@ -640,6 +643,27 @@ export interface ScreenHistorySnapshot {
|
|
|
640
643
|
error?: string;
|
|
641
644
|
capturedAt: string;
|
|
642
645
|
}
|
|
646
|
+
/**
|
|
647
|
+
* What a sealed history page's envelope holds (as JSON): the page and every
|
|
648
|
+
* number a viewer pages by, bound to the request it answers and to the
|
|
649
|
+
* subscription it was sealed for. On a sealed stream the snapshot carries
|
|
650
|
+
* only `requestId` (for routing) beside this — a relay that hands an earlier
|
|
651
|
+
* page back under a new request, or rewrites where it sits, is caught on open.
|
|
652
|
+
*/
|
|
653
|
+
export type SealedHistoryPage = {
|
|
654
|
+
sessionName: string;
|
|
655
|
+
requestId: string;
|
|
656
|
+
nonce?: string;
|
|
657
|
+
epoch: number;
|
|
658
|
+
before: number;
|
|
659
|
+
lines: number;
|
|
660
|
+
hasMore: boolean;
|
|
661
|
+
truncated: boolean;
|
|
662
|
+
/** The pane's history size when this page was captured, so the viewer can
|
|
663
|
+
* tell how much the history grew between its request and the capture. */
|
|
664
|
+
historySize: number;
|
|
665
|
+
content: string;
|
|
666
|
+
};
|
|
643
667
|
/**
|
|
644
668
|
* Answer a phone's request for one page of scrollback above the live window.
|
|
645
669
|
* Returns true when the message was ours to answer, so the caller stops routing
|
package/dist/listener.js
CHANGED
|
@@ -319,11 +319,13 @@ const knownSessionsToReport = (live, now = Date.now(), seqs = (0, run_seqs_js_1.
|
|
|
319
319
|
...(e.startedAt ? { startedAt: e.startedAt } : {}),
|
|
320
320
|
...(e.runId ? seqs.get((0, run_seqs_js_1.runSeqsKey)(e.name, e.runId)) : undefined),
|
|
321
321
|
...(e.tmuxRunIds?.length ? { tmuxRunIds: e.tmuxRunIds } : {}),
|
|
322
|
+
...(e.providerSessionName ? { providerSessionName: e.providerSessionName } : {}),
|
|
323
|
+
...(e.providerSessionName && e.providerSessionNameSource ? { providerSessionNameSource: e.providerSessionNameSource } : {}),
|
|
322
324
|
lastSeenAt: e.lastSeenAt,
|
|
323
325
|
}));
|
|
324
326
|
};
|
|
325
327
|
exports.knownSessionsToReport = knownSessionsToReport;
|
|
326
|
-
const knownSessionsFingerprint = (known) => known.map((k) => `${k.name}${k.agentKind}${k.runId ?? ''}${k.lastSeenAt}${k.firstSeq ?? ''}${k.lastSeq ?? ''}${k.tmuxRunIds?.join(',') ?? ''}`).sort().join('|');
|
|
328
|
+
const knownSessionsFingerprint = (known) => known.map((k) => `${k.name}${k.agentKind}${k.runId ?? ''}${k.lastSeenAt}${k.firstSeq ?? ''}${k.lastSeq ?? ''}${k.tmuxRunIds?.join(',') ?? ''} ${k.providerSessionNameSource ?? ''} ${k.providerSessionName ?? ''}`).sort().join('|');
|
|
327
329
|
exports.knownSessionsFingerprint = knownSessionsFingerprint;
|
|
328
330
|
const sessionsFingerprint = (sessions) => sessions
|
|
329
331
|
.map((s) => JSON.stringify([
|
|
@@ -1750,11 +1752,11 @@ const handleScreenHistoryRequest = (req, send) => {
|
|
|
1750
1752
|
return reply({ error: 'capture_failed' });
|
|
1751
1753
|
// Everything below this line the caller already has.
|
|
1752
1754
|
const held = before;
|
|
1753
|
-
// Scrolled past the oldest line
|
|
1754
|
-
// view stops, and stopping is not a failure.
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1755
|
+
// Scrolled past the oldest line: an empty page rather than an error — the
|
|
1756
|
+
// view stops, and stopping is not a failure.
|
|
1757
|
+
const captured = depth <= held
|
|
1758
|
+
? { content: '', truncated: false }
|
|
1759
|
+
: capturePaneRange(req.sessionName, -(held + lines), -(held + 1));
|
|
1758
1760
|
if (!captured)
|
|
1759
1761
|
return reply({ error: 'capture_failed' });
|
|
1760
1762
|
const returned = countLines(captured.content);
|
|
@@ -1766,11 +1768,21 @@ const handleScreenHistoryRequest = (req, send) => {
|
|
|
1766
1768
|
};
|
|
1767
1769
|
if (!stream.subscriberPublicKey)
|
|
1768
1770
|
return reply({ ...page, content: captured.content });
|
|
1769
|
-
// Encrypted stream: the page
|
|
1770
|
-
//
|
|
1771
|
-
//
|
|
1772
|
-
|
|
1773
|
-
|
|
1771
|
+
// Encrypted stream: the page and its numbers ride ONLY inside the envelope
|
|
1772
|
+
// — the empty last page too, or a relay could end the paging with one — and
|
|
1773
|
+
// a failed seal is an error rather than a plaintext page, the same rule the
|
|
1774
|
+
// frame path follows when its encrypt fails.
|
|
1775
|
+
const sealed = {
|
|
1776
|
+
sessionName: req.sessionName,
|
|
1777
|
+
requestId: req.requestId,
|
|
1778
|
+
...(stream.nonce ? { nonce: stream.nonce } : {}),
|
|
1779
|
+
epoch: stream.epoch,
|
|
1780
|
+
...page,
|
|
1781
|
+
historySize: depth,
|
|
1782
|
+
content: captured.content,
|
|
1783
|
+
};
|
|
1784
|
+
(0, crypto_js_1.sealV2)('screen.history', JSON.stringify(sealed), stream.subscriberPublicKey, req.sessionName)
|
|
1785
|
+
.then((encrypted) => send({ ...base, encrypted }))
|
|
1774
1786
|
.catch((err) => {
|
|
1775
1787
|
(0, listener_log_js_1.log)(`⧉ history ${req.sessionName}: seal failed (${err instanceof Error ? err.message : err})`);
|
|
1776
1788
|
send({ ...base, error: 'encrypt_failed' });
|
|
@@ -3639,6 +3651,8 @@ const collectSessionsVerbose = () => {
|
|
|
3639
3651
|
startedAt: s.createdAt,
|
|
3640
3652
|
tmuxRunId: runIdOfIso(s.createdAt),
|
|
3641
3653
|
agentSessionId: ownResumeId(s.name),
|
|
3654
|
+
providerSessionName: s.providerSessionName,
|
|
3655
|
+
providerSessionNameSource: s.providerSessionNameSource,
|
|
3642
3656
|
})));
|
|
3643
3657
|
return { sessions, rejected, targets, subagentTranscripts, recordable };
|
|
3644
3658
|
};
|
|
@@ -4617,7 +4631,7 @@ const openSealedCommand = async (push, deps) => {
|
|
|
4617
4631
|
return { text: opened.body ?? '', sender: senderKey, files: named, keys, ifPrompt, schedule };
|
|
4618
4632
|
};
|
|
4619
4633
|
/**
|
|
4620
|
-
* A phone's request to make or remove a schedule (F8). The decisions are `applyPhoneSchedule`'s; this spends
|
|
4634
|
+
* A phone's request to make, change or remove a schedule (F8). The decisions are `applyPhoneSchedule`'s; this spends
|
|
4621
4635
|
* the session's rate budget the way a typed command does and reports a refusal the way every command does.
|
|
4622
4636
|
* Never types anything. A push no one addressed to this machine is not acted on: it changes files, and a
|
|
4623
4637
|
* push.new reaches every listener of the account.
|
package/dist/mcp-command.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `hwip schedule` — add, list and remove scheduled sessions (F8).
|
|
2
|
+
* `hwip schedule` — add, list, edit and remove scheduled sessions (F8).
|
|
3
3
|
*
|
|
4
4
|
* This only writes and reads the files in `schedule.ts`. The listener starts the sessions
|
|
5
5
|
* (`schedule-runner.ts`), so nothing here needs a running listener except the sessions firing.
|
|
@@ -16,6 +16,11 @@ export interface ScheduleCommandDeps {
|
|
|
16
16
|
listenerRunning: () => boolean;
|
|
17
17
|
/** The agent kinds a prompt can be scheduled for: those whose detection rules can say `idle` (`schedule-ready.ts`). */
|
|
18
18
|
promptKinds: () => readonly string[];
|
|
19
|
+
/**
|
|
20
|
+
* Has a running listener of an older build replaced (`ensureListenerRunning`): an old one would skip a file it cannot read.
|
|
21
|
+
* False when one still runs after it.
|
|
22
|
+
*/
|
|
23
|
+
refreshListener: () => Promise<boolean>;
|
|
19
24
|
out: (line: string) => void;
|
|
20
25
|
err: (line: string) => void;
|
|
21
26
|
}
|
|
@@ -27,6 +32,11 @@ interface Built {
|
|
|
27
32
|
}
|
|
28
33
|
/** The schedule `hwip schedule add <words>` asks for, before it has an id. */
|
|
29
34
|
export declare const buildSchedule: (words: string[], deps: ScheduleCommandDeps) => Promise<Checked<Built>>;
|
|
35
|
+
/**
|
|
36
|
+
* Runs `work` with what it prints going to stderr: the listener refresh reports on stdout (`ensureListenerRunning`),
|
|
37
|
+
* and `hwip schedule edit --json` promises one JSON line there.
|
|
38
|
+
*/
|
|
39
|
+
export declare const onStderr: (work: () => Promise<void>) => Promise<void>;
|
|
30
40
|
/** `words`: argv after `schedule`. Returns the exit code. */
|
|
31
41
|
export declare const runSchedule: (words: string[], deps: ScheduleCommandDeps) => Promise<number>;
|
|
32
42
|
export declare const liveScheduleDeps: () => ScheduleCommandDeps;
|
package/dist/schedule-command.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* `hwip schedule` — add, list and remove scheduled sessions (F8).
|
|
3
|
+
* `hwip schedule` — add, list, edit and remove scheduled sessions (F8).
|
|
4
4
|
*
|
|
5
5
|
* This only writes and reads the files in `schedule.ts`. The listener starts the sessions
|
|
6
6
|
* (`schedule-runner.ts`), so nothing here needs a running listener except the sessions firing.
|
|
7
7
|
*/
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
-
exports.handleSchedule = exports.liveScheduleDeps = exports.runSchedule = exports.buildSchedule = exports.describeWhen = void 0;
|
|
9
|
+
exports.handleSchedule = exports.liveScheduleDeps = exports.runSchedule = exports.onStderr = exports.buildSchedule = exports.describeWhen = void 0;
|
|
10
10
|
const args_js_1 = require("./args.js");
|
|
11
11
|
const schedule_js_1 = require("./schedule.js");
|
|
12
12
|
const config_js_1 = require("./config.js");
|
|
@@ -16,6 +16,7 @@ const schedule_ready_js_1 = require("./schedule-ready.js");
|
|
|
16
16
|
const wrapper_js_1 = require("./wrapper.js");
|
|
17
17
|
const worktree_js_1 = require("./worktree.js");
|
|
18
18
|
const USAGE = "usage: hwip schedule add <agent> (--at HH:MM [--days daily|weekdays|mon,wed,…] | --every <N>m|<N>h) [--prompt <text>] [--label <x>] [--wt <branch>] [--max-runtime <N>m|<N>h] [-- <agent args…>]\n"
|
|
19
|
+
+ ' hwip schedule edit <id> [--at HH:MM] [--days …] [--every <N>m|<N>h] [--prompt <text>] [--max-runtime <N>m|<N>h]\n'
|
|
19
20
|
+ ' hwip schedule [list] [--json]\n'
|
|
20
21
|
+ ' hwip schedule rm <id>';
|
|
21
22
|
const LISTENER_NOTE = "hwip: schedules start only while the listener runs ('hwip listener --install-service' keeps it running at login)";
|
|
@@ -142,7 +143,7 @@ const handleAdd = async (words, json, deps) => {
|
|
|
142
143
|
return 2;
|
|
143
144
|
}
|
|
144
145
|
const { schedule } = added;
|
|
145
|
-
const next = (0, schedule_js_1.nextOccurrence)(schedule.when, deps.now(),
|
|
146
|
+
const next = (0, schedule_js_1.nextOccurrence)(schedule.when, deps.now(), (0, schedule_js_1.scheduleAnchor)(schedule));
|
|
146
147
|
if (json) {
|
|
147
148
|
deps.out(JSON.stringify({ ...schedule, nextAt: isoOrNull(next) }));
|
|
148
149
|
}
|
|
@@ -159,7 +160,7 @@ const handleList = (json, deps) => {
|
|
|
159
160
|
const state = (0, schedule_js_1.readScheduleState)().schedules;
|
|
160
161
|
const now = deps.now();
|
|
161
162
|
const rows = schedules.map((schedule) => {
|
|
162
|
-
const next = (0, schedule_js_1.nextOccurrence)(schedule.when, now,
|
|
163
|
+
const next = (0, schedule_js_1.nextOccurrence)(schedule.when, now, (0, schedule_js_1.scheduleAnchor)(schedule));
|
|
163
164
|
const last = state[schedule.id]?.last;
|
|
164
165
|
return { schedule, next, last, running: state[schedule.id]?.running !== undefined };
|
|
165
166
|
});
|
|
@@ -216,6 +217,108 @@ const handleRemove = (id, deps) => {
|
|
|
216
217
|
deps.out(`removed ${id}. A session it already started keeps running; end it yourself if you want it gone.`);
|
|
217
218
|
return 0;
|
|
218
219
|
};
|
|
220
|
+
/**
|
|
221
|
+
* Runs `work` with what it prints going to stderr: the listener refresh reports on stdout (`ensureListenerRunning`),
|
|
222
|
+
* and `hwip schedule edit --json` promises one JSON line there.
|
|
223
|
+
*/
|
|
224
|
+
const onStderr = async (work) => {
|
|
225
|
+
const log = console.log;
|
|
226
|
+
console.log = console.error;
|
|
227
|
+
try {
|
|
228
|
+
await work();
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
console.log = log;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
exports.onStderr = onStderr;
|
|
235
|
+
/** The schedule's new `when` from the flags given: what is not given is kept, so `--at` keeps the days and `--days` the time. */
|
|
236
|
+
const editedWhen = (current, at, days, every) => {
|
|
237
|
+
if (at === undefined && days === undefined && every === undefined)
|
|
238
|
+
return { ok: true, value: undefined };
|
|
239
|
+
if (every !== undefined || at !== undefined) {
|
|
240
|
+
const keptDays = current.kind === 'daily' ? current.days.map((day) => schedule_js_1.DAY_NAMES[day]).join(',') : undefined;
|
|
241
|
+
return (0, schedule_js_1.parseWhen)({ at, every, days: days ?? (every === undefined ? keptDays : undefined) });
|
|
242
|
+
}
|
|
243
|
+
if (current.kind !== 'daily')
|
|
244
|
+
return { ok: false, error: '--days needs --at HH:MM for a schedule that runs --every' };
|
|
245
|
+
return (0, schedule_js_1.parseWhen)({ at: current.at, days });
|
|
246
|
+
};
|
|
247
|
+
const buildChange = (current, flags, deps) => {
|
|
248
|
+
if (flags._arg1)
|
|
249
|
+
return { ok: false, error: `unexpected word '${String(flags._arg1)}'` };
|
|
250
|
+
const fixed = ['label', 'wt'].find((name) => flags[name] !== undefined);
|
|
251
|
+
if (fixed)
|
|
252
|
+
return { ok: false, error: `--${fixed} cannot be edited: the session is named for it; remove the schedule and add it again` };
|
|
253
|
+
const read = (name) => flagText(flags, name);
|
|
254
|
+
const [at, days, every, prompt, maxRuntime] = ['at', 'days', 'every', 'prompt', 'max-runtime'].map(read);
|
|
255
|
+
const bad = [at, days, every, prompt, maxRuntime].find((r) => !r.ok);
|
|
256
|
+
if (bad && !bad.ok)
|
|
257
|
+
return bad;
|
|
258
|
+
const value = (r) => (r.ok ? r.value : undefined);
|
|
259
|
+
const when = editedWhen(current.when, value(at), value(days), value(every));
|
|
260
|
+
if (!when.ok)
|
|
261
|
+
return when;
|
|
262
|
+
const change = when.value === undefined ? {} : { when: when.value };
|
|
263
|
+
const text = value(prompt);
|
|
264
|
+
if (text !== undefined) {
|
|
265
|
+
if (text.trim() === '')
|
|
266
|
+
return { ok: false, error: '--prompt must not be empty' };
|
|
267
|
+
if (!deps.promptKinds().includes(current.agentKind)) {
|
|
268
|
+
return { ok: false, error: `${current.agentKind} cannot take a scheduled --prompt: hwip has no rule that tells when it is idle` };
|
|
269
|
+
}
|
|
270
|
+
change.prompt = text;
|
|
271
|
+
}
|
|
272
|
+
if (value(maxRuntime) !== undefined) {
|
|
273
|
+
const parsed = (0, schedule_js_1.parseDuration)(value(maxRuntime), '--max-runtime', '90m or 2h');
|
|
274
|
+
if (!parsed.ok)
|
|
275
|
+
return parsed;
|
|
276
|
+
change.maxRuntimeMin = parsed.value;
|
|
277
|
+
}
|
|
278
|
+
if (Object.keys(change).length === 0)
|
|
279
|
+
return { ok: false, error: 'edit needs --at, --days, --every, --prompt or --max-runtime' };
|
|
280
|
+
return { ok: true, value: change };
|
|
281
|
+
};
|
|
282
|
+
const handleEdit = async (words, json, deps) => {
|
|
283
|
+
// `parseArgs` reads the words after the program name and script; the first word is the id.
|
|
284
|
+
const flags = (0, args_js_1.parseArgs)(['node', 'hwip', ...words]);
|
|
285
|
+
const id = flags._command === undefined ? '' : String(flags._command);
|
|
286
|
+
if (!(0, schedule_js_1.isScheduleId)(id)) {
|
|
287
|
+
deps.err(id ? `hwip schedule: '${id}' is not a schedule id` : 'hwip schedule: edit needs a schedule id');
|
|
288
|
+
return 2;
|
|
289
|
+
}
|
|
290
|
+
const current = (0, schedule_js_1.listSchedules)().schedules.find((schedule) => schedule.id === id);
|
|
291
|
+
if (!current) {
|
|
292
|
+
deps.err(`hwip schedule: no schedule ${id}`);
|
|
293
|
+
return 1;
|
|
294
|
+
}
|
|
295
|
+
const change = buildChange(current, flags, deps);
|
|
296
|
+
if (!change.ok) {
|
|
297
|
+
deps.err(`hwip schedule: ${change.error}`);
|
|
298
|
+
return 2;
|
|
299
|
+
}
|
|
300
|
+
// A new time writes `updatedAt`, which an older listener does not know: it would skip the file and the schedule would stop.
|
|
301
|
+
if (deps.listenerRunning() && !(await deps.refreshListener()) && change.value.when !== undefined) {
|
|
302
|
+
deps.err('hwip schedule: the listener running is an older hwip that could not be replaced, and it would stop running this schedule: restart it, then edit again');
|
|
303
|
+
return 1;
|
|
304
|
+
}
|
|
305
|
+
const updated = (0, schedule_js_1.updateSchedule)(id, change.value, deps.now());
|
|
306
|
+
if (!updated.ok) {
|
|
307
|
+
deps.err(`hwip schedule: ${updated.error}`);
|
|
308
|
+
return updated.failure === 'missing' ? 1 : 2;
|
|
309
|
+
}
|
|
310
|
+
const { schedule } = updated;
|
|
311
|
+
const next = (0, schedule_js_1.nextOccurrence)(schedule.when, deps.now(), (0, schedule_js_1.scheduleAnchor)(schedule));
|
|
312
|
+
if (json) {
|
|
313
|
+
deps.out(JSON.stringify({ ...schedule, nextAt: isoOrNull(next) }));
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
deps.out(`updated ${schedule.id}: ${(0, exports.describeWhen)(schedule.when)}${next === null ? '' : `, next ${formatTime(next)}`} (ends after ${schedule.maxRuntimeMin}m)`);
|
|
317
|
+
}
|
|
318
|
+
if (!deps.listenerRunning())
|
|
319
|
+
deps.err(LISTENER_NOTE);
|
|
320
|
+
return 0;
|
|
321
|
+
};
|
|
219
322
|
const JSON_ON = new Set(['--json', '--json=true', '--json=1', '--json=yes']);
|
|
220
323
|
/** `words`: argv after `schedule`. Returns the exit code. */
|
|
221
324
|
const runSchedule = async (words, deps) => {
|
|
@@ -229,6 +332,8 @@ const runSchedule = async (words, deps) => {
|
|
|
229
332
|
switch (sub) {
|
|
230
333
|
case 'add':
|
|
231
334
|
return handleAdd(rest, json, deps);
|
|
335
|
+
case 'edit':
|
|
336
|
+
return handleEdit(rest, json, deps);
|
|
232
337
|
case 'list':
|
|
233
338
|
if (rest.length > 0)
|
|
234
339
|
break;
|
|
@@ -252,6 +357,10 @@ const liveScheduleDeps = () => ({
|
|
|
252
357
|
validBranch: (root, name) => (0, worktree_js_1.isValidBranchName)(root, name),
|
|
253
358
|
listenerRunning: () => (0, listener_process_js_1.runningListenerPid)() !== null,
|
|
254
359
|
promptKinds: () => (0, schedule_ready_js_1.promptableScheduleKinds)(remote_agents_js_1.REMOTE_AGENTS.map((a) => a.kind)),
|
|
360
|
+
refreshListener: async () => {
|
|
361
|
+
await (0, exports.onStderr)(() => (0, wrapper_js_1.ensureListenerRunning)(false));
|
|
362
|
+
return (0, listener_process_js_1.runningListenerPid)() === null || !(0, wrapper_js_1.listenerVersionDrifted)((0, listener_process_js_1.runningListenerVersion)(), config_js_1.VERSION);
|
|
363
|
+
},
|
|
255
364
|
out: (line) => console.log(line),
|
|
256
365
|
err: (line) => console.error(line),
|
|
257
366
|
});
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Runs `hwip <agent> --detach …` for a scheduled session, as a child of the listener (F8). The child gets the schedule's
|
|
3
3
|
* directory as its cwd and none of the project-dir env vars, so it resolves its project from that directory and not from
|
|
4
4
|
* whatever shell once started the listener; and it is told it is the listener's own, so it does not "repair" the listener
|
|
5
|
-
* that is running it (see `LISTENER_LAUNCH_ENV`).
|
|
5
|
+
* that is running it (see `LISTENER_LAUNCH_ENV`). Its PATH also gets the directories the person's shell found agents in
|
|
6
|
+
* (agent-dirs.ts): the service's own PATH has none of them.
|
|
6
7
|
*/
|
|
7
8
|
import { execFile as nodeExecFile } from 'child_process';
|
|
8
9
|
import type { LaunchResult } from './schedule-runner.js';
|
|
@@ -10,6 +11,7 @@ export interface LaunchDeps {
|
|
|
10
11
|
execFile?: typeof nodeExecFile;
|
|
11
12
|
cliPath?: () => string | null;
|
|
12
13
|
env?: NodeJS.ProcessEnv;
|
|
14
|
+
agentPath?: (path: string | undefined) => string;
|
|
13
15
|
}
|
|
14
16
|
export declare const launchHwip: (argv: string[], cwd: string, timeoutMs: number, deps?: LaunchDeps) => Promise<LaunchResult>;
|
|
15
17
|
//# sourceMappingURL=schedule-launch.d.ts.map
|
package/dist/schedule-launch.js
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
* Runs `hwip <agent> --detach …` for a scheduled session, as a child of the listener (F8). The child gets the schedule's
|
|
4
4
|
* directory as its cwd and none of the project-dir env vars, so it resolves its project from that directory and not from
|
|
5
5
|
* whatever shell once started the listener; and it is told it is the listener's own, so it does not "repair" the listener
|
|
6
|
-
* that is running it (see `LISTENER_LAUNCH_ENV`).
|
|
6
|
+
* that is running it (see `LISTENER_LAUNCH_ENV`). Its PATH also gets the directories the person's shell found agents in
|
|
7
|
+
* (agent-dirs.ts): the service's own PATH has none of them.
|
|
7
8
|
*/
|
|
8
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
10
|
exports.launchHwip = void 0;
|
|
10
11
|
const child_process_1 = require("child_process");
|
|
12
|
+
const agent_dirs_js_1 = require("./agent-dirs.js");
|
|
11
13
|
const config_js_1 = require("./config.js");
|
|
12
14
|
const listener_process_js_1 = require("./listener-process.js");
|
|
13
15
|
const launchHwip = (argv, cwd, timeoutMs, deps = {}) => new Promise((resolve) => {
|
|
@@ -17,6 +19,7 @@ const launchHwip = (argv, cwd, timeoutMs, deps = {}) => new Promise((resolve) =>
|
|
|
17
19
|
const env = { ...(deps.env ?? process.env), [listener_process_js_1.LISTENER_LAUNCH_ENV]: '1' };
|
|
18
20
|
for (const key of config_js_1.PROJECT_DIR_ENV_VARS)
|
|
19
21
|
delete env[key];
|
|
22
|
+
env.PATH = (deps.agentPath ?? agent_dirs_js_1.withAgentDirs)(env.PATH);
|
|
20
23
|
(deps.execFile ?? child_process_1.execFile)(process.execPath, [cliPath, ...argv], { cwd, env, encoding: 'utf-8', timeout: timeoutMs }, (err, _stdout, stderr) => {
|
|
21
24
|
resolve(err ? { ok: false, error: stderr || err.message } : { ok: true });
|
|
22
25
|
});
|
|
@@ -15,6 +15,11 @@ export interface PhoneScheduleItem {
|
|
|
15
15
|
session: string;
|
|
16
16
|
when: ScheduleWhen;
|
|
17
17
|
prompt?: string;
|
|
18
|
+
/**
|
|
19
|
+
* The phone cannot change the prompt: what is here is not all of it (cut, or left out for room), or the schedule has
|
|
20
|
+
* agent arguments the computer gave. The app shows it and leaves it to `hwip schedule edit`.
|
|
21
|
+
*/
|
|
22
|
+
promptLocked?: true;
|
|
18
23
|
maxRuntimeMin: number;
|
|
19
24
|
/** ISO time of the next run; null when there is none. */
|
|
20
25
|
nextAt: string | null;
|
|
@@ -70,7 +75,8 @@ export interface PhoneScheduleDeps {
|
|
|
70
75
|
export declare const livePhoneScheduleDeps: PhoneScheduleDeps;
|
|
71
76
|
/**
|
|
72
77
|
* Carry out one change the phone asked for: `{ op: 'create', when, prompt, agentKind?, maxRuntimeMin? }` for the
|
|
73
|
-
* recorded session `session`,
|
|
78
|
+
* recorded session `session`, `{ op: 'update', id, when?, prompt?, maxRuntimeMin? }` or `{ op: 'remove', id }` for the
|
|
79
|
+
* schedule whose session that is. Nothing here
|
|
74
80
|
* types into a session. `request` is whatever the phone sealed, so every field is checked as it is used.
|
|
75
81
|
*/
|
|
76
82
|
export declare const applyPhoneSchedule: (request: unknown, session: string, deps?: PhoneScheduleDeps) => PhoneScheduleResult;
|
package/dist/schedule-remote.js
CHANGED
|
@@ -25,6 +25,12 @@ const clip = (text, max) => {
|
|
|
25
25
|
const last = cut.charCodeAt(cut.length - 1);
|
|
26
26
|
return `${last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut}…`;
|
|
27
27
|
};
|
|
28
|
+
const listedPrompt = (schedule, max) => {
|
|
29
|
+
const { prompt } = schedule;
|
|
30
|
+
const listed = prompt !== undefined && max > 0 ? clip(prompt, max) : undefined;
|
|
31
|
+
const locked = listed !== prompt || schedule.agentArgs !== undefined;
|
|
32
|
+
return { ...(listed === undefined ? {} : { prompt: listed }), ...(locked ? { promptLocked: true } : {}) };
|
|
33
|
+
};
|
|
28
34
|
const isoOrNull = (ms) => {
|
|
29
35
|
if (ms === null)
|
|
30
36
|
return null;
|
|
@@ -47,9 +53,9 @@ const phoneScheduleList = (now, promptChars = exports.LIST_PROMPT_CHARS) => {
|
|
|
47
53
|
project: (0, node_path_1.basename)(schedule.cwd),
|
|
48
54
|
session: schedule.session,
|
|
49
55
|
when: schedule.when,
|
|
50
|
-
...(schedule
|
|
56
|
+
...listedPrompt(schedule, promptChars),
|
|
51
57
|
maxRuntimeMin: schedule.maxRuntimeMin,
|
|
52
|
-
nextAt: isoOrNull((0, schedule_js_1.nextOccurrence)(schedule.when, now,
|
|
58
|
+
nextAt: isoOrNull((0, schedule_js_1.nextOccurrence)(schedule.when, now, (0, schedule_js_1.scheduleAnchor)(schedule))),
|
|
53
59
|
...(last && lastAt ? { last: { outcome: last.outcome, at: lastAt } } : {}),
|
|
54
60
|
};
|
|
55
61
|
});
|
|
@@ -144,9 +150,48 @@ const remove = (request, session) => {
|
|
|
144
150
|
const removed = (0, schedule_js_1.removeSchedule)(id);
|
|
145
151
|
return removed === 'invalid' ? refuse('schedule_invalid', 'not a schedule id') : { ok: true };
|
|
146
152
|
};
|
|
153
|
+
const UPDATE_KEYS = new Set(['op', 'id', 'when', 'prompt', 'maxRuntimeMin']);
|
|
154
|
+
const update = (request, session, deps) => {
|
|
155
|
+
const extra = Object.keys(request).find((k) => !UPDATE_KEYS.has(k));
|
|
156
|
+
if (extra !== undefined)
|
|
157
|
+
return refuse('schedule_invalid', `an update carries ${extra}`);
|
|
158
|
+
const { id, when, prompt, maxRuntimeMin } = request;
|
|
159
|
+
if (typeof id !== 'string')
|
|
160
|
+
return refuse('schedule_invalid', 'an update needs an id');
|
|
161
|
+
const read = (0, schedule_js_1.listSchedules)();
|
|
162
|
+
if (read.incomplete)
|
|
163
|
+
return refuse('schedule_failed', 'the schedules could not be listed');
|
|
164
|
+
// As for a remove: the push names the schedule's own session, so it cannot change another one.
|
|
165
|
+
const target = read.schedules.find((schedule) => schedule.id === id);
|
|
166
|
+
if (!target || target.session !== session)
|
|
167
|
+
return refuse('schedule_invalid', 'no such schedule for that session');
|
|
168
|
+
// A prompt the list cut is left out by the app, so a missing one keeps the file's whole text.
|
|
169
|
+
// Arguments the computer gave (a permissions bypass, say) were chosen for its own prompt: the phone cannot put another under them.
|
|
170
|
+
if (prompt !== undefined && target.agentArgs !== undefined)
|
|
171
|
+
return refuse('schedule_invalid', 'a schedule with agent arguments takes its prompt from hwip schedule edit');
|
|
172
|
+
// As `hwip schedule edit`: a prompt of spaces would be typed as nothing.
|
|
173
|
+
if (typeof prompt === 'string' && prompt.trim() === '')
|
|
174
|
+
return refuse('schedule_invalid', 'the prompt is empty');
|
|
175
|
+
if (prompt !== undefined && !deps.canPrompt(target.agentKind))
|
|
176
|
+
return refuse('schedule_invalid', 'that agent cannot take a scheduled prompt');
|
|
177
|
+
// Unchecked here: `updateSchedule` validates the whole schedule they make before it writes.
|
|
178
|
+
const change = {};
|
|
179
|
+
if (when !== undefined)
|
|
180
|
+
change.when = when;
|
|
181
|
+
if (prompt !== undefined)
|
|
182
|
+
change.prompt = prompt;
|
|
183
|
+
if (maxRuntimeMin !== undefined)
|
|
184
|
+
change.maxRuntimeMin = maxRuntimeMin;
|
|
185
|
+
const updated = (0, schedule_js_1.updateSchedule)(id, change, deps.now());
|
|
186
|
+
if (updated.ok)
|
|
187
|
+
return { ok: true };
|
|
188
|
+
// Gone between the list and the write is the same as never there for this session.
|
|
189
|
+
return refuse(updated.failure === 'io' ? 'schedule_failed' : 'schedule_invalid', updated.error);
|
|
190
|
+
};
|
|
147
191
|
/**
|
|
148
192
|
* Carry out one change the phone asked for: `{ op: 'create', when, prompt, agentKind?, maxRuntimeMin? }` for the
|
|
149
|
-
* recorded session `session`,
|
|
193
|
+
* recorded session `session`, `{ op: 'update', id, when?, prompt?, maxRuntimeMin? }` or `{ op: 'remove', id }` for the
|
|
194
|
+
* schedule whose session that is. Nothing here
|
|
150
195
|
* types into a session. `request` is whatever the phone sealed, so every field is checked as it is used.
|
|
151
196
|
*/
|
|
152
197
|
const applyPhoneSchedule = (request, session, deps = exports.livePhoneScheduleDeps) => {
|
|
@@ -155,6 +200,8 @@ const applyPhoneSchedule = (request, session, deps = exports.livePhoneScheduleDe
|
|
|
155
200
|
try {
|
|
156
201
|
if (request.op === 'create')
|
|
157
202
|
return create(request, session, deps);
|
|
203
|
+
if (request.op === 'update')
|
|
204
|
+
return update(request, session, deps);
|
|
158
205
|
if (request.op === 'remove')
|
|
159
206
|
return remove(request, session);
|
|
160
207
|
}
|
|
@@ -162,6 +209,6 @@ const applyPhoneSchedule = (request, session, deps = exports.livePhoneScheduleDe
|
|
|
162
209
|
// A file that would not read or write is this machine's trouble, not a mistake in the request.
|
|
163
210
|
return refuse('schedule_failed', err instanceof Error ? err.message : String(err));
|
|
164
211
|
}
|
|
165
|
-
return refuse('schedule_invalid', 'op is
|
|
212
|
+
return refuse('schedule_invalid', 'op is not create, update or remove');
|
|
166
213
|
};
|
|
167
214
|
exports.applyPhoneSchedule = applyPhoneSchedule;
|
package/dist/schedule-runner.js
CHANGED
|
@@ -142,7 +142,10 @@ const createScheduleRunner = (deps) => {
|
|
|
142
142
|
for (const schedule of schedules) {
|
|
143
143
|
// No record of a schedule in a damaged file may mean "nothing ran" or "the record was lost": start from now, so
|
|
144
144
|
// neither a pile of old runs is counted as missed nor a run that already happened is started again.
|
|
145
|
-
|
|
145
|
+
// An edit restarts the count: the times before it belong to the schedule as it was.
|
|
146
|
+
const prior = previous[schedule.id];
|
|
147
|
+
const anchor = (0, schedule_js_1.scheduleAnchor)(schedule);
|
|
148
|
+
state[schedule.id] = { ...prior, cursor: Math.max(prior?.cursor ?? (file.damaged ? tickNow : anchor), anchor) };
|
|
146
149
|
}
|
|
147
150
|
// A state entry whose schedule file is gone is dropped — but only when the list is whole: a file that could not be
|
|
148
151
|
// read this time is still there, and losing its cursor would repeat or miscount runs. A session that ended on its own is no longer running.
|
|
@@ -206,7 +209,7 @@ const createScheduleRunner = (deps) => {
|
|
|
206
209
|
for (const schedule of schedules) {
|
|
207
210
|
const st = state[schedule.id];
|
|
208
211
|
const now = tickNow + (clock() - tickStart);
|
|
209
|
-
const due = (0, schedule_js_1.occurrencesBetween)(schedule.when, st.cursor, now,
|
|
212
|
+
const due = (0, schedule_js_1.occurrencesBetween)(schedule.when, st.cursor, now, (0, schedule_js_1.scheduleAnchor)(schedule));
|
|
210
213
|
if (due.count === 0 || due.latest === null)
|
|
211
214
|
continue;
|
|
212
215
|
const runs = now - due.latest <= exports.GRACE_MS;
|
package/dist/schedule.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface Schedule {
|
|
|
21
21
|
/** Lowercase base36, 6 characters. It becomes a file name, so nothing else is accepted. */
|
|
22
22
|
id: string;
|
|
23
23
|
createdAt: string;
|
|
24
|
+
/** Set by `updateSchedule` when an edit changes `when`: the schedule counts its runs from here, not from `createdAt` (see `scheduleAnchor`). */
|
|
25
|
+
updatedAt?: string;
|
|
24
26
|
source: 'cli' | 'phone';
|
|
25
27
|
/** `REMOTE_AGENTS` kind. Checked against the table when it runs, not here: a hand-edited file is the runner's to refuse. */
|
|
26
28
|
agentKind: string;
|
|
@@ -75,6 +77,7 @@ export interface Occurrences {
|
|
|
75
77
|
export declare const occurrencesBetween: (when: ScheduleWhen, after: number, until: number, anchor: number) => Occurrences;
|
|
76
78
|
/** The first time after `after` the schedule comes due; null for a `daily` with no days (a file `validateSchedule` would refuse). */
|
|
77
79
|
export declare const nextOccurrence: (when: ScheduleWhen, after: number, anchor: number) => number | null;
|
|
80
|
+
export declare const isScheduleId: (value: unknown) => value is string;
|
|
78
81
|
/**
|
|
79
82
|
* Whether `input` is a schedule this machine will run. The file reader, `hwip schedule add` and (in
|
|
80
83
|
* the remote PR) the phone's request all pass through here, so a limit lives in one place. Unknown
|
|
@@ -105,6 +108,34 @@ export type AddResult = {
|
|
|
105
108
|
* is drawn.
|
|
106
109
|
*/
|
|
107
110
|
export declare const addSchedule: (make: (id: string) => Schedule, newId?: () => string) => AddResult;
|
|
111
|
+
/** What an edit may change. Everything else — the session, the directory, the agent — is what the schedule is. */
|
|
112
|
+
export interface ScheduleChange {
|
|
113
|
+
when?: ScheduleWhen;
|
|
114
|
+
prompt?: string;
|
|
115
|
+
maxRuntimeMin?: number;
|
|
116
|
+
}
|
|
117
|
+
/** A change as it arrives from outside (the phone): its values are checked with the schedule they make. */
|
|
118
|
+
export type UncheckedScheduleChange = {
|
|
119
|
+
[K in keyof ScheduleChange]?: unknown;
|
|
120
|
+
};
|
|
121
|
+
/** `missing` — no such schedule; `invalid` — the id or the changed schedule was refused; `io` — the file could not be written. */
|
|
122
|
+
export type UpdateResult = {
|
|
123
|
+
ok: true;
|
|
124
|
+
schedule: Schedule;
|
|
125
|
+
} | {
|
|
126
|
+
ok: false;
|
|
127
|
+
error: string;
|
|
128
|
+
failure: 'missing' | 'invalid' | 'io';
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* Change a schedule in place. The result passes `validateSchedule` before it is written, and is written whole (a
|
|
132
|
+
* listener reading it at the same time sees the old file or the new one). A new `when` sets `updatedAt` to `now`:
|
|
133
|
+
* the runner counts the edited schedule's runs from then, so the times it had before are neither run nor counted as
|
|
134
|
+
* missed. A change of the prompt or the run time only keeps the count where it was — an hourly run stays on its hour.
|
|
135
|
+
*/
|
|
136
|
+
export declare const updateSchedule: (id: string, change: UncheckedScheduleChange, now: number) => UpdateResult;
|
|
137
|
+
/** The time a schedule's runs are counted from: its last edit, else its creation. */
|
|
138
|
+
export declare const scheduleAnchor: (schedule: Schedule) => number;
|
|
108
139
|
/** `invalid` means the id could not name a schedule file; nothing was touched. */
|
|
109
140
|
export type RemoveResult = 'removed' | 'missing' | 'invalid';
|
|
110
141
|
export declare const removeSchedule: (id: string) => RemoveResult;
|
package/dist/schedule.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Everything here is pure or touches only that directory. The clock is a parameter.
|
|
11
11
|
*/
|
|
12
12
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
-
exports.writeScheduleState = exports.readScheduleState = exports.scheduleStatePath = exports.removeSchedule = exports.addSchedule = exports.listSchedules = exports.schedulesDir = exports.validateSchedule = exports.nextOccurrence = exports.occurrencesBetween = exports.parseWhen = exports.parseDuration = exports.WEEKDAYS = exports.ALL_DAYS = exports.DAY_NAMES = exports.defaultMaxRuntimeMin = exports.MAX_SCHEDULES = void 0;
|
|
13
|
+
exports.writeScheduleState = exports.readScheduleState = exports.scheduleStatePath = exports.removeSchedule = exports.scheduleAnchor = exports.updateSchedule = exports.addSchedule = exports.listSchedules = exports.schedulesDir = exports.validateSchedule = exports.isScheduleId = exports.nextOccurrence = exports.occurrencesBetween = exports.parseWhen = exports.parseDuration = exports.WEEKDAYS = exports.ALL_DAYS = exports.DAY_NAMES = exports.defaultMaxRuntimeMin = exports.MAX_SCHEDULES = void 0;
|
|
14
14
|
const node_crypto_1 = require("node:crypto");
|
|
15
15
|
const node_fs_1 = require("node:fs");
|
|
16
16
|
const node_path_1 = require("node:path");
|
|
@@ -163,8 +163,9 @@ exports.nextOccurrence = nextOccurrence;
|
|
|
163
163
|
const SCHEDULE_ID = /^[a-z0-9]{6}$/;
|
|
164
164
|
const LABEL = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
165
165
|
const AGENT_KIND = /^[A-Za-z0-9_-]{1,32}$/;
|
|
166
|
-
const KEYS = new Set(['v', 'id', 'createdAt', 'source', 'agentKind', 'cwd', 'label', 'session', 'wt', 'agentArgs', 'prompt', 'when', 'maxRuntimeMin']);
|
|
166
|
+
const KEYS = new Set(['v', 'id', 'createdAt', 'updatedAt', 'source', 'agentKind', 'cwd', 'label', 'session', 'wt', 'agentArgs', 'prompt', 'when', 'maxRuntimeMin']);
|
|
167
167
|
const isScheduleId = (value) => typeof value === 'string' && SCHEDULE_ID.test(value);
|
|
168
|
+
exports.isScheduleId = isScheduleId;
|
|
168
169
|
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
169
170
|
const noControl = (text) => !/[\u0000-\u001f\u007f]/.test(text);
|
|
170
171
|
/**
|
|
@@ -202,13 +203,18 @@ const validateSchedule = (input) => {
|
|
|
202
203
|
const unknownKey = Object.keys(input).find((k) => !KEYS.has(k));
|
|
203
204
|
if (unknownKey !== undefined)
|
|
204
205
|
return fail(`schedule has a field it does not know: ${unknownKey}`);
|
|
205
|
-
const { v, id, createdAt, source, agentKind, cwd, label, session, wt, agentArgs, prompt, when, maxRuntimeMin } = input;
|
|
206
|
+
const { v, id, createdAt, updatedAt, source, agentKind, cwd, label, session, wt, agentArgs, prompt, when, maxRuntimeMin } = input;
|
|
206
207
|
if (v !== 1)
|
|
207
208
|
return fail('schedule.v must be 1');
|
|
208
|
-
if (!isScheduleId(id))
|
|
209
|
+
if (!(0, exports.isScheduleId)(id))
|
|
209
210
|
return fail('schedule.id must be 6 lowercase letters or digits');
|
|
210
211
|
if (typeof createdAt !== 'string' || createdAt.length > 40 || !Number.isFinite(Date.parse(createdAt)))
|
|
211
212
|
return fail('schedule.createdAt must be a date');
|
|
213
|
+
if (updatedAt !== undefined && (typeof updatedAt !== 'string' || updatedAt.length > 40 || !Number.isFinite(Date.parse(updatedAt))))
|
|
214
|
+
return fail('schedule.updatedAt must be a date');
|
|
215
|
+
// Before the schedule existed, it would count runs from a time it was not there for.
|
|
216
|
+
if (typeof updatedAt === 'string' && Date.parse(updatedAt) < Date.parse(createdAt))
|
|
217
|
+
return fail('schedule.updatedAt cannot be before createdAt');
|
|
212
218
|
if (source !== 'cli' && source !== 'phone')
|
|
213
219
|
return fail("schedule.source must be 'cli' or 'phone'");
|
|
214
220
|
if (typeof agentKind !== 'string' || !AGENT_KIND.test(agentKind))
|
|
@@ -287,7 +293,7 @@ const listSchedules = () => {
|
|
|
287
293
|
if (!name.endsWith('.json'))
|
|
288
294
|
continue;
|
|
289
295
|
const id = name.slice(0, -'.json'.length);
|
|
290
|
-
const schedule = isScheduleId(id) ? readOne(id) : null;
|
|
296
|
+
const schedule = (0, exports.isScheduleId)(id) ? readOne(id) : null;
|
|
291
297
|
if (schedule)
|
|
292
298
|
schedules.push(schedule);
|
|
293
299
|
else
|
|
@@ -343,8 +349,40 @@ const addSchedule = (make, newId = newScheduleId) => {
|
|
|
343
349
|
return { ok: false, error: 'could not find a free schedule id', failure: 'io' };
|
|
344
350
|
};
|
|
345
351
|
exports.addSchedule = addSchedule;
|
|
352
|
+
/**
|
|
353
|
+
* Change a schedule in place. The result passes `validateSchedule` before it is written, and is written whole (a
|
|
354
|
+
* listener reading it at the same time sees the old file or the new one). A new `when` sets `updatedAt` to `now`:
|
|
355
|
+
* the runner counts the edited schedule's runs from then, so the times it had before are neither run nor counted as
|
|
356
|
+
* missed. A change of the prompt or the run time only keeps the count where it was — an hourly run stays on its hour.
|
|
357
|
+
*/
|
|
358
|
+
const updateSchedule = (id, change, now) => {
|
|
359
|
+
if (!(0, exports.isScheduleId)(id))
|
|
360
|
+
return { ok: false, error: 'not a schedule id', failure: 'invalid' };
|
|
361
|
+
if (change.when === undefined && change.prompt === undefined && change.maxRuntimeMin === undefined) {
|
|
362
|
+
return { ok: false, error: 'nothing to change', failure: 'invalid' };
|
|
363
|
+
}
|
|
364
|
+
const current = readOne(id);
|
|
365
|
+
if (!current)
|
|
366
|
+
return { ok: false, error: `no schedule ${id}`, failure: 'missing' };
|
|
367
|
+
// The same time sent again (an app sends the sheet's) is not a new one: the count stays where it was.
|
|
368
|
+
const retimed = change.when !== undefined && JSON.stringify(change.when) !== JSON.stringify(current.when);
|
|
369
|
+
const checked = (0, exports.validateSchedule)({ ...current, ...change, ...(retimed ? { updatedAt: new Date(now).toISOString() } : {}) });
|
|
370
|
+
if (!checked.ok)
|
|
371
|
+
return { ok: false, error: checked.error, failure: 'invalid' };
|
|
372
|
+
try {
|
|
373
|
+
(0, replace_file_js_1.replaceFile)(schedulePath(id), `${JSON.stringify(checked.value, null, 2)}\n`, undefined, 0o600);
|
|
374
|
+
return { ok: true, schedule: checked.value };
|
|
375
|
+
}
|
|
376
|
+
catch (err) {
|
|
377
|
+
return { ok: false, error: `could not write the schedule: ${err instanceof Error ? err.message : String(err)}`, failure: 'io' };
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
exports.updateSchedule = updateSchedule;
|
|
381
|
+
/** The time a schedule's runs are counted from: its last edit, else its creation. */
|
|
382
|
+
const scheduleAnchor = (schedule) => Date.parse(schedule.updatedAt ?? schedule.createdAt);
|
|
383
|
+
exports.scheduleAnchor = scheduleAnchor;
|
|
346
384
|
const removeSchedule = (id) => {
|
|
347
|
-
if (!isScheduleId(id))
|
|
385
|
+
if (!(0, exports.isScheduleId)(id))
|
|
348
386
|
return 'invalid';
|
|
349
387
|
try {
|
|
350
388
|
(0, node_fs_1.unlinkSync)(schedulePath(id));
|
|
@@ -401,7 +439,7 @@ const readScheduleState = () => {
|
|
|
401
439
|
if (!isRecord(raw) || !isRecord(raw.schedules))
|
|
402
440
|
return { ...state, damaged: true };
|
|
403
441
|
for (const [id, entry] of Object.entries(raw.schedules)) {
|
|
404
|
-
const runState = isScheduleId(id) ? readRunState(entry) : null;
|
|
442
|
+
const runState = (0, exports.isScheduleId)(id) ? readRunState(entry) : null;
|
|
405
443
|
if (runState)
|
|
406
444
|
state.schedules[id] = runState;
|
|
407
445
|
else
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* relay posing as one) sends can point the daemon at another directory or
|
|
15
15
|
* another program.
|
|
16
16
|
*/
|
|
17
|
+
import type { ProviderNameSource } from './session-title.js';
|
|
17
18
|
export interface KnownSession {
|
|
18
19
|
/** tmux session name — the key the phone addresses. */
|
|
19
20
|
name: string;
|
|
@@ -43,6 +44,14 @@ export interface KnownSession {
|
|
|
43
44
|
* run (`continuedRunId`). Absent for a run that has had one tmux session.
|
|
44
45
|
*/
|
|
45
46
|
tmuxRunIds?: string[];
|
|
47
|
+
/**
|
|
48
|
+
* The last name the agent gave this run (Claude's ai-title or `/rename`,
|
|
49
|
+
* …) and who gave it, as the sweep read them: what the phone calls the run
|
|
50
|
+
* once it has ended (hwip docs/session-runs.md Q39). Absent for a run never
|
|
51
|
+
* named, and in files written before it.
|
|
52
|
+
*/
|
|
53
|
+
providerSessionName?: string;
|
|
54
|
+
providerSessionNameSource?: ProviderNameSource;
|
|
46
55
|
/** Last time this machine saw the session running. */
|
|
47
56
|
lastSeenAt: string;
|
|
48
57
|
}
|
|
@@ -138,6 +147,9 @@ export declare const rememberSessions: (live: Array<{
|
|
|
138
147
|
* run's alone to resume — the run forgets it.
|
|
139
148
|
*/
|
|
140
149
|
agentSessionId?: string | null;
|
|
150
|
+
/** The name the agent gives the run now, and who gave it. None this sweep: the run keeps the one it had. */
|
|
151
|
+
providerSessionName?: string | null;
|
|
152
|
+
providerSessionNameSource?: ProviderNameSource;
|
|
141
153
|
}>, now?: number) => void;
|
|
142
154
|
/**
|
|
143
155
|
* Forget one session, by name. Returns false when this machine never knew it.
|
package/dist/session-registry.js
CHANGED
|
@@ -35,6 +35,14 @@ exports.MAX_RUNS_PER_NAME = 10;
|
|
|
35
35
|
* likely to have moved than to be what the user meant. */
|
|
36
36
|
exports.KNOWN_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
37
37
|
const registryPath = () => (0, path_1.join)((0, gate_js_1.stateDir)(), 'known-sessions.json');
|
|
38
|
+
const isNameSource = (v) => v === 'user' || v === 'auto';
|
|
39
|
+
/** A run's name as the file holds it: a name that is not a string goes, and with it its source; a source that is not one goes alone. */
|
|
40
|
+
const withReadableName = (e) => {
|
|
41
|
+
const { providerSessionName: name, providerSessionNameSource: source, ...rest } = e;
|
|
42
|
+
if (typeof name !== 'string' || name === '')
|
|
43
|
+
return rest;
|
|
44
|
+
return { ...rest, providerSessionName: name, ...(isNameSource(source) ? { providerSessionNameSource: source } : {}) };
|
|
45
|
+
};
|
|
38
46
|
const isTmuxRunIds = (v) => v === undefined || (Array.isArray(v) && v.every((id) => typeof id === 'string'));
|
|
39
47
|
const readAll = () => {
|
|
40
48
|
try {
|
|
@@ -51,7 +59,7 @@ const readAll = () => {
|
|
|
51
59
|
typeof e.agentKind === 'string' &&
|
|
52
60
|
typeof e.lastSeenAt === 'string' &&
|
|
53
61
|
(e.runId === undefined || typeof e.runId === 'string') &&
|
|
54
|
-
isTmuxRunIds(e.tmuxRunIds));
|
|
62
|
+
isTmuxRunIds(e.tmuxRunIds)).map(withReadableName);
|
|
55
63
|
}
|
|
56
64
|
catch {
|
|
57
65
|
return [];
|
|
@@ -200,6 +208,12 @@ const rememberSessions = (live, now = Date.now()) => {
|
|
|
200
208
|
const tmuxRunIds = continued && s.tmuxRunId
|
|
201
209
|
? [...(prev?.tmuxRunIds ?? []).filter((id) => id !== s.tmuxRunId), s.tmuxRunId].slice(-exports.MAX_TMUX_RUNS_PER_RUN)
|
|
202
210
|
: prev?.tmuxRunIds;
|
|
211
|
+
// The run's name is the last one read: a sweep that read none (the
|
|
212
|
+
// agent has not titled it yet, or its record was unreadable) does not
|
|
213
|
+
// take it away.
|
|
214
|
+
const named = s.providerSessionName
|
|
215
|
+
? { providerSessionName: s.providerSessionName, providerSessionNameSource: s.providerSessionNameSource }
|
|
216
|
+
: prev;
|
|
203
217
|
byRun.set(key, {
|
|
204
218
|
name: s.name,
|
|
205
219
|
cwd: s.cwd,
|
|
@@ -210,6 +224,8 @@ const rememberSessions = (live, now = Date.now()) => {
|
|
|
210
224
|
...(startedAt ? { startedAt } : {}),
|
|
211
225
|
...(agentSessionId ? { agentSessionId } : {}),
|
|
212
226
|
...(tmuxRunIds?.length ? { tmuxRunIds } : {}),
|
|
227
|
+
...(named?.providerSessionName ? { providerSessionName: named.providerSessionName } : {}),
|
|
228
|
+
...(named?.providerSessionName && named.providerSessionNameSource ? { providerSessionNameSource: named.providerSessionNameSource } : {}),
|
|
213
229
|
lastSeenAt: seenAt,
|
|
214
230
|
});
|
|
215
231
|
}
|
package/dist/usage.js
CHANGED
|
@@ -172,6 +172,10 @@ ${usageAgentLines()}
|
|
|
172
172
|
(cc, codex, …) and the project is this one
|
|
173
173
|
schedule [list] show them, with the next run and the last
|
|
174
174
|
result (--json for scripts)
|
|
175
|
+
schedule edit <id> [--at HH:MM] [--days …] [--every <N>m|<N>h]
|
|
176
|
+
[--prompt <text>] [--max-runtime <N>m|<N>h]
|
|
177
|
+
change one in place; a new time counts
|
|
178
|
+
from the edit, a running session keeps going
|
|
175
179
|
schedule rm <id> remove one; a session it already started
|
|
176
180
|
keeps running
|
|
177
181
|
sh Open (or reattach) 'hwip-<project>-sh', a login shell the
|
package/dist/wrapper.js
CHANGED
|
@@ -14,6 +14,7 @@ exports.handleShellSession = exports.shellTargets = exports.readShellSessionStat
|
|
|
14
14
|
*/
|
|
15
15
|
const child_process_1 = require("child_process");
|
|
16
16
|
const path_1 = require("path");
|
|
17
|
+
const agent_dirs_js_1 = require("./agent-dirs.js");
|
|
17
18
|
const agents_js_1 = require("./agents.js");
|
|
18
19
|
const tmux_install_js_1 = require("./tmux-install.js");
|
|
19
20
|
const check_update_js_1 = require("./check-update.js");
|
|
@@ -530,6 +531,15 @@ const handleAgentSession = async (agent, extra = [], opts = {}, deps = {}) => {
|
|
|
530
531
|
console.error(missing);
|
|
531
532
|
return 127;
|
|
532
533
|
}
|
|
534
|
+
// Where this shell finds the agent: a schedule launch, which has only the listener's PATH, looks there too (agent-dirs.ts).
|
|
535
|
+
const agentFile = (0, agents_js_1.findOnPath)(agent.binary);
|
|
536
|
+
if (agentFile && !launchedByListener)
|
|
537
|
+
(0, agent_dirs_js_1.rememberAgentDir)(agentFile);
|
|
538
|
+
// tmux would create the session anyway and its pane would die at once, leaving the caller a session that is just gone.
|
|
539
|
+
if (opts.detach && !agentFile) {
|
|
540
|
+
console.error(`hwip: '${agent.binary}' not found on PATH`);
|
|
541
|
+
return 127;
|
|
542
|
+
}
|
|
533
543
|
// Before the listener starts: a refused `--wt` should cost nothing.
|
|
534
544
|
if (opts.worktree) {
|
|
535
545
|
const made = await (0, worktree_js_1.ensureWorktree)({ dir: (0, exports.detectProjectDir)(), name: opts.worktree });
|