@hwip/cli 0.0.1 → 0.1.1
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/LICENSE +190 -0
- package/NOTICE +11 -0
- package/README.md +1983 -0
- package/dist/agent-list.d.ts +76 -0
- package/dist/agent-list.js +102 -0
- package/dist/agent-read.d.ts +105 -0
- package/dist/agent-read.js +309 -0
- package/dist/agent-rules-fetch.d.ts +51 -0
- package/dist/agent-rules-fetch.js +275 -0
- package/dist/agent-rules.default.d.ts +30 -0
- package/dist/agent-rules.default.js +305 -0
- package/dist/agent-session.d.ts +23 -0
- package/dist/agent-session.js +44 -0
- package/dist/agent-state.d.ts +84 -0
- package/dist/agent-state.js +188 -0
- package/dist/agent-target.d.ts +60 -0
- package/dist/agent-target.js +81 -0
- package/dist/agents.d.ts +31 -0
- package/dist/agents.js +75 -0
- package/dist/api.d.ts +28 -0
- package/dist/api.js +76 -0
- package/dist/args.d.ts +74 -0
- package/dist/args.js +254 -0
- package/dist/ask-context-attach.d.ts +73 -0
- package/dist/ask-context-attach.js +151 -0
- package/dist/ask-context-screen.d.ts +21 -0
- package/dist/ask-context-screen.js +49 -0
- package/dist/ask-context-switch.d.ts +31 -0
- package/dist/ask-context-switch.js +74 -0
- package/dist/ask-context.d.ts +84 -0
- package/dist/ask-context.js +172 -0
- package/dist/ask-pending.d.ts +47 -0
- package/dist/ask-pending.js +221 -0
- package/dist/ask-seal.d.ts +120 -0
- package/dist/ask-seal.js +179 -0
- package/dist/ask.d.ts +107 -0
- package/dist/ask.js +551 -0
- package/dist/blocked-prompt-seal.d.ts +39 -0
- package/dist/blocked-prompt-seal.js +92 -0
- package/dist/blocked-prompt.d.ts +62 -0
- package/dist/blocked-prompt.js +518 -0
- package/dist/channel.d.ts +67 -0
- package/dist/channel.js +136 -0
- package/dist/check-update.d.ts +28 -0
- package/dist/check-update.js +153 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +813 -0
- package/dist/codex-config.d.ts +24 -0
- package/dist/codex-config.js +169 -0
- package/dist/codex-transcript.d.ts +59 -0
- package/dist/codex-transcript.js +373 -0
- package/dist/command-refusal.d.ts +56 -0
- package/dist/command-refusal.js +127 -0
- package/dist/command-scan.d.ts +46 -0
- package/dist/command-scan.js +154 -0
- package/dist/command-seal.d.ts +57 -0
- package/dist/command-seal.js +88 -0
- package/dist/command-seq.d.ts +30 -0
- package/dist/command-seq.js +150 -0
- package/dist/config.d.ts +156 -0
- package/dist/config.js +392 -0
- package/dist/core-sections.d.ts +18 -0
- package/dist/core-sections.js +28 -0
- package/dist/crypto.d.ts +278 -0
- package/dist/crypto.js +698 -0
- package/dist/desktop-notify.d.ts +33 -0
- package/dist/desktop-notify.js +49 -0
- package/dist/device-directory.d.ts +36 -0
- package/dist/device-directory.js +97 -0
- package/dist/device-presence.d.ts +17 -0
- package/dist/device-presence.js +62 -0
- package/dist/downloads.d.ts +29 -0
- package/dist/downloads.js +106 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +28 -0
- package/dist/file-crypto.d.ts +94 -0
- package/dist/file-crypto.js +278 -0
- package/dist/file-view-budget.d.ts +15 -0
- package/dist/file-view-budget.js +34 -0
- package/dist/file-view-switch.d.ts +28 -0
- package/dist/file-view-switch.js +122 -0
- package/dist/file-view.d.ts +108 -0
- package/dist/file-view.js +327 -0
- package/dist/gate.d.ts +141 -0
- package/dist/gate.js +336 -0
- package/dist/gemini-transcript.d.ts +32 -0
- package/dist/gemini-transcript.js +189 -0
- package/dist/http-failure.d.ts +98 -0
- package/dist/http-failure.js +349 -0
- package/dist/hwip-core.generated.d.ts +7 -0
- package/dist/hwip-core.generated.js +14 -0
- package/dist/hwip-hook.d.ts +77 -0
- package/dist/hwip-hook.js +340 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/input-sequencer.d.ts +65 -0
- package/dist/input-sequencer.js +142 -0
- package/dist/insecure-url.d.ts +16 -0
- package/dist/insecure-url.js +76 -0
- package/dist/installer.d.ts +96 -0
- package/dist/installer.js +850 -0
- package/dist/inventory-offload.d.ts +49 -0
- package/dist/inventory-offload.js +209 -0
- package/dist/inventory-worker.d.ts +2 -0
- package/dist/inventory-worker.js +27 -0
- package/dist/jsonc-doc.d.ts +106 -0
- package/dist/jsonc-doc.js +354 -0
- package/dist/keep-awake.d.ts +32 -0
- package/dist/keep-awake.js +46 -0
- package/dist/key-changes.d.ts +48 -0
- package/dist/key-changes.js +140 -0
- package/dist/lan-auth.d.ts +140 -0
- package/dist/lan-auth.js +217 -0
- package/dist/lan-endpoint.d.ts +41 -0
- package/dist/lan-endpoint.js +115 -0
- package/dist/lan-receiver.d.ts +122 -0
- package/dist/lan-receiver.js +510 -0
- package/dist/lan-sender.d.ts +76 -0
- package/dist/lan-sender.js +184 -0
- package/dist/lan-transfer.d.ts +36 -0
- package/dist/lan-transfer.js +54 -0
- package/dist/listener-device-id.d.ts +20 -0
- package/dist/listener-device-id.js +133 -0
- package/dist/listener-log.d.ts +4 -0
- package/dist/listener-log.js +23 -0
- package/dist/listener-process.d.ts +200 -0
- package/dist/listener-process.js +470 -0
- package/dist/listener-service.d.ts +225 -0
- package/dist/listener-service.js +682 -0
- package/dist/listener-worktree-cleanup.d.ts +27 -0
- package/dist/listener-worktree-cleanup.js +62 -0
- package/dist/listener.d.ts +1325 -0
- package/dist/listener.js +5681 -0
- package/dist/login-address.d.ts +25 -0
- package/dist/login-address.js +97 -0
- package/dist/login-page.d.ts +29 -0
- package/dist/login-page.js +93 -0
- package/dist/login.d.ts +98 -0
- package/dist/login.js +388 -0
- package/dist/mcp-command.d.ts +45 -0
- package/dist/mcp-command.js +50 -0
- package/dist/mcp.d.ts +19 -0
- package/dist/mcp.js +29 -0
- package/dist/node-support.d.ts +19 -0
- package/dist/node-support.js +38 -0
- package/dist/notify-failure.d.ts +57 -0
- package/dist/notify-failure.js +150 -0
- package/dist/opencode-transcript.d.ts +139 -0
- package/dist/opencode-transcript.js +390 -0
- package/dist/optional-features.d.ts +6 -0
- package/dist/optional-features.js +19 -0
- package/dist/pane-fit.d.ts +40 -0
- package/dist/pane-fit.js +119 -0
- package/dist/peer-key.d.ts +33 -0
- package/dist/peer-key.js +34 -0
- package/dist/pi-transcript.d.ts +26 -0
- package/dist/pi-transcript.js +255 -0
- package/dist/presence.d.ts +14 -0
- package/dist/presence.js +124 -0
- package/dist/private-files.d.ts +51 -0
- package/dist/private-files.js +218 -0
- package/dist/project-switch.d.ts +19 -0
- package/dist/project-switch.js +60 -0
- package/dist/redact.d.ts +10 -0
- package/dist/redact.js +21 -0
- package/dist/remote-agents.d.ts +410 -0
- package/dist/remote-agents.js +1218 -0
- package/dist/remote-hook.d.ts +12 -0
- package/dist/remote-hook.js +196 -0
- package/dist/replace-file.d.ts +50 -0
- package/dist/replace-file.js +328 -0
- package/dist/schedule-command.d.ts +35 -0
- package/dist/schedule-command.js +260 -0
- package/dist/schedule-launch.d.ts +15 -0
- package/dist/schedule-launch.js +24 -0
- package/dist/schedule-ready.d.ts +44 -0
- package/dist/schedule-ready.js +71 -0
- package/dist/schedule-remote.d.ts +77 -0
- package/dist/schedule-remote.js +167 -0
- package/dist/schedule-runner.d.ts +89 -0
- package/dist/schedule-runner.js +452 -0
- package/dist/schedule.d.ts +149 -0
- package/dist/schedule.js +428 -0
- package/dist/secret-mask.d.ts +29 -0
- package/dist/secret-mask.js +245 -0
- package/dist/self-register.d.ts +45 -0
- package/dist/self-register.js +67 -0
- package/dist/send.d.ts +56 -0
- package/dist/send.js +104 -0
- package/dist/session-diff.d.ts +161 -0
- package/dist/session-diff.js +445 -0
- package/dist/session-registry.d.ts +143 -0
- package/dist/session-registry.js +216 -0
- package/dist/sessions-report.d.ts +59 -0
- package/dist/sessions-report.js +99 -0
- package/dist/shell-audit.d.ts +34 -0
- package/dist/shell-audit.js +47 -0
- package/dist/subagent-transcripts.d.ts +167 -0
- package/dist/subagent-transcripts.js +437 -0
- package/dist/subscriber-trust.d.ts +128 -0
- package/dist/subscriber-trust.js +397 -0
- package/dist/templates.d.ts +105 -0
- package/dist/templates.js +871 -0
- package/dist/tmux-install.d.ts +44 -0
- package/dist/tmux-install.js +65 -0
- package/dist/tmux-liveness.d.ts +45 -0
- package/dist/tmux-liveness.js +71 -0
- package/dist/tmux-pane.d.ts +55 -0
- package/dist/tmux-pane.js +126 -0
- package/dist/tmux-socket.d.ts +20 -0
- package/dist/tmux-socket.js +292 -0
- package/dist/todo-fold.d.ts +31 -0
- package/dist/todo-fold.js +102 -0
- package/dist/transcript-tail.d.ts +312 -0
- package/dist/transcript-tail.js +718 -0
- package/dist/trust-command.d.ts +49 -0
- package/dist/trust-command.js +200 -0
- package/dist/trust-lock.d.ts +26 -0
- package/dist/trust-lock.js +94 -0
- package/dist/trust-pins.d.ts +37 -0
- package/dist/trust-pins.js +82 -0
- package/dist/turn-recorder-wire.d.ts +70 -0
- package/dist/turn-recorder-wire.js +165 -0
- package/dist/turn-recorder.d.ts +190 -0
- package/dist/turn-recorder.js +695 -0
- package/dist/turn-ring.d.ts +93 -0
- package/dist/turn-ring.js +418 -0
- package/dist/turn-watch.d.ts +177 -0
- package/dist/turn-watch.js +630 -0
- package/dist/types.d.ts +63 -0
- package/dist/types.js +2 -0
- package/dist/uninstall.d.ts +55 -0
- package/dist/uninstall.js +507 -0
- package/dist/usage.d.ts +17 -0
- package/dist/usage.js +421 -0
- package/dist/verify.d.ts +47 -0
- package/dist/verify.js +396 -0
- package/dist/worktree.d.ts +144 -0
- package/dist/worktree.js +310 -0
- package/dist/wrapper.d.ts +254 -0
- package/dist/wrapper.js +704 -0
- package/dist/ws-url.d.ts +109 -0
- package/dist/ws-url.js +202 -0
- package/package.json +78 -10
- package/bin/hwip.js +0 -3
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Starts scheduled sessions on time (F8). The listener calls `tick(now)` on a timer; everything the tick touches
|
|
4
|
+
* outside this file (the clock, tmux, `hwip <agent> --detach`, the state file) comes in through `deps`, so a
|
|
5
|
+
* fake clock and fakes drive every case in the tests.
|
|
6
|
+
*
|
|
7
|
+
* What a tick decides, per schedule: how many due times passed since its cursor, whether the newest is still
|
|
8
|
+
* worth running (`GRACE_MS`; older ones are `missed` and never run late), and whether the session may start.
|
|
9
|
+
* The cursor is written before anything is launched, so a listener killed in between skips that run instead of
|
|
10
|
+
* starting it twice.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.createScheduleRunner = exports.launchArgs = exports.notifyFailureOutcome = exports.MAX_RUNNING = exports.PROMPT_WAIT_MS = exports.PROMPT_POLL_MS = exports.PUSH_RETRY_MAX_MS = exports.PUSH_RETRY_MS = exports.LAUNCH_WT_TIMEOUT_MS = exports.LAUNCH_TIMEOUT_MS = exports.START_CHECK_MS = exports.GRACE_MS = exports.TICK_MS = void 0;
|
|
14
|
+
const schedule_js_1 = require("./schedule.js");
|
|
15
|
+
/** How often the listener ticks. */
|
|
16
|
+
exports.TICK_MS = 30_000;
|
|
17
|
+
/** A due time older than this when the tick sees it (the machine slept, the listener was down) is missed, not run. */
|
|
18
|
+
exports.GRACE_MS = 120_000;
|
|
19
|
+
/** After a launch exits 0, how long before the session has to be there. */
|
|
20
|
+
exports.START_CHECK_MS = 2_000;
|
|
21
|
+
/** A launch that has not exited by then is given up on. */
|
|
22
|
+
exports.LAUNCH_TIMEOUT_MS = 30_000;
|
|
23
|
+
/** The same for `--wt`: the launch first makes the worktree (`git worktree add`, and whatever its hooks do), which a big repo does not finish in 30 s. */
|
|
24
|
+
exports.LAUNCH_WT_TIMEOUT_MS = 120_000;
|
|
25
|
+
/** After a push that did not go out, how long until the next try; doubled on every failure up to the cap. */
|
|
26
|
+
exports.PUSH_RETRY_MS = exports.TICK_MS;
|
|
27
|
+
exports.PUSH_RETRY_MAX_MS = 30 * 60_000;
|
|
28
|
+
/** How often the listener polls sessions that are waiting for their prompt (only while one is). */
|
|
29
|
+
exports.PROMPT_POLL_MS = 5_000;
|
|
30
|
+
/**
|
|
31
|
+
* How long after the start a session may take to become ready for its prompt; later, the prompt is given up on. Ready
|
|
32
|
+
* takes two polls in a row on the same idle screen (`schedule-ready.ts`), so the earliest prompt goes one poll after
|
|
33
|
+
* the session is idle, and a retry after a failed inject two polls later.
|
|
34
|
+
*/
|
|
35
|
+
exports.PROMPT_WAIT_MS = 90_000;
|
|
36
|
+
/** A report that has waited this long for a push that would not go through is dropped, not sent as news. */
|
|
37
|
+
const PENDING_MAX_MS = 24 * 60 * 60 * 1000;
|
|
38
|
+
/** Scheduled sessions running at once. */
|
|
39
|
+
exports.MAX_RUNNING = 3;
|
|
40
|
+
/** The part of a launch's stderr that goes to the log. */
|
|
41
|
+
const STDERR_LOG_CHARS = 200;
|
|
42
|
+
/** First non-empty line of `text`, control characters out, cut to `STDERR_LOG_CHARS` — one log line, whatever the child wrote. */
|
|
43
|
+
const firstLogLine = (text) => {
|
|
44
|
+
const line = text.split('\n').find((l) => l.trim() !== '') ?? '';
|
|
45
|
+
// eslint-disable-next-line no-control-regex -- stripping them is the point
|
|
46
|
+
return line.replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, STDERR_LOG_CHARS);
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* What a failed push means for trying again. Only the server saying no to this very push is final: a 4xx (a daily
|
|
50
|
+
* limit among them). The client's own timeout (reported as 408), a proxy asking for credentials (407) and a page that
|
|
51
|
+
* is not the API's (a captive portal) are the network failing, and the push is worth sending again when it is back.
|
|
52
|
+
*/
|
|
53
|
+
const notifyFailureOutcome = (e) => {
|
|
54
|
+
const { code, status } = (typeof e === 'object' && e !== null ? e : {});
|
|
55
|
+
if (code === 'TIMEOUT' || code === 'INVALID_RESPONSE')
|
|
56
|
+
return 'retry';
|
|
57
|
+
if (typeof status !== 'number' || status === 407 || status === 408)
|
|
58
|
+
return 'retry';
|
|
59
|
+
return status >= 400 && status < 500 ? 'rejected' : 'retry';
|
|
60
|
+
};
|
|
61
|
+
exports.notifyFailureOutcome = notifyFailureOutcome;
|
|
62
|
+
/** How each result that is not `started` reads in the push, as "<label> · <reason> <count>회". */
|
|
63
|
+
const REASON_TEXT = {
|
|
64
|
+
missed: '놓침',
|
|
65
|
+
overlap: '이미 실행 중이라 건너뜀',
|
|
66
|
+
busy: '동시 실행 한도라 건너뜀',
|
|
67
|
+
start_failed: '시작 실패',
|
|
68
|
+
missing_directory: '폴더 없음',
|
|
69
|
+
unknown_agent: '알 수 없는 에이전트',
|
|
70
|
+
prompt_undelivered: '할 일 입력 못 함',
|
|
71
|
+
prompt_unsupported: '할 일 입력 불가(이 에이전트는 입력 준비를 판정할 규칙이 없음)',
|
|
72
|
+
capped: '실행 시간 초과',
|
|
73
|
+
};
|
|
74
|
+
/** The push title for each result: what kind of news it is. A report that mixes kinds gets the general one. */
|
|
75
|
+
const SKIPPED = '예약 건너뜀';
|
|
76
|
+
const PROMPT_NOT_TYPED = '예약 할 일 입력 못 함';
|
|
77
|
+
const TITLE_OF = {
|
|
78
|
+
missed: SKIPPED,
|
|
79
|
+
overlap: SKIPPED,
|
|
80
|
+
busy: SKIPPED,
|
|
81
|
+
start_failed: SKIPPED,
|
|
82
|
+
missing_directory: SKIPPED,
|
|
83
|
+
unknown_agent: SKIPPED,
|
|
84
|
+
prompt_undelivered: PROMPT_NOT_TYPED,
|
|
85
|
+
prompt_unsupported: PROMPT_NOT_TYPED,
|
|
86
|
+
capped: '예약 실행 시간 초과',
|
|
87
|
+
};
|
|
88
|
+
const MIXED_TITLE = '예약 알림';
|
|
89
|
+
/** Adds `count` of `outcome` to what the person has not been told yet. */
|
|
90
|
+
const addPending = (st, outcome, count, now) => {
|
|
91
|
+
const pending = (st.pending ??= { since: now, counts: {} });
|
|
92
|
+
pending.counts[outcome] = (pending.counts[outcome] ?? 0) + count;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* The arguments of `hwip` that start `schedule`'s session. One array element per argument, never a string for a shell:
|
|
96
|
+
* `agentArgs` are passed on as typed, whatever they contain.
|
|
97
|
+
*/
|
|
98
|
+
const launchArgs = (schedule, subcommand) => [
|
|
99
|
+
subcommand,
|
|
100
|
+
'--detach',
|
|
101
|
+
'--label',
|
|
102
|
+
schedule.label,
|
|
103
|
+
...(schedule.wt !== undefined ? ['--wt', schedule.wt] : []),
|
|
104
|
+
...(schedule.agentArgs ?? []),
|
|
105
|
+
];
|
|
106
|
+
exports.launchArgs = launchArgs;
|
|
107
|
+
const createScheduleRunner = (deps) => {
|
|
108
|
+
// A launch can take seconds; a tick (or poll) that finds the last one still going does nothing.
|
|
109
|
+
let working = false;
|
|
110
|
+
// A push is on its way (the state is not held while it is): no second one is started until it has been settled.
|
|
111
|
+
let pushing = false;
|
|
112
|
+
// Pushes that went out, to be taken off `pending` the next time the state is loaded. Only in memory: a listener that
|
|
113
|
+
// stops in between tells the same news once more (at least once, never lost).
|
|
114
|
+
let sentReports = [];
|
|
115
|
+
let lastListNote = '';
|
|
116
|
+
// After a push failed: when the next try may go out, and how long the wait after that one will be.
|
|
117
|
+
let nextPushAt = 0;
|
|
118
|
+
let retryAfter = exports.PUSH_RETRY_MS;
|
|
119
|
+
const clock = deps.clock ?? Date.now;
|
|
120
|
+
// Time spent launching other sessions while a session waits for its prompt is not the session's wait: a `--wt` launch
|
|
121
|
+
// holds the listener for up to two minutes, during which nobody can look at the others. `launchedMs` is what all launches
|
|
122
|
+
// took so far; a started session remembers its value, and only what came after counts as excused. In memory: after a
|
|
123
|
+
// restart nothing is excused, which is right — the listener was down.
|
|
124
|
+
let launchedMs = 0;
|
|
125
|
+
const launchedBefore = new Map();
|
|
126
|
+
// Sessions already asked to end for their runtime cap, so a state file that cannot be written does not ask again every tick.
|
|
127
|
+
const cappedAsked = new Set();
|
|
128
|
+
const load = (tickNow) => {
|
|
129
|
+
const { schedules, unreadable, incomplete } = deps.listSchedules();
|
|
130
|
+
const file = deps.readState();
|
|
131
|
+
const previous = file.schedules;
|
|
132
|
+
if (file.damaged)
|
|
133
|
+
deps.log('! schedules: the state file was not readable; schedules without a record start from now');
|
|
134
|
+
// Said once per change, not every tick: files that cannot be run are skipped, and the person should be able to find out why.
|
|
135
|
+
const listNote = unreadable > 0 || incomplete ? `${unreadable} schedule file${unreadable === 1 ? '' : 's'} skipped (unreadable, unsafe or over the limit)${incomplete ? '; the list may be incomplete' : ''}` : '';
|
|
136
|
+
if (listNote !== lastListNote) {
|
|
137
|
+
lastListNote = listNote;
|
|
138
|
+
if (listNote)
|
|
139
|
+
deps.log(`! schedules: ${listNote}`);
|
|
140
|
+
}
|
|
141
|
+
const state = {};
|
|
142
|
+
for (const schedule of schedules) {
|
|
143
|
+
// No record of a schedule in a damaged file may mean "nothing ran" or "the record was lost": start from now, so
|
|
144
|
+
// neither a pile of old runs is counted as missed nor a run that already happened is started again.
|
|
145
|
+
state[schedule.id] = previous[schedule.id] ?? { cursor: file.damaged ? tickNow : Date.parse(schedule.createdAt) };
|
|
146
|
+
}
|
|
147
|
+
// A state entry whose schedule file is gone is dropped — but only when the list is whole: a file that could not be
|
|
148
|
+
// 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.
|
|
149
|
+
const listWhole = unreadable === 0 && !incomplete;
|
|
150
|
+
if (!listWhole)
|
|
151
|
+
for (const id of Object.keys(previous))
|
|
152
|
+
state[id] ??= previous[id];
|
|
153
|
+
let dirty = file.damaged === true || Object.keys(previous).some((id) => !(id in state));
|
|
154
|
+
// A schedule only ever runs in its own session. A record that names another one is not ours: it would count against
|
|
155
|
+
// the limit and, at the cap, have that other session asked to end.
|
|
156
|
+
for (const schedule of schedules) {
|
|
157
|
+
const st = state[schedule.id];
|
|
158
|
+
if (st.running && st.running.session !== schedule.session) {
|
|
159
|
+
deps.log(`! schedules: ${schedule.id} had a running record for ${st.running.session}, not ${schedule.session}; dropped`);
|
|
160
|
+
delete st.running;
|
|
161
|
+
dirty = true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
for (const st of Object.values(state)) {
|
|
165
|
+
if (st.running && deps.sessionEnded(st.running.session)) {
|
|
166
|
+
delete st.running;
|
|
167
|
+
dirty = true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
for (const told of sentReports) {
|
|
171
|
+
for (const { id, counts } of told) {
|
|
172
|
+
const pending = state[id]?.pending;
|
|
173
|
+
if (!pending)
|
|
174
|
+
continue;
|
|
175
|
+
for (const outcome of Object.keys(counts)) {
|
|
176
|
+
const left = (pending.counts[outcome] ?? 0) - (counts[outcome] ?? 0);
|
|
177
|
+
if (left > 0)
|
|
178
|
+
pending.counts[outcome] = left;
|
|
179
|
+
else
|
|
180
|
+
delete pending.counts[outcome];
|
|
181
|
+
}
|
|
182
|
+
if (Object.keys(pending.counts).length === 0)
|
|
183
|
+
delete state[id].pending;
|
|
184
|
+
}
|
|
185
|
+
dirty = true;
|
|
186
|
+
}
|
|
187
|
+
if (sentReports.length > 0) {
|
|
188
|
+
sentReports = [];
|
|
189
|
+
pushing = false;
|
|
190
|
+
}
|
|
191
|
+
const save = () => {
|
|
192
|
+
const saved = deps.writeState({ v: 1, schedules: state });
|
|
193
|
+
if (!saved)
|
|
194
|
+
deps.log('! schedules: could not write the state file');
|
|
195
|
+
return saved;
|
|
196
|
+
};
|
|
197
|
+
return { schedules, state, dirty, save };
|
|
198
|
+
};
|
|
199
|
+
const run = async (tickNow) => {
|
|
200
|
+
const tickStart = clock();
|
|
201
|
+
const loaded = load(tickNow);
|
|
202
|
+
const { schedules, state, save } = loaded;
|
|
203
|
+
// Every due time is judged (and its cursor written) before the first launch: a `--wt` launch takes minutes, and the
|
|
204
|
+
// schedules after it must not be judged late, and so missed, by the time it took.
|
|
205
|
+
const toStart = [];
|
|
206
|
+
for (const schedule of schedules) {
|
|
207
|
+
const st = state[schedule.id];
|
|
208
|
+
const now = tickNow + (clock() - tickStart);
|
|
209
|
+
const due = (0, schedule_js_1.occurrencesBetween)(schedule.when, st.cursor, now, Date.parse(schedule.createdAt));
|
|
210
|
+
if (due.count === 0 || due.latest === null)
|
|
211
|
+
continue;
|
|
212
|
+
const runs = now - due.latest <= exports.GRACE_MS;
|
|
213
|
+
const missed = runs ? due.count - 1 : due.count;
|
|
214
|
+
const before = structuredClone(st);
|
|
215
|
+
st.cursor = due.latest;
|
|
216
|
+
// Before anything is launched: a run whose cursor is not on disk must not start.
|
|
217
|
+
if (missed > 0) {
|
|
218
|
+
st.last = { outcome: 'missed', at: now };
|
|
219
|
+
addPending(st, 'missed', missed, now);
|
|
220
|
+
}
|
|
221
|
+
if (!save()) {
|
|
222
|
+
state[schedule.id] = before;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
loaded.dirty = false;
|
|
226
|
+
if (missed > 0)
|
|
227
|
+
deps.log(`schedule ${schedule.id} (${schedule.label}): missed ${missed} run${missed === 1 ? '' : 's'}`);
|
|
228
|
+
if (runs)
|
|
229
|
+
toStart.push({ schedule, st, before, at: now });
|
|
230
|
+
}
|
|
231
|
+
for (const { schedule, st, before, at: now } of toStart) {
|
|
232
|
+
// A prompt is only ever typed when the agent's own rules can tell idle from a dialog; otherwise the session still starts, without it.
|
|
233
|
+
const typable = schedule.prompt === undefined || deps.canPrompt(schedule.agentKind);
|
|
234
|
+
const launchStart = clock();
|
|
235
|
+
const outcome = await start(schedule, st, state, typable, () => tickNow + (clock() - tickStart));
|
|
236
|
+
launchedMs += clock() - launchStart;
|
|
237
|
+
const running = st.running;
|
|
238
|
+
if (outcome === 'started' && running)
|
|
239
|
+
launchedBefore.set(`${running.session}@${running.startedAt}`, launchedMs);
|
|
240
|
+
st.last = { outcome, at: now };
|
|
241
|
+
deps.log(`schedule ${schedule.id} (${schedule.label}): ${outcome}`);
|
|
242
|
+
if (outcome === 'started' && !typable) {
|
|
243
|
+
st.last = { outcome: 'prompt_unsupported', at: now };
|
|
244
|
+
addPending(st, 'prompt_unsupported', 1, now);
|
|
245
|
+
deps.log(`schedule ${schedule.id} (${schedule.label}): ${schedule.agentKind} has no rule that says idle, so its prompt is not typed`);
|
|
246
|
+
}
|
|
247
|
+
// A session still running (or no room) stays the same for every run until it ends: the first of such a streak is news.
|
|
248
|
+
const repeat = (outcome === 'overlap' || outcome === 'busy') && before.last?.outcome === outcome;
|
|
249
|
+
if (outcome !== 'started' && !repeat)
|
|
250
|
+
addPending(st, outcome, 1, now);
|
|
251
|
+
saveNow(loaded);
|
|
252
|
+
}
|
|
253
|
+
supervise(loaded, tickNow + (clock() - tickStart));
|
|
254
|
+
return collectReport(loaded, tickNow + (clock() - tickStart));
|
|
255
|
+
};
|
|
256
|
+
/**
|
|
257
|
+
* What has piled up, as one push for all schedules; null when there is nothing (or a push is still on its way).
|
|
258
|
+
* Reports older than 24 h are dropped on the way. `pending` is only taken off once the push has gone out (`tick`).
|
|
259
|
+
*/
|
|
260
|
+
const collectReport = (loaded, now) => {
|
|
261
|
+
const { schedules, state } = loaded;
|
|
262
|
+
const lines = [];
|
|
263
|
+
const told = [];
|
|
264
|
+
const titles = new Set();
|
|
265
|
+
let dropped = 0;
|
|
266
|
+
for (const schedule of schedules) {
|
|
267
|
+
const st = state[schedule.id];
|
|
268
|
+
if (!st.pending)
|
|
269
|
+
continue;
|
|
270
|
+
if (now - st.pending.since > PENDING_MAX_MS) {
|
|
271
|
+
delete st.pending;
|
|
272
|
+
dropped += 1;
|
|
273
|
+
loaded.dirty = true;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const { counts } = st.pending;
|
|
277
|
+
const reasons = Object.keys(REASON_TEXT)
|
|
278
|
+
.filter((outcome) => counts[outcome])
|
|
279
|
+
.map((outcome) => `${REASON_TEXT[outcome]} ${counts[outcome]}회`);
|
|
280
|
+
if (reasons.length === 0) {
|
|
281
|
+
delete st.pending;
|
|
282
|
+
loaded.dirty = true;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
lines.push(`${schedule.label} · ${reasons.join(', ')}`);
|
|
286
|
+
for (const outcome of Object.keys(counts))
|
|
287
|
+
if (counts[outcome])
|
|
288
|
+
titles.add(TITLE_OF[outcome]);
|
|
289
|
+
told.push({ id: schedule.id, counts: { ...counts } });
|
|
290
|
+
}
|
|
291
|
+
if (dropped > 0)
|
|
292
|
+
deps.log(`schedules: dropped the unsent report of ${dropped} schedule${dropped === 1 ? '' : 's'} older than 24 h`);
|
|
293
|
+
if (loaded.dirty)
|
|
294
|
+
loaded.save();
|
|
295
|
+
if (lines.length === 0 || pushing)
|
|
296
|
+
return null;
|
|
297
|
+
pushing = true;
|
|
298
|
+
return { title: titles.size === 1 ? [...titles][0] : MIXED_TITLE, body: lines.join('\n'), told };
|
|
299
|
+
};
|
|
300
|
+
// Prompts typed whose "done" could not be written (`session@startedAt`); in memory, so only for this listener's life.
|
|
301
|
+
const delivered = new Set();
|
|
302
|
+
// Whether any running session is still waiting for its prompt: `poll` costs nothing while none is.
|
|
303
|
+
let watching = true;
|
|
304
|
+
/** Delivers the prompts of running sessions and ends those past their runtime cap, then saves what changed. */
|
|
305
|
+
const supervise = (loaded, now) => {
|
|
306
|
+
const { schedules, state } = loaded;
|
|
307
|
+
for (const schedule of schedules) {
|
|
308
|
+
const st = state[schedule.id];
|
|
309
|
+
const running = st.running;
|
|
310
|
+
if (!running)
|
|
311
|
+
continue;
|
|
312
|
+
const label = `schedule ${schedule.id} (${schedule.label})`;
|
|
313
|
+
// Asked to end once; it stays counted until it is gone (below), whatever it does with the request.
|
|
314
|
+
if (running.capped)
|
|
315
|
+
continue;
|
|
316
|
+
if (now - running.startedAt > schedule.maxRuntimeMin * 60_000) {
|
|
317
|
+
// Asking is all it does: a session that does not end stays, counts against `MAX_RUNNING`, and the next due time says overlap (D11).
|
|
318
|
+
running.capped = true;
|
|
319
|
+
running.promptPending = false;
|
|
320
|
+
const cappedKey = `${running.session}@${running.startedAt}`;
|
|
321
|
+
// Once per session: when the state file cannot be written, the next tick finds it uncapped again and must not ask or tell again.
|
|
322
|
+
if (!cappedAsked.has(cappedKey)) {
|
|
323
|
+
cappedAsked.add(cappedKey);
|
|
324
|
+
deps.endSession(running.session);
|
|
325
|
+
st.last = { outcome: 'capped', at: now };
|
|
326
|
+
deps.log(`${label}: capped after ${schedule.maxRuntimeMin} min, asked ${running.session} to end`);
|
|
327
|
+
addPending(st, 'capped', 1, now);
|
|
328
|
+
}
|
|
329
|
+
saveNow(loaded);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (!running.promptPending)
|
|
333
|
+
continue;
|
|
334
|
+
if (schedule.prompt === undefined) {
|
|
335
|
+
// The prompt was taken out of the schedule after the session started: nothing is left to wait for.
|
|
336
|
+
running.promptPending = false;
|
|
337
|
+
loaded.dirty = true;
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const deliveredKey = `${running.session}@${running.startedAt}`;
|
|
341
|
+
if (delivered.has(deliveredKey)) {
|
|
342
|
+
// Typed, but the state saying so could not be written then: never type it twice.
|
|
343
|
+
running.promptPending = false;
|
|
344
|
+
loaded.dirty = true;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
// A prompt that waited too long (or a listener that was down while it waited) is not typed late into whatever the screen shows now.
|
|
348
|
+
const excused = launchedMs - (launchedBefore.get(deliveredKey) ?? launchedMs);
|
|
349
|
+
const late = now - running.startedAt - excused > exports.PROMPT_WAIT_MS;
|
|
350
|
+
if (!late && deps.ready(running.session, schedule.agentKind)) {
|
|
351
|
+
if (deps.inject(running.session, schedule.prompt)) {
|
|
352
|
+
running.promptPending = false;
|
|
353
|
+
launchedBefore.delete(deliveredKey);
|
|
354
|
+
deps.log(`${label}: prompt delivered`);
|
|
355
|
+
// On disk right away: the work after this (other schedules, a push) must not be able to make a restart type it again.
|
|
356
|
+
if (!saveNow(loaded))
|
|
357
|
+
delivered.add(deliveredKey);
|
|
358
|
+
}
|
|
359
|
+
continue; // not delivered (rate limit …): the next poll tries again
|
|
360
|
+
}
|
|
361
|
+
if (late) {
|
|
362
|
+
running.promptPending = false;
|
|
363
|
+
launchedBefore.delete(deliveredKey);
|
|
364
|
+
st.last = { outcome: 'prompt_undelivered', at: now };
|
|
365
|
+
addPending(st, 'prompt_undelivered', 1, now);
|
|
366
|
+
deps.log(`${label}: ${running.session} was not ready for its prompt in time`);
|
|
367
|
+
saveNow(loaded);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
watching = Object.values(state).some((st) => st.running?.promptPending);
|
|
371
|
+
if (loaded.dirty)
|
|
372
|
+
loaded.save();
|
|
373
|
+
};
|
|
374
|
+
/** Writes the state now; what a failed write leaves behind is still `dirty`, for the save at the end. */
|
|
375
|
+
const saveNow = (loaded) => {
|
|
376
|
+
loaded.dirty = !loaded.save();
|
|
377
|
+
return !loaded.dirty;
|
|
378
|
+
};
|
|
379
|
+
const start = async (schedule, st, state, typable, at) => {
|
|
380
|
+
if (deps.sessionExists(schedule.session))
|
|
381
|
+
return 'overlap';
|
|
382
|
+
if (Object.values(state).filter((other) => other.running).length >= exports.MAX_RUNNING)
|
|
383
|
+
return 'busy';
|
|
384
|
+
if (!deps.directoryExists(schedule.cwd))
|
|
385
|
+
return 'missing_directory';
|
|
386
|
+
const subcommand = deps.subcommandFor(schedule.agentKind);
|
|
387
|
+
if (subcommand === undefined)
|
|
388
|
+
return 'unknown_agent';
|
|
389
|
+
const launched = await deps.launch((0, exports.launchArgs)(schedule, subcommand), schedule.cwd, schedule.wt !== undefined ? exports.LAUNCH_WT_TIMEOUT_MS : exports.LAUNCH_TIMEOUT_MS);
|
|
390
|
+
if (!launched.ok) {
|
|
391
|
+
deps.log(`schedule ${schedule.id}: launch failed: ${firstLogLine(launched.error)}`);
|
|
392
|
+
return 'start_failed';
|
|
393
|
+
}
|
|
394
|
+
await deps.sleep(exports.START_CHECK_MS);
|
|
395
|
+
if (!deps.sessionExists(schedule.session)) {
|
|
396
|
+
deps.log(`schedule ${schedule.id}: the launch exited 0 but ${schedule.session} is not there`);
|
|
397
|
+
return 'start_failed';
|
|
398
|
+
}
|
|
399
|
+
st.running = { session: schedule.session, startedAt: at(), promptPending: schedule.prompt !== undefined && typable };
|
|
400
|
+
return 'started';
|
|
401
|
+
};
|
|
402
|
+
// One of tick and poll at a time: both read the state file, change it and write it back.
|
|
403
|
+
const exclusive = async (work) => {
|
|
404
|
+
if (working)
|
|
405
|
+
return undefined;
|
|
406
|
+
working = true;
|
|
407
|
+
try {
|
|
408
|
+
return await work();
|
|
409
|
+
}
|
|
410
|
+
finally {
|
|
411
|
+
working = false;
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
// The push goes out after the tick has let go of the state: it can take a minute, and nothing else should wait for that.
|
|
415
|
+
const tick = async (now) => {
|
|
416
|
+
const report = await exclusive(() => run(now));
|
|
417
|
+
if (!report)
|
|
418
|
+
return;
|
|
419
|
+
if (now < nextPushAt) {
|
|
420
|
+
// The last push failed and its retry is not due yet: the news waits in `pending`.
|
|
421
|
+
pushing = false;
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
let outcome = 'retry';
|
|
425
|
+
try {
|
|
426
|
+
outcome = await deps.notify(report.title, report.body);
|
|
427
|
+
}
|
|
428
|
+
catch (e) {
|
|
429
|
+
deps.log(`! schedules: could not notify: ${e instanceof Error ? e.message : String(e)}`);
|
|
430
|
+
}
|
|
431
|
+
if (outcome === 'retry') {
|
|
432
|
+
pushing = false;
|
|
433
|
+
nextPushAt = now + retryAfter;
|
|
434
|
+
deps.log(`! schedules: the push did not go out; it is tried again in ${Math.round(retryAfter / 1000)} s`);
|
|
435
|
+
retryAfter = Math.min(retryAfter * 2, exports.PUSH_RETRY_MAX_MS);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (outcome === 'rejected')
|
|
439
|
+
deps.log('! schedules: the server refused the push; it is not sent again');
|
|
440
|
+
retryAfter = exports.PUSH_RETRY_MS;
|
|
441
|
+
nextPushAt = 0;
|
|
442
|
+
sentReports.push(report.told);
|
|
443
|
+
};
|
|
444
|
+
const poll = async (now) => {
|
|
445
|
+
await exclusive(() => {
|
|
446
|
+
if (watching)
|
|
447
|
+
supervise(load(now), now);
|
|
448
|
+
});
|
|
449
|
+
};
|
|
450
|
+
return { tick, poll };
|
|
451
|
+
};
|
|
452
|
+
exports.createScheduleRunner = createScheduleRunner;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduled sessions (F8): what a schedule is, when it comes due, and the files it lives in.
|
|
3
|
+
*
|
|
4
|
+
* A schedule is one file, `<stateDir>/schedules/<id>.json` (0600). `hwip schedule` writes and
|
|
5
|
+
* deletes them, the listener only reads them, and nothing reads-then-rewrites a file, so the two
|
|
6
|
+
* never lose each other's change. What the listener has done about each one lives in its own file
|
|
7
|
+
* (`schedule-state.json`, `schedule-runner.ts`).
|
|
8
|
+
*
|
|
9
|
+
* Everything here is pure or touches only that directory. The clock is a parameter.
|
|
10
|
+
*/
|
|
11
|
+
export type ScheduleWhen = {
|
|
12
|
+
kind: 'daily';
|
|
13
|
+
at: string;
|
|
14
|
+
days: number[];
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'every';
|
|
17
|
+
minutes: number;
|
|
18
|
+
};
|
|
19
|
+
export interface Schedule {
|
|
20
|
+
v: 1;
|
|
21
|
+
/** Lowercase base36, 6 characters. It becomes a file name, so nothing else is accepted. */
|
|
22
|
+
id: string;
|
|
23
|
+
createdAt: string;
|
|
24
|
+
source: 'cli' | 'phone';
|
|
25
|
+
/** `REMOTE_AGENTS` kind. Checked against the table when it runs, not here: a hand-edited file is the runner's to refuse. */
|
|
26
|
+
agentKind: string;
|
|
27
|
+
cwd: string;
|
|
28
|
+
/** Passed to `hwip <agent> --label`; the default is `sched-<id>`. */
|
|
29
|
+
label: string;
|
|
30
|
+
/** The tmux session the launch will create, worked out when the schedule is made. */
|
|
31
|
+
session: string;
|
|
32
|
+
/** `--wt <branch>`. From the command line only. */
|
|
33
|
+
wt?: string;
|
|
34
|
+
/** Arguments after `--`. From the command line only. */
|
|
35
|
+
agentArgs?: string[];
|
|
36
|
+
prompt?: string;
|
|
37
|
+
when: ScheduleWhen;
|
|
38
|
+
maxRuntimeMin: number;
|
|
39
|
+
}
|
|
40
|
+
export type Checked<T> = {
|
|
41
|
+
ok: true;
|
|
42
|
+
value: T;
|
|
43
|
+
} | {
|
|
44
|
+
ok: false;
|
|
45
|
+
error: string;
|
|
46
|
+
};
|
|
47
|
+
export declare const MAX_SCHEDULES = 20;
|
|
48
|
+
/** How long a session may run when nobody said: two hours, and never longer than the period of an `every` schedule. */
|
|
49
|
+
export declare const defaultMaxRuntimeMin: (when: ScheduleWhen) => number;
|
|
50
|
+
export declare const DAY_NAMES: readonly ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
|
51
|
+
export declare const ALL_DAYS: readonly number[];
|
|
52
|
+
export declare const WEEKDAYS: readonly number[];
|
|
53
|
+
/** `<N>m` or `<N>h` as minutes; `example` is what the error shows for a value that is neither. */
|
|
54
|
+
export declare const parseDuration: (raw: string, flag: string, example: string) => Checked<number>;
|
|
55
|
+
/** `--at HH:MM [--days …]` or `--every <N>m|<N>h`, not both and not neither. */
|
|
56
|
+
export declare const parseWhen: (input: {
|
|
57
|
+
at?: string;
|
|
58
|
+
days?: string;
|
|
59
|
+
every?: string;
|
|
60
|
+
}) => Checked<ScheduleWhen>;
|
|
61
|
+
export interface Occurrences {
|
|
62
|
+
count: number;
|
|
63
|
+
/** The last one in the span, ms since the epoch. */
|
|
64
|
+
latest: number | null;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The times a schedule comes due in `(after, until]`.
|
|
68
|
+
*
|
|
69
|
+
* `every` is arithmetic from `anchor` (when the schedule was made; the anchor itself is not a run),
|
|
70
|
+
* so a machine that slept for a day counts its misses without looping. `daily` walks local dates and
|
|
71
|
+
* builds each time with the local-time constructor, which settles the two DST cases the way the
|
|
72
|
+
* spec says: a time the clock skips (spring) happens once, at the instant JS moves it to, and a time
|
|
73
|
+
* the clock repeats (fall) happens once, the first time round.
|
|
74
|
+
*/
|
|
75
|
+
export declare const occurrencesBetween: (when: ScheduleWhen, after: number, until: number, anchor: number) => Occurrences;
|
|
76
|
+
/** The first time after `after` the schedule comes due; null for a `daily` with no days (a file `validateSchedule` would refuse). */
|
|
77
|
+
export declare const nextOccurrence: (when: ScheduleWhen, after: number, anchor: number) => number | null;
|
|
78
|
+
/**
|
|
79
|
+
* Whether `input` is a schedule this machine will run. The file reader, `hwip schedule add` and (in
|
|
80
|
+
* the remote PR) the phone's request all pass through here, so a limit lives in one place. Unknown
|
|
81
|
+
* fields are an error: a field nobody checks is a field that gets read later.
|
|
82
|
+
*/
|
|
83
|
+
export declare const validateSchedule: (input: unknown) => Checked<Schedule>;
|
|
84
|
+
export declare const schedulesDir: () => string;
|
|
85
|
+
export interface ScheduleList {
|
|
86
|
+
schedules: Schedule[];
|
|
87
|
+
/** `.json` files that could not be read or did not pass `validateSchedule`. */
|
|
88
|
+
unreadable: number;
|
|
89
|
+
/** The directory could not be listed, or holds more than `MAX_SCHEDULES`: what is in `schedules` is not everything there is. */
|
|
90
|
+
incomplete?: true;
|
|
91
|
+
}
|
|
92
|
+
export declare const listSchedules: () => ScheduleList;
|
|
93
|
+
/** `failure`: `limit` — `MAX_SCHEDULES` are already there; `io` — the directory or file could not be made. Absent: the schedule itself was refused. */
|
|
94
|
+
export type AddResult = {
|
|
95
|
+
ok: true;
|
|
96
|
+
schedule: Schedule;
|
|
97
|
+
} | {
|
|
98
|
+
ok: false;
|
|
99
|
+
error: string;
|
|
100
|
+
failure?: 'limit' | 'io';
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Write a new schedule. `make` gets the id so the label and session name that contain it can be
|
|
104
|
+
* built first. The file is created exclusively: an id that is taken is never overwritten, a new one
|
|
105
|
+
* is drawn.
|
|
106
|
+
*/
|
|
107
|
+
export declare const addSchedule: (make: (id: string) => Schedule, newId?: () => string) => AddResult;
|
|
108
|
+
/** `invalid` means the id could not name a schedule file; nothing was touched. */
|
|
109
|
+
export type RemoveResult = 'removed' | 'missing' | 'invalid';
|
|
110
|
+
export declare const removeSchedule: (id: string) => RemoveResult;
|
|
111
|
+
declare const SCHEDULE_OUTCOMES: readonly ["started", "missed", "overlap", "busy", "start_failed", "missing_directory", "unknown_agent", "prompt_undelivered", "prompt_unsupported", "capped"];
|
|
112
|
+
export type ScheduleOutcome = (typeof SCHEDULE_OUTCOMES)[number];
|
|
113
|
+
/** Per schedule, in `schedule-state.json`. Only the listener writes it (`schedule-runner.ts`); `hwip schedule list` reads it. */
|
|
114
|
+
export interface ScheduleRunState {
|
|
115
|
+
/** Time (ms) up to which due times have been dealt with; the next tick counts from here. */
|
|
116
|
+
cursor: number;
|
|
117
|
+
/** `capped`: the runtime cap passed and the session was asked to end; it keeps counting as running until it is gone. */
|
|
118
|
+
running?: {
|
|
119
|
+
session: string;
|
|
120
|
+
startedAt: number;
|
|
121
|
+
promptPending: boolean;
|
|
122
|
+
capped?: true;
|
|
123
|
+
};
|
|
124
|
+
last?: {
|
|
125
|
+
outcome: ScheduleOutcome;
|
|
126
|
+
at: number;
|
|
127
|
+
};
|
|
128
|
+
/** Results not yet told to the person, by reason, and when the first of them was added. */
|
|
129
|
+
pending?: {
|
|
130
|
+
since: number;
|
|
131
|
+
counts: Partial<Record<ScheduleOutcome, number>>;
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
export interface ScheduleStateFile {
|
|
135
|
+
v: 1;
|
|
136
|
+
schedules: Record<string, ScheduleRunState>;
|
|
137
|
+
/** Set by `readScheduleState` only: the file is there but was not (all) readable, so the cursors in it are not to be trusted as complete. */
|
|
138
|
+
damaged?: true;
|
|
139
|
+
}
|
|
140
|
+
export declare const scheduleStatePath: () => string;
|
|
141
|
+
/**
|
|
142
|
+
* The state file, tolerant of everything: a missing file reads as nothing done yet, an unreadable or damaged one
|
|
143
|
+
* as what could be read plus `damaged`, so the listener does not take "nothing recorded" for "nothing happened".
|
|
144
|
+
*/
|
|
145
|
+
export declare const readScheduleState: () => ScheduleStateFile;
|
|
146
|
+
/** Replaces the state file atomically (0600). False when it could not be written; nothing is half-written. */
|
|
147
|
+
export declare const writeScheduleState: (state: ScheduleStateFile) => boolean;
|
|
148
|
+
export {};
|
|
149
|
+
//# sourceMappingURL=schedule.d.ts.map
|