@hwip/cli 0.0.1 → 0.1.0
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 +1979 -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 +292 -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 +35 -0
- package/dist/blocked-prompt-seal.js +89 -0
- package/dist/blocked-prompt.d.ts +57 -0
- package/dist/blocked-prompt.js +181 -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 +272 -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 +5680 -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
package/dist/schedule.js
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Scheduled sessions (F8): what a schedule is, when it comes due, and the files it lives in.
|
|
4
|
+
*
|
|
5
|
+
* A schedule is one file, `<stateDir>/schedules/<id>.json` (0600). `hwip schedule` writes and
|
|
6
|
+
* deletes them, the listener only reads them, and nothing reads-then-rewrites a file, so the two
|
|
7
|
+
* never lose each other's change. What the listener has done about each one lives in its own file
|
|
8
|
+
* (`schedule-state.json`, `schedule-runner.ts`).
|
|
9
|
+
*
|
|
10
|
+
* Everything here is pure or touches only that directory. The clock is a parameter.
|
|
11
|
+
*/
|
|
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;
|
|
14
|
+
const node_crypto_1 = require("node:crypto");
|
|
15
|
+
const node_fs_1 = require("node:fs");
|
|
16
|
+
const node_path_1 = require("node:path");
|
|
17
|
+
const gate_js_1 = require("./gate.js");
|
|
18
|
+
const private_files_js_1 = require("./private-files.js");
|
|
19
|
+
const replace_file_js_1 = require("./replace-file.js");
|
|
20
|
+
const isErrno = (err, code) => err instanceof Error && 'code' in err && err.code === code;
|
|
21
|
+
const fail = (error) => ({ ok: false, error });
|
|
22
|
+
exports.MAX_SCHEDULES = 20;
|
|
23
|
+
const MIN_EVERY_MIN = 15;
|
|
24
|
+
const MAX_EVERY_MIN = 366 * 24 * 60;
|
|
25
|
+
const MAX_PROMPT_CHARS = 4000;
|
|
26
|
+
const DEFAULT_MAX_RUNTIME_MIN = 120;
|
|
27
|
+
/** How long a session may run when nobody said: two hours, and never longer than the period of an `every` schedule. */
|
|
28
|
+
const defaultMaxRuntimeMin = (when) => Math.min(DEFAULT_MAX_RUNTIME_MIN, when.kind === 'every' ? when.minutes : DEFAULT_MAX_RUNTIME_MIN);
|
|
29
|
+
exports.defaultMaxRuntimeMin = defaultMaxRuntimeMin;
|
|
30
|
+
const MIN_MAX_RUNTIME_MIN = 5;
|
|
31
|
+
const MAX_MAX_RUNTIME_MIN = 1440;
|
|
32
|
+
const MAX_AGENT_ARGS = 64;
|
|
33
|
+
const MAX_ARG_CHARS = 4000;
|
|
34
|
+
/** A schedule file is a few hundred bytes; one this big was not written by `addSchedule`. */
|
|
35
|
+
const MAX_FILE_BYTES = 256 * 1024;
|
|
36
|
+
// ── When ─────────────────────────────────────────────────────────────
|
|
37
|
+
exports.DAY_NAMES = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
|
38
|
+
const DAY_NUMBER = Object.fromEntries(exports.DAY_NAMES.map((name, day) => [name, day]));
|
|
39
|
+
exports.ALL_DAYS = [0, 1, 2, 3, 4, 5, 6];
|
|
40
|
+
exports.WEEKDAYS = [1, 2, 3, 4, 5];
|
|
41
|
+
const AT_TIME = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
42
|
+
const parseDays = (raw) => {
|
|
43
|
+
const word = raw.trim().toLowerCase();
|
|
44
|
+
if (word === 'daily')
|
|
45
|
+
return { ok: true, value: [...exports.ALL_DAYS] };
|
|
46
|
+
if (word === 'weekdays')
|
|
47
|
+
return { ok: true, value: [...exports.WEEKDAYS] };
|
|
48
|
+
const picked = new Set();
|
|
49
|
+
for (const part of word.split(',')) {
|
|
50
|
+
const day = Object.hasOwn(DAY_NUMBER, part.trim()) ? DAY_NUMBER[part.trim()] : undefined;
|
|
51
|
+
if (day === undefined)
|
|
52
|
+
return fail(`--days takes daily, weekdays or day names like mon,wed,fri (got '${raw}')`);
|
|
53
|
+
picked.add(day);
|
|
54
|
+
}
|
|
55
|
+
return { ok: true, value: [...picked].sort((a, b) => a - b) };
|
|
56
|
+
};
|
|
57
|
+
/** `<N>m` or `<N>h` as minutes; `example` is what the error shows for a value that is neither. */
|
|
58
|
+
const parseDuration = (raw, flag, example) => {
|
|
59
|
+
const match = /^(\d{1,7})([mh])$/.exec(raw.trim().toLowerCase());
|
|
60
|
+
if (!match)
|
|
61
|
+
return fail(`${flag} takes minutes or hours like ${example} (got '${raw}')`);
|
|
62
|
+
return { ok: true, value: Number(match[1]) * (match[2] === 'h' ? 60 : 1) };
|
|
63
|
+
};
|
|
64
|
+
exports.parseDuration = parseDuration;
|
|
65
|
+
const parseEvery = (raw) => {
|
|
66
|
+
const duration = (0, exports.parseDuration)(raw, '--every', '30m or 2h');
|
|
67
|
+
if (!duration.ok)
|
|
68
|
+
return duration;
|
|
69
|
+
const minutes = duration.value;
|
|
70
|
+
if (minutes < MIN_EVERY_MIN)
|
|
71
|
+
return fail(`--every must be at least ${MIN_EVERY_MIN}m`);
|
|
72
|
+
if (minutes > MAX_EVERY_MIN)
|
|
73
|
+
return fail('--every must be at most 366 days');
|
|
74
|
+
return { ok: true, value: minutes };
|
|
75
|
+
};
|
|
76
|
+
/** `--at HH:MM [--days …]` or `--every <N>m|<N>h`, not both and not neither. */
|
|
77
|
+
const parseWhen = (input) => {
|
|
78
|
+
const { at, days, every } = input;
|
|
79
|
+
if (every !== undefined) {
|
|
80
|
+
if (at !== undefined || days !== undefined)
|
|
81
|
+
return fail('--every cannot be combined with --at or --days');
|
|
82
|
+
const minutes = parseEvery(every);
|
|
83
|
+
return minutes.ok ? { ok: true, value: { kind: 'every', minutes: minutes.value } } : minutes;
|
|
84
|
+
}
|
|
85
|
+
if (at === undefined)
|
|
86
|
+
return fail(days === undefined ? 'give --at HH:MM (with --days) or --every <N>m' : '--days needs --at HH:MM');
|
|
87
|
+
if (!AT_TIME.test(at.trim()))
|
|
88
|
+
return fail(`--at takes a 24-hour local time like 09:00 (got '${at}')`);
|
|
89
|
+
const parsed = parseDays(days ?? 'daily');
|
|
90
|
+
return parsed.ok ? { ok: true, value: { kind: 'daily', at: at.trim(), days: parsed.value } } : parsed;
|
|
91
|
+
};
|
|
92
|
+
exports.parseWhen = parseWhen;
|
|
93
|
+
/** The longest stretch `occurrencesBetween` walks day by day. */
|
|
94
|
+
const MAX_WALK_MS = 400 * 24 * 60 * 60 * 1000;
|
|
95
|
+
/** How many whole periods of an `every` schedule lie between its anchor and `t`. */
|
|
96
|
+
const periodsSince = (anchor, period, t) => Math.max(0, Math.floor((t - anchor) / period));
|
|
97
|
+
/** Local midnight of the day `step` days after the day `from` falls on. Built from the calendar date, so a day that is 23 or 25 hours long is still one step. */
|
|
98
|
+
const dayAfter = (from, step) => {
|
|
99
|
+
const first = new Date(from);
|
|
100
|
+
return new Date(first.getFullYear(), first.getMonth(), first.getDate() + step);
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* The local time of day of a `daily` schedule on `day`, or null when it does not run on that weekday. Built from the
|
|
104
|
+
* calendar date and the clock time, so a time the clocks skip runs once at the moved time and a repeated one once.
|
|
105
|
+
*/
|
|
106
|
+
const timeOnDay = (when, day) => {
|
|
107
|
+
if (!when.days.includes(day.getDay()))
|
|
108
|
+
return null;
|
|
109
|
+
const [hour, minute] = when.at.split(':').map(Number);
|
|
110
|
+
return new Date(day.getFullYear(), day.getMonth(), day.getDate(), hour, minute).getTime();
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* The times a schedule comes due in `(after, until]`.
|
|
114
|
+
*
|
|
115
|
+
* `every` is arithmetic from `anchor` (when the schedule was made; the anchor itself is not a run),
|
|
116
|
+
* so a machine that slept for a day counts its misses without looping. `daily` walks local dates and
|
|
117
|
+
* builds each time with the local-time constructor, which settles the two DST cases the way the
|
|
118
|
+
* spec says: a time the clock skips (spring) happens once, at the instant JS moves it to, and a time
|
|
119
|
+
* the clock repeats (fall) happens once, the first time round.
|
|
120
|
+
*/
|
|
121
|
+
const occurrencesBetween = (when, after, until, anchor) => {
|
|
122
|
+
// A cursor read from a damaged state file may not be a number; the day walk below would never end on one.
|
|
123
|
+
if (!Number.isFinite(after) || !Number.isFinite(until) || until <= after)
|
|
124
|
+
return { count: 0, latest: null };
|
|
125
|
+
if (when.kind === 'every') {
|
|
126
|
+
const period = when.minutes * 60_000;
|
|
127
|
+
const upTo = periodsSince(anchor, period, until);
|
|
128
|
+
const count = upTo - periodsSince(anchor, period, after);
|
|
129
|
+
return { count, latest: count > 0 ? anchor + upTo * period : null };
|
|
130
|
+
}
|
|
131
|
+
let count = 0;
|
|
132
|
+
let latest = null;
|
|
133
|
+
// A cursor from a hand-edited file may lie millennia back; a year and more of days is all that is ever counted.
|
|
134
|
+
const from = Math.max(after, until - MAX_WALK_MS);
|
|
135
|
+
for (let step = 0;; step++) {
|
|
136
|
+
const day = dayAfter(from, step);
|
|
137
|
+
if (day.getTime() > until)
|
|
138
|
+
break;
|
|
139
|
+
const at = timeOnDay(when, day);
|
|
140
|
+
if (at === null || at <= from || at > until)
|
|
141
|
+
continue;
|
|
142
|
+
count += 1;
|
|
143
|
+
latest = at;
|
|
144
|
+
}
|
|
145
|
+
return { count, latest };
|
|
146
|
+
};
|
|
147
|
+
exports.occurrencesBetween = occurrencesBetween;
|
|
148
|
+
/** The first time after `after` the schedule comes due; null for a `daily` with no days (a file `validateSchedule` would refuse). */
|
|
149
|
+
const nextOccurrence = (when, after, anchor) => {
|
|
150
|
+
if (when.kind === 'every') {
|
|
151
|
+
const period = when.minutes * 60_000;
|
|
152
|
+
return anchor + (periodsSince(anchor, period, after) + 1) * period;
|
|
153
|
+
}
|
|
154
|
+
for (let step = 0; step <= 8; step++) {
|
|
155
|
+
const at = timeOnDay(when, dayAfter(after, step));
|
|
156
|
+
if (at !== null && at > after)
|
|
157
|
+
return at;
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
};
|
|
161
|
+
exports.nextOccurrence = nextOccurrence;
|
|
162
|
+
// ── Validation ───────────────────────────────────────────────────────
|
|
163
|
+
const SCHEDULE_ID = /^[a-z0-9]{6}$/;
|
|
164
|
+
const LABEL = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
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']);
|
|
167
|
+
const isScheduleId = (value) => typeof value === 'string' && SCHEDULE_ID.test(value);
|
|
168
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
169
|
+
const noControl = (text) => !/[\u0000-\u001f\u007f]/.test(text);
|
|
170
|
+
/**
|
|
171
|
+
* A session this feature may type into or end: a `hwip-` tmux session name. tmux reads `:` and `.` in a `-t` target
|
|
172
|
+
* as syntax (and `;` separates commands); `tmuxSessionName` never leaves one in a session name.
|
|
173
|
+
*/
|
|
174
|
+
const isSessionName = (value) => typeof value === 'string' && value.startsWith('hwip-') && value.length <= 256 && noControl(value) && !/[:.;]/.test(value);
|
|
175
|
+
const isInt = (value, min, max) => typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max;
|
|
176
|
+
const checkWhen = (raw) => {
|
|
177
|
+
if (!isRecord(raw))
|
|
178
|
+
return 'when must be an object';
|
|
179
|
+
if (raw.kind === 'every') {
|
|
180
|
+
if (Object.keys(raw).some((k) => k !== 'kind' && k !== 'minutes'))
|
|
181
|
+
return 'when has a field it does not know';
|
|
182
|
+
return isInt(raw.minutes, MIN_EVERY_MIN, MAX_EVERY_MIN) ? null : `when.minutes must be a whole number of at least ${MIN_EVERY_MIN}`;
|
|
183
|
+
}
|
|
184
|
+
if (raw.kind !== 'daily')
|
|
185
|
+
return "when.kind must be 'daily' or 'every'";
|
|
186
|
+
if (Object.keys(raw).some((k) => k !== 'kind' && k !== 'at' && k !== 'days'))
|
|
187
|
+
return 'when has a field it does not know';
|
|
188
|
+
if (typeof raw.at !== 'string' || !AT_TIME.test(raw.at))
|
|
189
|
+
return 'when.at must be HH:MM';
|
|
190
|
+
const days = raw.days;
|
|
191
|
+
const wellFormed = Array.isArray(days) && days.length > 0 && days.every((d) => isInt(d, 0, 6)) && new Set(days).size === days.length;
|
|
192
|
+
return wellFormed ? null : 'when.days must be distinct day numbers 0 to 6';
|
|
193
|
+
};
|
|
194
|
+
/**
|
|
195
|
+
* Whether `input` is a schedule this machine will run. The file reader, `hwip schedule add` and (in
|
|
196
|
+
* the remote PR) the phone's request all pass through here, so a limit lives in one place. Unknown
|
|
197
|
+
* fields are an error: a field nobody checks is a field that gets read later.
|
|
198
|
+
*/
|
|
199
|
+
const validateSchedule = (input) => {
|
|
200
|
+
if (!isRecord(input))
|
|
201
|
+
return fail('a schedule must be an object');
|
|
202
|
+
const unknownKey = Object.keys(input).find((k) => !KEYS.has(k));
|
|
203
|
+
if (unknownKey !== undefined)
|
|
204
|
+
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
|
+
if (v !== 1)
|
|
207
|
+
return fail('schedule.v must be 1');
|
|
208
|
+
if (!isScheduleId(id))
|
|
209
|
+
return fail('schedule.id must be 6 lowercase letters or digits');
|
|
210
|
+
if (typeof createdAt !== 'string' || createdAt.length > 40 || !Number.isFinite(Date.parse(createdAt)))
|
|
211
|
+
return fail('schedule.createdAt must be a date');
|
|
212
|
+
if (source !== 'cli' && source !== 'phone')
|
|
213
|
+
return fail("schedule.source must be 'cli' or 'phone'");
|
|
214
|
+
if (typeof agentKind !== 'string' || !AGENT_KIND.test(agentKind))
|
|
215
|
+
return fail('schedule.agentKind is not an agent kind');
|
|
216
|
+
if (typeof cwd !== 'string' || !(0, node_path_1.isAbsolute)(cwd) || cwd.length > 4096 || !noControl(cwd))
|
|
217
|
+
return fail('schedule.cwd must be an absolute path');
|
|
218
|
+
if (typeof label !== 'string' || !LABEL.test(label))
|
|
219
|
+
return fail('schedule.label may hold letters, digits, - and _ (63 after the first character)');
|
|
220
|
+
if (!isSessionName(session))
|
|
221
|
+
return fail('schedule.session must be a hwip- tmux session name');
|
|
222
|
+
if (wt !== undefined && (typeof wt !== 'string' || wt === '' || wt.startsWith('-') || wt.length > 200 || !noControl(wt)))
|
|
223
|
+
return fail('schedule.wt must be a branch name');
|
|
224
|
+
if (agentArgs !== undefined) {
|
|
225
|
+
const wellFormed = Array.isArray(agentArgs) && agentArgs.length <= MAX_AGENT_ARGS
|
|
226
|
+
&& agentArgs.every((a) => typeof a === 'string' && a.length <= MAX_ARG_CHARS && !a.includes('\0'));
|
|
227
|
+
if (!wellFormed)
|
|
228
|
+
return fail(`schedule.agentArgs must be at most ${MAX_AGENT_ARGS} strings of at most ${MAX_ARG_CHARS} characters`);
|
|
229
|
+
}
|
|
230
|
+
// `hwip <agent> --detach --label … --wt …` reads its own flags first and stops at the first word that is not one:
|
|
231
|
+
// an agent argument that opens like one would be taken for the wrapper's.
|
|
232
|
+
if (Array.isArray(agentArgs) && typeof agentArgs[0] === 'string' && /^--(detach|label|wt)(=|$)/.test(agentArgs[0])) {
|
|
233
|
+
return fail('schedule.agentArgs cannot start with --detach, --label or --wt');
|
|
234
|
+
}
|
|
235
|
+
if (source === 'phone' && (wt !== undefined || agentArgs !== undefined))
|
|
236
|
+
return fail('a schedule from the phone carries no wt or agentArgs');
|
|
237
|
+
if (prompt !== undefined && (typeof prompt !== 'string' || prompt === '' || prompt.length > MAX_PROMPT_CHARS || prompt.includes('\0'))) {
|
|
238
|
+
return fail(`schedule.prompt must be 1 to ${MAX_PROMPT_CHARS} characters`);
|
|
239
|
+
}
|
|
240
|
+
if (source === 'phone' && prompt === undefined)
|
|
241
|
+
return fail('a schedule from the phone needs a prompt');
|
|
242
|
+
const badWhen = checkWhen(when);
|
|
243
|
+
if (badWhen)
|
|
244
|
+
return fail(badWhen);
|
|
245
|
+
const checkedWhen = when; // checkWhen accepted its shape
|
|
246
|
+
if (!isInt(maxRuntimeMin, MIN_MAX_RUNTIME_MIN, MAX_MAX_RUNTIME_MIN))
|
|
247
|
+
return fail(`schedule.maxRuntimeMin must be ${MIN_MAX_RUNTIME_MIN} to ${MAX_MAX_RUNTIME_MIN}`);
|
|
248
|
+
if (checkedWhen.kind === 'every' && maxRuntimeMin > checkedWhen.minutes)
|
|
249
|
+
return fail('schedule.maxRuntimeMin cannot be longer than the --every period');
|
|
250
|
+
// Every field of `Schedule` was checked above; `input` is a record, which TypeScript cannot narrow field by field.
|
|
251
|
+
return { ok: true, value: input };
|
|
252
|
+
};
|
|
253
|
+
exports.validateSchedule = validateSchedule;
|
|
254
|
+
// ── Files ────────────────────────────────────────────────────────────
|
|
255
|
+
const schedulesDir = () => (0, node_path_1.join)((0, gate_js_1.stateDir)(), 'schedules');
|
|
256
|
+
exports.schedulesDir = schedulesDir;
|
|
257
|
+
const schedulePath = (id) => (0, node_path_1.join)((0, exports.schedulesDir)(), `${id}.json`);
|
|
258
|
+
const newScheduleId = () => Array.from({ length: 6 }, () => (0, node_crypto_1.randomInt)(36).toString(36)).join('');
|
|
259
|
+
const readOne = (id) => {
|
|
260
|
+
try {
|
|
261
|
+
const path = schedulePath(id);
|
|
262
|
+
// Only the user's own regular file: a link could point anywhere, and a file others can write is not the user's word.
|
|
263
|
+
const info = (0, node_fs_1.lstatSync)(path);
|
|
264
|
+
if (!info.isFile() || (info.mode & 0o022) !== 0)
|
|
265
|
+
return null;
|
|
266
|
+
if (info.size > MAX_FILE_BYTES)
|
|
267
|
+
return null;
|
|
268
|
+
const checked = (0, exports.validateSchedule)(JSON.parse((0, node_fs_1.readFileSync)(path, 'utf8')));
|
|
269
|
+
return checked.ok && checked.value.id === id ? checked.value : null;
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
const listSchedules = () => {
|
|
276
|
+
let names;
|
|
277
|
+
try {
|
|
278
|
+
names = (0, node_fs_1.readdirSync)((0, exports.schedulesDir)());
|
|
279
|
+
}
|
|
280
|
+
catch (e) {
|
|
281
|
+
// No directory yet is just no schedules; any other failure means the list is unknown.
|
|
282
|
+
return isErrno(e, 'ENOENT') ? { schedules: [], unreadable: 0 } : { schedules: [], unreadable: 0, incomplete: true };
|
|
283
|
+
}
|
|
284
|
+
const schedules = [];
|
|
285
|
+
let unreadable = 0;
|
|
286
|
+
for (const name of names) {
|
|
287
|
+
if (!name.endsWith('.json'))
|
|
288
|
+
continue;
|
|
289
|
+
const id = name.slice(0, -'.json'.length);
|
|
290
|
+
const schedule = isScheduleId(id) ? readOne(id) : null;
|
|
291
|
+
if (schedule)
|
|
292
|
+
schedules.push(schedule);
|
|
293
|
+
else
|
|
294
|
+
unreadable += 1;
|
|
295
|
+
}
|
|
296
|
+
schedules.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
|
297
|
+
// `addSchedule` stops at the limit, but files can arrive by hand: the oldest are kept, the rest are not run.
|
|
298
|
+
if (schedules.length > exports.MAX_SCHEDULES) {
|
|
299
|
+
unreadable += schedules.length - exports.MAX_SCHEDULES;
|
|
300
|
+
schedules.length = exports.MAX_SCHEDULES;
|
|
301
|
+
return { schedules, unreadable, incomplete: true };
|
|
302
|
+
}
|
|
303
|
+
return { schedules, unreadable };
|
|
304
|
+
};
|
|
305
|
+
exports.listSchedules = listSchedules;
|
|
306
|
+
const ID_ATTEMPTS = 8;
|
|
307
|
+
/**
|
|
308
|
+
* Write a new schedule. `make` gets the id so the label and session name that contain it can be
|
|
309
|
+
* built first. The file is created exclusively: an id that is taken is never overwritten, a new one
|
|
310
|
+
* is drawn.
|
|
311
|
+
*/
|
|
312
|
+
const addSchedule = (make, newId = newScheduleId) => {
|
|
313
|
+
const { schedules, unreadable } = (0, exports.listSchedules)();
|
|
314
|
+
if (schedules.length + unreadable >= exports.MAX_SCHEDULES) {
|
|
315
|
+
return { ok: false, error: `there are already ${exports.MAX_SCHEDULES} schedules; remove one with 'hwip schedule rm <id>'`, failure: 'limit' };
|
|
316
|
+
}
|
|
317
|
+
for (let attempt = 0; attempt < ID_ATTEMPTS; attempt++) {
|
|
318
|
+
const id = newId();
|
|
319
|
+
const schedule = make(id);
|
|
320
|
+
const checked = (0, exports.validateSchedule)(schedule);
|
|
321
|
+
if (!checked.ok)
|
|
322
|
+
return { ok: false, error: checked.error };
|
|
323
|
+
if (schedule.id !== id)
|
|
324
|
+
return { ok: false, error: 'schedule.id is not the id it was made for' };
|
|
325
|
+
try {
|
|
326
|
+
(0, private_files_js_1.mkdirHandedBack)((0, exports.schedulesDir)(), 0o700);
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
return { ok: false, error: `could not create ${(0, exports.schedulesDir)()}: ${err instanceof Error ? err.message : String(err)}`, failure: 'io' };
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
const path = schedulePath(id);
|
|
333
|
+
(0, node_fs_1.writeFileSync)(path, `${JSON.stringify(schedule, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
334
|
+
(0, private_files_js_1.handBackToSudoUser)(path);
|
|
335
|
+
return { ok: true, schedule };
|
|
336
|
+
}
|
|
337
|
+
catch (err) {
|
|
338
|
+
if (isErrno(err, 'EEXIST'))
|
|
339
|
+
continue;
|
|
340
|
+
return { ok: false, error: `could not write the schedule: ${err instanceof Error ? err.message : String(err)}`, failure: 'io' };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return { ok: false, error: 'could not find a free schedule id', failure: 'io' };
|
|
344
|
+
};
|
|
345
|
+
exports.addSchedule = addSchedule;
|
|
346
|
+
const removeSchedule = (id) => {
|
|
347
|
+
if (!isScheduleId(id))
|
|
348
|
+
return 'invalid';
|
|
349
|
+
try {
|
|
350
|
+
(0, node_fs_1.unlinkSync)(schedulePath(id));
|
|
351
|
+
return 'removed';
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
if (isErrno(err, 'ENOENT'))
|
|
355
|
+
return 'missing';
|
|
356
|
+
throw err;
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
exports.removeSchedule = removeSchedule;
|
|
360
|
+
// ── What the listener has done ───────────────────────────────────────
|
|
361
|
+
const SCHEDULE_OUTCOMES = [
|
|
362
|
+
'started', 'missed', 'overlap', 'busy', 'start_failed', 'missing_directory', 'unknown_agent', 'prompt_undelivered', 'prompt_unsupported', 'capped',
|
|
363
|
+
];
|
|
364
|
+
const scheduleStatePath = () => (0, node_path_1.join)((0, gate_js_1.stateDir)(), 'schedule-state.json');
|
|
365
|
+
exports.scheduleStatePath = scheduleStatePath;
|
|
366
|
+
const isOutcome = (value) => SCHEDULE_OUTCOMES.some((outcome) => outcome === value);
|
|
367
|
+
/** A time as a Date holds it, so `new Date(value)` of anything read back cannot throw. */
|
|
368
|
+
const isNumber = (value) => typeof value === 'number' && Math.abs(value) <= 8.64e15;
|
|
369
|
+
const readRunState = (raw) => {
|
|
370
|
+
if (!isRecord(raw) || !isNumber(raw.cursor))
|
|
371
|
+
return null;
|
|
372
|
+
const state = { cursor: raw.cursor };
|
|
373
|
+
const { running, last, pending } = raw;
|
|
374
|
+
if (isRecord(running) && isSessionName(running.session) && isNumber(running.startedAt) && typeof running.promptPending === 'boolean') {
|
|
375
|
+
state.running = { session: running.session, startedAt: running.startedAt, promptPending: running.promptPending, ...(running.capped === true ? { capped: true } : {}) };
|
|
376
|
+
}
|
|
377
|
+
if (isRecord(last) && isOutcome(last.outcome) && isNumber(last.at))
|
|
378
|
+
state.last = { outcome: last.outcome, at: last.at };
|
|
379
|
+
if (isRecord(pending) && isNumber(pending.since) && isRecord(pending.counts)) {
|
|
380
|
+
const counts = {};
|
|
381
|
+
for (const [reason, count] of Object.entries(pending.counts)) {
|
|
382
|
+
if (isOutcome(reason) && isInt(count, 1, Number.MAX_SAFE_INTEGER))
|
|
383
|
+
counts[reason] = count;
|
|
384
|
+
}
|
|
385
|
+
if (Object.keys(counts).length > 0)
|
|
386
|
+
state.pending = { since: pending.since, counts };
|
|
387
|
+
}
|
|
388
|
+
return state;
|
|
389
|
+
};
|
|
390
|
+
/**
|
|
391
|
+
* The state file, tolerant of everything: a missing file reads as nothing done yet, an unreadable or damaged one
|
|
392
|
+
* as what could be read plus `damaged`, so the listener does not take "nothing recorded" for "nothing happened".
|
|
393
|
+
*/
|
|
394
|
+
const readScheduleState = () => {
|
|
395
|
+
const state = { v: 1, schedules: {} };
|
|
396
|
+
try {
|
|
397
|
+
const path = (0, exports.scheduleStatePath)();
|
|
398
|
+
if ((0, node_fs_1.statSync)(path).size > MAX_FILE_BYTES)
|
|
399
|
+
return { ...state, damaged: true };
|
|
400
|
+
const raw = JSON.parse((0, node_fs_1.readFileSync)(path, 'utf8'));
|
|
401
|
+
if (!isRecord(raw) || !isRecord(raw.schedules))
|
|
402
|
+
return { ...state, damaged: true };
|
|
403
|
+
for (const [id, entry] of Object.entries(raw.schedules)) {
|
|
404
|
+
const runState = isScheduleId(id) ? readRunState(entry) : null;
|
|
405
|
+
if (runState)
|
|
406
|
+
state.schedules[id] = runState;
|
|
407
|
+
else
|
|
408
|
+
state.damaged = true;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
catch (e) {
|
|
412
|
+
if (!isErrno(e, 'ENOENT'))
|
|
413
|
+
state.damaged = true;
|
|
414
|
+
}
|
|
415
|
+
return state;
|
|
416
|
+
};
|
|
417
|
+
exports.readScheduleState = readScheduleState;
|
|
418
|
+
/** Replaces the state file atomically (0600). False when it could not be written; nothing is half-written. */
|
|
419
|
+
const writeScheduleState = (state) => {
|
|
420
|
+
try {
|
|
421
|
+
(0, replace_file_js_1.replaceFile)((0, exports.scheduleStatePath)(), `${JSON.stringify(state)}\n`, undefined, 0o600);
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
exports.writeScheduleState = writeScheduleState;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort masking of secret-looking strings in a patch, before it is sealed
|
|
3
|
+
* for the phone. It keeps the patch's shape — the same lines, the same diff
|
|
4
|
+
* marker on each, the same headers — so line numbers and hunks still line up;
|
|
5
|
+
* only text after the marker changes.
|
|
6
|
+
*
|
|
7
|
+
* This is a list of well-known token formats and secret-named assignments, not
|
|
8
|
+
* a promise: a secret in a form no rule knows goes through, and so does key
|
|
9
|
+
* material whose BEGIN line is outside the patch (the rules see one line at a
|
|
10
|
+
* time, plus whether a key block opened above). The count of masked spots is
|
|
11
|
+
* part of the answer so the reader is told something was hidden, and is never
|
|
12
|
+
* told nothing was.
|
|
13
|
+
*
|
|
14
|
+
* Every pattern is bounded: the patch comes from a repository the agent may
|
|
15
|
+
* have cloned from anywhere, the masking runs on the listener's event loop, and
|
|
16
|
+
* an unbounded `x*` retried from every start of a 200 KB line is quadratic.
|
|
17
|
+
*/
|
|
18
|
+
export interface MaskedPatch {
|
|
19
|
+
text: string;
|
|
20
|
+
masked: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Mask secret-looking strings in prose or any other text that is not a patch: no marker is taken off a line, so
|
|
24
|
+
* a token at the head of a line is read like any other. For text that leaves the machine to a reader other than
|
|
25
|
+
* the user (what `hwip agent read` prints). Same rules, same limits as `maskSecrets`: best effort, not a promise.
|
|
26
|
+
*/
|
|
27
|
+
export declare const maskText: (text: string) => MaskedPatch;
|
|
28
|
+
export declare const maskSecrets: (patch: string) => MaskedPatch;
|
|
29
|
+
//# sourceMappingURL=secret-mask.d.ts.map
|