claude-usage-limits 1.11.7 → 1.13.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +133 -3
- package/bin/cli.js +1 -0
- package/commands/relay.md +45 -0
- package/commands/voice.md +33 -0
- package/hooks/hooks.json +24 -1
- package/package.json +1 -1
- package/skills/usage-limits/SKILL.md +130 -5
- package/skills/usage-limits/scripts/bars.js +56 -0
- package/skills/usage-limits/scripts/brief.js +257 -17
- package/skills/usage-limits/scripts/codex-lowpower.js +135 -0
- package/skills/usage-limits/scripts/codex.js +92 -22
- package/skills/usage-limits/scripts/feed.js +136 -5
- package/skills/usage-limits/scripts/install-codex-hook.js +71 -20
- package/skills/usage-limits/scripts/lowpower.js +10 -2
- package/skills/usage-limits/scripts/panel.js +180 -15
- package/skills/usage-limits/scripts/pulse.js +82 -22
- package/skills/usage-limits/scripts/reading.js +121 -0
- package/skills/usage-limits/scripts/recommend.js +16 -3
- package/skills/usage-limits/scripts/relay.js +859 -0
- package/skills/usage-limits/scripts/tally.js +7 -8
- package/skills/usage-limits/scripts/usage.js +842 -53
- package/skills/usage-limits/scripts/view.js +150 -8
- package/skills/usage-limits/scripts/voice.js +416 -0
- package/skills/usage-limits/scripts/wake.js +312 -0
|
@@ -0,0 +1,859 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Carrying a project across the reset.
|
|
4
|
+
//
|
|
5
|
+
// The old shape of the last hour before a limit was: notice the wall, write a
|
|
6
|
+
// plan, stop. The plan then sat in a dead terminal until somebody came back and
|
|
7
|
+
// read it. Everything the session knew - the files it had open, the half-made
|
|
8
|
+
// decision, the reason the second approach was abandoned - went with the
|
|
9
|
+
// window.
|
|
10
|
+
//
|
|
11
|
+
// The relay is the other half of that handoff. While there is still budget it
|
|
12
|
+
// arms a one-shot wake a few minutes after the window resets, and at that
|
|
13
|
+
// moment it hands the stored continuation back to the same conversation. The
|
|
14
|
+
// arming costs nothing and changes nothing about the work in progress; that is
|
|
15
|
+
// the point. Nobody should slow down to prepare for a wall.
|
|
16
|
+
//
|
|
17
|
+
// What this is NOT: a way to run an agent the user did not ask for. It is off
|
|
18
|
+
// until switched on, it only arms while there is a plan or a todo list to carry
|
|
19
|
+
// (an idle chat is not a project), and its default delivery is a notification,
|
|
20
|
+
// not a launch. Nothing here starts work unattended unless somebody chose that
|
|
21
|
+
// outright.
|
|
22
|
+
//
|
|
23
|
+
// Claude Code has its own version of this - autoContinueAtUsageLimit waits
|
|
24
|
+
// inside an open session and continues when the limit lifts - and where that
|
|
25
|
+
// applies it is better, because the process never dies. It is documented not to
|
|
26
|
+
// offer the wait for -p runs or background sessions, and it cannot help a
|
|
27
|
+
// terminal that has been closed, a machine that slept, or Codex. It also sends
|
|
28
|
+
// a fixed prompt of its own rather than the plan the session actually wrote.
|
|
29
|
+
// That is the gap this fills, and the reason it defers: when the native wait is
|
|
30
|
+
// what fired, the relay stands down.
|
|
31
|
+
|
|
32
|
+
const fs = require('fs');
|
|
33
|
+
const os = require('os');
|
|
34
|
+
const path = require('path');
|
|
35
|
+
const { spawn, spawnSync } = require('child_process');
|
|
36
|
+
|
|
37
|
+
const host = require('./host.js');
|
|
38
|
+
const voice = require('./voice.js');
|
|
39
|
+
|
|
40
|
+
const MINUTE = 60 * 1000;
|
|
41
|
+
|
|
42
|
+
const DEFAULTS = {
|
|
43
|
+
// Off. Scheduling an agent to run while nobody is watching is a decision
|
|
44
|
+
// somebody has to make on purpose.
|
|
45
|
+
enabled: false,
|
|
46
|
+
// Where the safety net goes up. Not a wall and not a warning: at this point
|
|
47
|
+
// the session carries on exactly as before, and a wake is prepared in case
|
|
48
|
+
// the budget runs out before the work does.
|
|
49
|
+
at: 75,
|
|
50
|
+
// Long enough after the reset that the meter has actually turned over. The
|
|
51
|
+
// endpoint's reset time is the moment the window opens, and a request one
|
|
52
|
+
// second later has been refused before.
|
|
53
|
+
graceMinutes: 5,
|
|
54
|
+
// notify - raise a toast and leave the continuation on disk (default)
|
|
55
|
+
// resume - hand the continuation back to the conversation itself
|
|
56
|
+
mode: 'notify',
|
|
57
|
+
// off | resume | always. Only the middle one is free: 'always' changes a
|
|
58
|
+
// Claude Code setting, and says so.
|
|
59
|
+
thinking: 'resume',
|
|
60
|
+
// Not restored by --resume. Whatever was in force in the session is gone at
|
|
61
|
+
// wake time, so it is stated or the resumed run sits waiting for a person.
|
|
62
|
+
permissionMode: null,
|
|
63
|
+
model: null,
|
|
64
|
+
// If the reading says the window has not turned over yet, try again this
|
|
65
|
+
// many times before giving up and leaving a note.
|
|
66
|
+
attempts: 3,
|
|
67
|
+
// What to do when the user is at the keyboard when the wake fires. Their
|
|
68
|
+
// session is the one that matters; a second agent in the same directory is
|
|
69
|
+
// a way to lose work.
|
|
70
|
+
whenBusy: 'notify',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function configDir() {
|
|
74
|
+
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function relayFile() {
|
|
78
|
+
return path.join(configDir(), 'usage-limits-relay.json');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function logFile() {
|
|
82
|
+
return path.join(configDir(), 'usage-limits-relay.log');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function planFile(id) {
|
|
86
|
+
return path.join(configDir(), 'usage-limits-relay-' + String(id).replace(/[^A-Za-z0-9_-]/g, '') + '.md');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function note(line, now) {
|
|
90
|
+
try {
|
|
91
|
+
fs.appendFileSync(logFile(), new Date(Number.isFinite(now) ? now : Date.now()).toISOString() + ' ' + line + '\n');
|
|
92
|
+
} catch (err) {
|
|
93
|
+
// A log that cannot be written must not stop the thing it is logging.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function empty() {
|
|
98
|
+
return { version: 1, config: {}, armed: null, history: [] };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function read() {
|
|
102
|
+
let parsed;
|
|
103
|
+
try {
|
|
104
|
+
parsed = JSON.parse(fs.readFileSync(relayFile(), 'utf8'));
|
|
105
|
+
} catch (err) {
|
|
106
|
+
return empty();
|
|
107
|
+
}
|
|
108
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return empty();
|
|
109
|
+
return {
|
|
110
|
+
version: 1,
|
|
111
|
+
config: parsed.config && typeof parsed.config === 'object' && !Array.isArray(parsed.config) ? parsed.config : {},
|
|
112
|
+
armed: parsed.armed && typeof parsed.armed === 'object' && !Array.isArray(parsed.armed) ? parsed.armed : null,
|
|
113
|
+
history: Array.isArray(parsed.history) ? parsed.history.slice(-10) : [],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function write(state) {
|
|
118
|
+
try {
|
|
119
|
+
fs.mkdirSync(configDir(), { recursive: true });
|
|
120
|
+
fs.writeFileSync(relayFile(), JSON.stringify(state, null, 2) + '\n');
|
|
121
|
+
return true;
|
|
122
|
+
} catch (err) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// File first, then environment, then the defaults. The environment wins over
|
|
128
|
+
// the file so a single run can be steered without editing anything, which is
|
|
129
|
+
// also how every other setting in this plugin behaves.
|
|
130
|
+
function settings(state) {
|
|
131
|
+
const held = state || read();
|
|
132
|
+
const env = process.env;
|
|
133
|
+
const stored = held.config || {};
|
|
134
|
+
const bool = (value, fallback) => {
|
|
135
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
136
|
+
const text = String(value).toLowerCase();
|
|
137
|
+
if (['1', 'on', 'true', 'yes'].includes(text)) return true;
|
|
138
|
+
if (['0', 'off', 'false', 'no'].includes(text)) return false;
|
|
139
|
+
return fallback;
|
|
140
|
+
};
|
|
141
|
+
const number = (value, fallback) => {
|
|
142
|
+
const parsed = Number(value);
|
|
143
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
144
|
+
};
|
|
145
|
+
const pick = (value, allowed, fallback) => {
|
|
146
|
+
const text = value === undefined || value === null ? '' : String(value).toLowerCase();
|
|
147
|
+
return allowed.includes(text) ? text : fallback;
|
|
148
|
+
};
|
|
149
|
+
return {
|
|
150
|
+
enabled: bool(env.USAGE_LIMITS_RELAY, bool(stored.enabled, DEFAULTS.enabled)),
|
|
151
|
+
at: Math.min(99, Math.max(10, number(env.USAGE_LIMITS_RELAY_AT, number(stored.at, DEFAULTS.at)))),
|
|
152
|
+
graceMinutes: Math.min(180, Math.max(1, number(env.USAGE_LIMITS_RELAY_GRACE, number(stored.graceMinutes, DEFAULTS.graceMinutes)))),
|
|
153
|
+
mode: pick(env.USAGE_LIMITS_RELAY_MODE, ['notify', 'resume'], pick(stored.mode, ['notify', 'resume'], DEFAULTS.mode)),
|
|
154
|
+
thinking: pick(env.USAGE_LIMITS_RELAY_THINKING, ['off', 'resume', 'always'], pick(stored.thinking, ['off', 'resume', 'always'], DEFAULTS.thinking)),
|
|
155
|
+
permissionMode: typeof stored.permissionMode === 'string' ? stored.permissionMode : DEFAULTS.permissionMode,
|
|
156
|
+
model: typeof stored.model === 'string' ? stored.model : DEFAULTS.model,
|
|
157
|
+
attempts: Math.min(10, Math.max(1, number(stored.attempts, DEFAULTS.attempts))),
|
|
158
|
+
whenBusy: pick(stored.whenBusy, ['notify', 'resume'], DEFAULTS.whenBusy),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function configure(changes) {
|
|
163
|
+
const state = read();
|
|
164
|
+
state.config = Object.assign({}, state.config, changes || {});
|
|
165
|
+
write(state);
|
|
166
|
+
return settings(state);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/* ------------------------------------------------------ is there work? ---- */
|
|
170
|
+
|
|
171
|
+
// A relay for a conversation with nothing outstanding is a scheduled agent
|
|
172
|
+
// with nothing to do, which is the exact thing this must never become. The
|
|
173
|
+
// evidence has to come from the session itself, so it is read out of the
|
|
174
|
+
// transcript: a todo list with anything unfinished, or a plan that was
|
|
175
|
+
// approved. Both are things the user asked for.
|
|
176
|
+
const TAIL_BYTES = 512 * 1024;
|
|
177
|
+
|
|
178
|
+
function transcriptTail(file, bytes) {
|
|
179
|
+
let handle;
|
|
180
|
+
try {
|
|
181
|
+
handle = fs.openSync(file, 'r');
|
|
182
|
+
const size = fs.fstatSync(handle).size;
|
|
183
|
+
const want = Math.min(size, bytes || TAIL_BYTES);
|
|
184
|
+
const buffer = Buffer.alloc(want);
|
|
185
|
+
fs.readSync(handle, buffer, 0, want, size - want);
|
|
186
|
+
return buffer.toString('utf8');
|
|
187
|
+
} catch (err) {
|
|
188
|
+
return '';
|
|
189
|
+
} finally {
|
|
190
|
+
if (handle !== undefined) {
|
|
191
|
+
try {
|
|
192
|
+
fs.closeSync(handle);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
// Nothing useful to do about a handle that will not close.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function detectWork(transcriptPath, options) {
|
|
201
|
+
const out = { todos: [], pending: 0, plan: null, hasWork: false, source: null };
|
|
202
|
+
if (!transcriptPath) return out;
|
|
203
|
+
const text = transcriptTail(transcriptPath, options && options.bytes);
|
|
204
|
+
if (!text) return out;
|
|
205
|
+
const lines = text.split('\n');
|
|
206
|
+
// Read backwards: the newest todo list is the only one that describes now.
|
|
207
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
208
|
+
const line = lines[i];
|
|
209
|
+
if (!line || line.charAt(0) !== '{') continue;
|
|
210
|
+
if (out.todos.length === 0 && line.indexOf('"TodoWrite"') === -1 && line.indexOf('"ExitPlanMode"') === -1) continue;
|
|
211
|
+
let event;
|
|
212
|
+
try {
|
|
213
|
+
event = JSON.parse(line);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const content = event && event.message && Array.isArray(event.message.content) ? event.message.content : [];
|
|
218
|
+
for (const part of content) {
|
|
219
|
+
if (!part || part.type !== 'tool_use' || !part.input) continue;
|
|
220
|
+
if (part.name === 'TodoWrite' && Array.isArray(part.input.todos) && !out.todos.length) {
|
|
221
|
+
out.todos = part.input.todos
|
|
222
|
+
.filter((todo) => todo && typeof todo.content === 'string')
|
|
223
|
+
.map((todo) => ({ content: todo.content, status: String(todo.status || 'pending') }));
|
|
224
|
+
out.source = 'todos';
|
|
225
|
+
}
|
|
226
|
+
if (part.name === 'ExitPlanMode' && typeof part.input.plan === 'string' && !out.plan) {
|
|
227
|
+
out.plan = part.input.plan.slice(0, 4000);
|
|
228
|
+
if (!out.source) out.source = 'plan';
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (out.todos.length && out.plan) break;
|
|
232
|
+
}
|
|
233
|
+
out.pending = out.todos.filter((todo) => todo.status !== 'completed').length;
|
|
234
|
+
out.hasWork = out.pending > 0 || Boolean(out.plan);
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/* --------------------------------------------------- what gets handed on -- */
|
|
239
|
+
|
|
240
|
+
// The continuation the session wrote itself, stored by the /usage-limits:relay
|
|
241
|
+
// note command. This is the good case: a paragraph from the session that knows
|
|
242
|
+
// what it was doing beats anything reconstructed from a todo list.
|
|
243
|
+
function saveContinuation(id, text) {
|
|
244
|
+
const body = String(text || '').slice(0, 8000).trim();
|
|
245
|
+
if (!body) return null;
|
|
246
|
+
try {
|
|
247
|
+
fs.mkdirSync(configDir(), { recursive: true });
|
|
248
|
+
fs.writeFileSync(planFile(id), body + '\n');
|
|
249
|
+
} catch (err) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
const state = read();
|
|
253
|
+
if (state.armed && state.armed.id === id) {
|
|
254
|
+
state.armed.continuation = true;
|
|
255
|
+
state.armed.continuationAt = Date.now();
|
|
256
|
+
write(state);
|
|
257
|
+
}
|
|
258
|
+
return planFile(id);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function readContinuation(id) {
|
|
262
|
+
try {
|
|
263
|
+
return fs.readFileSync(planFile(id), 'utf8').trim();
|
|
264
|
+
} catch (err) {
|
|
265
|
+
return '';
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// The prompt that restarts the work.
|
|
270
|
+
//
|
|
271
|
+
// Written the way the user writes, because it is delivered as if it came from
|
|
272
|
+
// them and a form letter gets a form letter back. Written in the imperative,
|
|
273
|
+
// because a resumed session that opens by asking what to do has wasted the
|
|
274
|
+
// wake it was given.
|
|
275
|
+
function compose(input) {
|
|
276
|
+
const parts = [];
|
|
277
|
+
const options = input || {};
|
|
278
|
+
if (options.thinking) parts.push('ultrathink');
|
|
279
|
+
parts.push(
|
|
280
|
+
'The usage window has reset and this is the plugin picking the work back up, not a new request. ' +
|
|
281
|
+
'Carry on from where the last turn stopped, at full quality, without re-asking what to do.'
|
|
282
|
+
);
|
|
283
|
+
const continuation = String(options.continuation || '').trim();
|
|
284
|
+
if (continuation) {
|
|
285
|
+
parts.push('This is what the session left for itself:\n\n' + continuation);
|
|
286
|
+
}
|
|
287
|
+
const work = options.work;
|
|
288
|
+
if (work && work.todos && work.todos.length) {
|
|
289
|
+
const outstanding = work.todos.filter((todo) => todo.status !== 'completed');
|
|
290
|
+
if (outstanding.length) {
|
|
291
|
+
parts.push(
|
|
292
|
+
'Still outstanding when the budget ran out:\n' +
|
|
293
|
+
outstanding.map((todo) => '- ' + todo.content + (todo.status === 'in_progress' ? ' (was mid-change)' : '')).join('\n')
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (!continuation && work && work.plan) {
|
|
298
|
+
parts.push('The plan that was approved:\n\n' + work.plan);
|
|
299
|
+
}
|
|
300
|
+
parts.push('Verify anything that was mid-change before building on it: the last turn may have been cut off part-way through an edit.');
|
|
301
|
+
const card = options.voice;
|
|
302
|
+
if (card) parts.push('When you write back to the user, this is how they write:\n' + card);
|
|
303
|
+
return parts.join('\n\n');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* ------------------------------------------------------------ machinery --- */
|
|
307
|
+
|
|
308
|
+
function firstExisting(candidates) {
|
|
309
|
+
for (const candidate of candidates) {
|
|
310
|
+
if (!candidate) continue;
|
|
311
|
+
try {
|
|
312
|
+
fs.accessSync(candidate);
|
|
313
|
+
return candidate;
|
|
314
|
+
} catch (err) {
|
|
315
|
+
// Try the next one.
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Codex installs itself into a content-addressed folder that changes with
|
|
322
|
+
// every update, so the path is discovered rather than remembered.
|
|
323
|
+
function findCodex(env) {
|
|
324
|
+
const environment = env || process.env;
|
|
325
|
+
if (environment.CODEX_CLI_PATH) return firstExisting([environment.CODEX_CLI_PATH]);
|
|
326
|
+
const local = environment.LOCALAPPDATA;
|
|
327
|
+
if (local) {
|
|
328
|
+
const bin = path.join(local, 'OpenAI', 'Codex', 'bin');
|
|
329
|
+
try {
|
|
330
|
+
const found = fs
|
|
331
|
+
.readdirSync(bin)
|
|
332
|
+
.map((entry) => path.join(bin, entry, process.platform === 'win32' ? 'codex.exe' : 'codex'))
|
|
333
|
+
.filter((file) => {
|
|
334
|
+
try {
|
|
335
|
+
fs.accessSync(file);
|
|
336
|
+
return true;
|
|
337
|
+
} catch (err) {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
})
|
|
341
|
+
.sort();
|
|
342
|
+
if (found.length) return found[found.length - 1];
|
|
343
|
+
} catch (err) {
|
|
344
|
+
// Not installed there.
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return firstExisting([
|
|
348
|
+
path.join(os.homedir(), '.codex', 'bin', process.platform === 'win32' ? 'codex.exe' : 'codex'),
|
|
349
|
+
]);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function findClaude(env) {
|
|
353
|
+
const environment = env || process.env;
|
|
354
|
+
if (environment.USAGE_LIMITS_CLAUDE_CLI) return firstExisting([environment.USAGE_LIMITS_CLAUDE_CLI]);
|
|
355
|
+
if (process.platform === 'win32') {
|
|
356
|
+
const found = firstExisting([
|
|
357
|
+
environment.APPDATA ? path.join(environment.APPDATA, 'npm', 'claude.cmd') : null,
|
|
358
|
+
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'claude.cmd'),
|
|
359
|
+
path.join(os.homedir(), '.local', 'bin', 'claude.exe'),
|
|
360
|
+
]);
|
|
361
|
+
if (found) return found;
|
|
362
|
+
}
|
|
363
|
+
return firstExisting([
|
|
364
|
+
path.join(os.homedir(), '.local', 'bin', 'claude'),
|
|
365
|
+
'/usr/local/bin/claude',
|
|
366
|
+
'/opt/homebrew/bin/claude',
|
|
367
|
+
path.join(os.homedir(), '.npm-global', 'bin', 'claude'),
|
|
368
|
+
]);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Computer Use, if the user installed it. The relay uses it for one thing:
|
|
372
|
+
// asking whether somebody is at the machine before starting an agent in the
|
|
373
|
+
// directory they might be working in. It is never used to type into a
|
|
374
|
+
// terminal - that plugin refuses to send input to a shell or an editor on
|
|
375
|
+
// purpose, and routing around a safety rule because it is inconvenient is how
|
|
376
|
+
// safety rules stop meaning anything.
|
|
377
|
+
function findComputerUse(env) {
|
|
378
|
+
const environment = env || process.env;
|
|
379
|
+
if (environment.USAGE_LIMITS_COMPUTER_USE) return firstExisting([environment.USAGE_LIMITS_COMPUTER_USE]);
|
|
380
|
+
const roots = [
|
|
381
|
+
path.join(configDir(), 'plugins', 'cache', 'computer-use', 'computer-use'),
|
|
382
|
+
path.join(os.homedir(), '.claude', 'plugins', 'cache', 'computer-use', 'computer-use'),
|
|
383
|
+
];
|
|
384
|
+
for (const root of roots) {
|
|
385
|
+
let versions;
|
|
386
|
+
try {
|
|
387
|
+
versions = fs.readdirSync(root).sort();
|
|
388
|
+
} catch (err) {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
for (const version of versions.reverse()) {
|
|
392
|
+
const cli = path.join(root, version, 'tools', 'cli.mjs');
|
|
393
|
+
try {
|
|
394
|
+
fs.accessSync(cli);
|
|
395
|
+
return cli;
|
|
396
|
+
} catch (err) {
|
|
397
|
+
// Keep looking.
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function capabilities(env) {
|
|
405
|
+
return {
|
|
406
|
+
claude: findClaude(env),
|
|
407
|
+
codex: findCodex(env),
|
|
408
|
+
computerUse: findComputerUse(env),
|
|
409
|
+
node: process.execPath,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// The wake time: the moment the window opens, plus enough slack that the meter
|
|
414
|
+
// has really turned over.
|
|
415
|
+
function wakeAt(resetsAt, graceMinutes, now) {
|
|
416
|
+
// Number(null) is 0 and Number('') is 0, and a wake booked for the epoch
|
|
417
|
+
// plus five minutes is a wake booked for right now. A missing reset time has
|
|
418
|
+
// to read as missing.
|
|
419
|
+
if (resetsAt === null || resetsAt === undefined || resetsAt === '') return null;
|
|
420
|
+
const reset = Number(resetsAt);
|
|
421
|
+
if (!Number.isFinite(reset)) return null;
|
|
422
|
+
const at = reset + Math.max(1, graceMinutes) * MINUTE;
|
|
423
|
+
const floor = (Number.isFinite(now) ? now : Date.now()) + MINUTE;
|
|
424
|
+
return Math.max(at, floor);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function taskName(id) {
|
|
428
|
+
return 'UsageLimitsRelay-' + String(id).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 40);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function psQuote(value) {
|
|
432
|
+
return "'" + String(value).replace(/'/g, "''") + "'";
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function two(value) {
|
|
436
|
+
return String(value).padStart(2, '0');
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Registered through the ScheduledTasks module rather than schtasks.exe, for
|
|
440
|
+
// one reason that matters: a schtasks /V1 one-shot cannot carry
|
|
441
|
+
// StartWhenAvailable, so a machine asleep at the trigger moment misses the run
|
|
442
|
+
// silently and forever. -StartWhenAvailable makes it fire on wake instead,
|
|
443
|
+
// which is the whole difference between a relay and a coin toss. schtasks is
|
|
444
|
+
// still the fallback for a box where the module is missing.
|
|
445
|
+
function scheduleWindows(when, argv, name, cwd) {
|
|
446
|
+
const date = new Date(when);
|
|
447
|
+
const stamp =
|
|
448
|
+
date.getFullYear() + '-' + two(date.getMonth() + 1) + '-' + two(date.getDate()) + ' ' +
|
|
449
|
+
two(date.getHours()) + ':' + two(date.getMinutes()) + ':' + two(date.getSeconds());
|
|
450
|
+
const argument = argv.map((value) => (/[\s"]/.test(value) ? '"' + value.replace(/"/g, '\\"') + '"' : value)).join(' ');
|
|
451
|
+
const script = [
|
|
452
|
+
'$ErrorActionPreference = "Stop"',
|
|
453
|
+
'$action = New-ScheduledTaskAction -Execute ' + psQuote(process.execPath) +
|
|
454
|
+
' -Argument ' + psQuote(argument) + ' -WorkingDirectory ' + psQuote(cwd || os.homedir()),
|
|
455
|
+
'$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date ' + psQuote(stamp) + ')',
|
|
456
|
+
// A one-shot task does not remove itself. Without an expiry, every relay
|
|
457
|
+
// leaves a dead entry in Task Scheduler forever; the wake also unregisters
|
|
458
|
+
// itself when it finishes, and this is what catches the wakes that never
|
|
459
|
+
// get to run at all.
|
|
460
|
+
'$trigger.EndBoundary = (Get-Date ' + psQuote(stamp) + ').AddHours(12).ToString("s")',
|
|
461
|
+
'$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -WakeToRun -AllowStartIfOnBatteries ' +
|
|
462
|
+
'-DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Hours 4) ' +
|
|
463
|
+
'-DeleteExpiredTaskAfter (New-TimeSpan -Minutes 10)',
|
|
464
|
+
'Register-ScheduledTask -TaskName ' + psQuote(name) + ' -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null',
|
|
465
|
+
'Write-Output "registered"',
|
|
466
|
+
].join('\n');
|
|
467
|
+
const file = path.join(os.tmpdir(), name + '.ps1');
|
|
468
|
+
try {
|
|
469
|
+
fs.writeFileSync(file, script, 'utf8');
|
|
470
|
+
const run = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', file], {
|
|
471
|
+
encoding: 'utf8',
|
|
472
|
+
windowsHide: true,
|
|
473
|
+
timeout: 8000,
|
|
474
|
+
});
|
|
475
|
+
if (run.status === 0 && /registered/.test(run.stdout || '')) return { ok: true, how: 'ScheduledTasks' };
|
|
476
|
+
// schtasks cannot express StartWhenAvailable, so this path is a worse
|
|
477
|
+
// guarantee and says so rather than pretending the two are the same.
|
|
478
|
+
const fallback = spawnSync(
|
|
479
|
+
'schtasks.exe',
|
|
480
|
+
['/Create', '/TN', name, '/TR', '"' + process.execPath + '" ' + argument, '/SC', 'ONCE',
|
|
481
|
+
'/ST', two(date.getHours()) + ':' + two(date.getMinutes()),
|
|
482
|
+
'/SD', two(date.getMonth() + 1) + '/' + two(date.getDate()) + '/' + date.getFullYear(),
|
|
483
|
+
'/IT', '/Z', '/F'],
|
|
484
|
+
{ encoding: 'utf8', windowsHide: true, timeout: 30000 }
|
|
485
|
+
);
|
|
486
|
+
if (fallback.status === 0) return { ok: true, how: 'schtasks', warning: 'a sleeping machine will miss this wake' };
|
|
487
|
+
return { ok: false, error: (run.stderr || fallback.stderr || 'could not register a scheduled task').trim().split('\n')[0] };
|
|
488
|
+
} catch (err) {
|
|
489
|
+
return { ok: false, error: err.message };
|
|
490
|
+
} finally {
|
|
491
|
+
try {
|
|
492
|
+
fs.unlinkSync(file);
|
|
493
|
+
} catch (err) {
|
|
494
|
+
// Leaving a script behind in the temp directory is not worth reporting.
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function schedulePosix(when, argv, name, cwd) {
|
|
500
|
+
const seconds = Math.max(60, Math.round((when - Date.now()) / 1000));
|
|
501
|
+
// `at` is the right tool and is absent on most desktops now. A detached
|
|
502
|
+
// sleeper is second best: it survives the terminal closing, but not a
|
|
503
|
+
// reboot, and the status line says so.
|
|
504
|
+
const command = [process.execPath].concat(argv).map((value) => "'" + String(value).replace(/'/g, "'\\''") + "'").join(' ');
|
|
505
|
+
const at = spawnSync('sh', ['-c', 'command -v at >/dev/null 2>&1 && echo yes || echo no'], { encoding: 'utf8' });
|
|
506
|
+
if ((at.stdout || '').trim() === 'yes') {
|
|
507
|
+
const minutes = Math.max(1, Math.round(seconds / 60));
|
|
508
|
+
const run = spawnSync('sh', ['-c', 'echo ' + JSON.stringify(command) + ' | at now + ' + minutes + ' minutes'], {
|
|
509
|
+
encoding: 'utf8',
|
|
510
|
+
timeout: 20000,
|
|
511
|
+
});
|
|
512
|
+
if (run.status === 0) return { ok: true, how: 'at' };
|
|
513
|
+
}
|
|
514
|
+
try {
|
|
515
|
+
const child = spawn('sh', ['-c', 'sleep ' + seconds + ' && ' + command], {
|
|
516
|
+
detached: true,
|
|
517
|
+
stdio: 'ignore',
|
|
518
|
+
cwd: cwd || os.homedir(),
|
|
519
|
+
});
|
|
520
|
+
child.unref();
|
|
521
|
+
return { ok: true, how: 'sleeper', warning: 'a reboot before the reset will cancel this wake', pid: child.pid };
|
|
522
|
+
} catch (err) {
|
|
523
|
+
return { ok: false, error: err.message };
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function schedule(when, argv, name, cwd) {
|
|
528
|
+
if (process.platform === 'win32') return scheduleWindows(when, argv, name, cwd);
|
|
529
|
+
return schedulePosix(when, argv, name, cwd);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function cancelSchedule(name) {
|
|
533
|
+
if (process.platform === 'win32') {
|
|
534
|
+
const run = spawnSync(
|
|
535
|
+
'powershell.exe',
|
|
536
|
+
['-NoProfile', '-NonInteractive', '-Command', 'Unregister-ScheduledTask -TaskName ' + psQuote(name) + ' -Confirm:$false'],
|
|
537
|
+
{ encoding: 'utf8', windowsHide: true, timeout: 20000 }
|
|
538
|
+
);
|
|
539
|
+
if (run.status === 0) return true;
|
|
540
|
+
return spawnSync('schtasks.exe', ['/Delete', '/TN', name, '/F'], { encoding: 'utf8', windowsHide: true, timeout: 20000 }).status === 0;
|
|
541
|
+
}
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/* ------------------------------------------------------------- arming ----- */
|
|
546
|
+
|
|
547
|
+
// Everything that has to be true before a wake is scheduled, in the order that
|
|
548
|
+
// makes the answer most useful to read.
|
|
549
|
+
function armable(input) {
|
|
550
|
+
const options = input || {};
|
|
551
|
+
const config = options.config || settings();
|
|
552
|
+
if (!config.enabled) return { ok: false, why: 'the relay is off' };
|
|
553
|
+
const binding = options.binding;
|
|
554
|
+
if (!binding || binding.percentUsed === null || binding.percentUsed === undefined) return { ok: false, why: 'no usable window reading' };
|
|
555
|
+
if (binding.stale) return { ok: false, why: 'the reading is stale' };
|
|
556
|
+
if (binding.percentUsed < config.at) return { ok: false, why: 'below ' + config.at + ' per cent' };
|
|
557
|
+
if (!Number.isFinite(binding.resetsAt)) return { ok: false, why: 'the window has no known reset time' };
|
|
558
|
+
if (!options.sessionId) return { ok: false, why: 'no session id' };
|
|
559
|
+
if (!options.work || !options.work.hasWork) return { ok: false, why: 'no plan or unfinished todo list to carry' };
|
|
560
|
+
return { ok: true };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function arm(input) {
|
|
564
|
+
const options = input || {};
|
|
565
|
+
const now = Number.isFinite(options.now) ? options.now : Date.now();
|
|
566
|
+
const config = options.config || settings();
|
|
567
|
+
const when = wakeAt(options.resetsAt, config.graceMinutes, now);
|
|
568
|
+
if (!when) return { ok: false, error: 'no reset time to wake after' };
|
|
569
|
+
|
|
570
|
+
const state = read();
|
|
571
|
+
const id = options.sessionId;
|
|
572
|
+
const name = taskName(id);
|
|
573
|
+
// Re-arming the same session for the same reset would register the task
|
|
574
|
+
// twice; -Force replaces it, and the record is rewritten either way.
|
|
575
|
+
const argv = [path.join(__dirname, 'wake.js'), '--id', id];
|
|
576
|
+
if (options.hostName) argv.push('--host', options.hostName);
|
|
577
|
+
// The wake runs hours later in a process that inherits nothing. If this
|
|
578
|
+
// session is pointed at a config directory of its own, the wake has to be
|
|
579
|
+
// pointed at the same one or it reads somebody else's relay - which in
|
|
580
|
+
// testing meant it read the real one and found nothing armed.
|
|
581
|
+
if (process.env.CLAUDE_CONFIG_DIR) argv.push('--config-dir', process.env.CLAUDE_CONFIG_DIR);
|
|
582
|
+
const scheduled = options.schedule === false ? { ok: true, how: 'none' } : schedule(when, argv, name, options.cwd);
|
|
583
|
+
if (!scheduled.ok) {
|
|
584
|
+
note('arm failed for ' + id + ': ' + scheduled.error, now);
|
|
585
|
+
return { ok: false, error: scheduled.error };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const record = {
|
|
589
|
+
id,
|
|
590
|
+
task: scheduled.how === 'none' ? null : name,
|
|
591
|
+
host: options.hostName || host.CLAUDE,
|
|
592
|
+
cwd: options.cwd || process.cwd(),
|
|
593
|
+
project: options.project || null,
|
|
594
|
+
armedAt: now,
|
|
595
|
+
wakeAt: when,
|
|
596
|
+
resetsAt: options.resetsAt,
|
|
597
|
+
percentAtArming: options.binding ? options.binding.percentUsed : null,
|
|
598
|
+
window: options.binding ? options.binding.label || options.binding.key : null,
|
|
599
|
+
// The label is for reading; the key is what the meter is indexed by.
|
|
600
|
+
windowKey: options.binding && options.binding.key ? options.binding.key : null,
|
|
601
|
+
mode: config.mode,
|
|
602
|
+
how: scheduled.how,
|
|
603
|
+
warning: scheduled.warning || null,
|
|
604
|
+
attempt: 0,
|
|
605
|
+
continuation: false,
|
|
606
|
+
// The outstanding items travel with the record, not just their count. They
|
|
607
|
+
// are the work; a wake that knows only "two things were pending" has
|
|
608
|
+
// nothing to hand back.
|
|
609
|
+
work: options.work
|
|
610
|
+
? {
|
|
611
|
+
pending: options.work.pending,
|
|
612
|
+
source: options.work.source,
|
|
613
|
+
todos: (options.work.todos || []).filter((todo) => todo.status !== 'completed').slice(0, 20),
|
|
614
|
+
plan: options.work.plan ? String(options.work.plan).slice(0, 2000) : null,
|
|
615
|
+
}
|
|
616
|
+
: null,
|
|
617
|
+
};
|
|
618
|
+
if (state.armed && state.armed.id !== id && state.armed.task) cancelSchedule(state.armed.task);
|
|
619
|
+
state.armed = record;
|
|
620
|
+
write(state);
|
|
621
|
+
note('armed ' + id + ' for ' + new Date(when).toISOString() + ' via ' + scheduled.how, now);
|
|
622
|
+
return { ok: true, record };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function disarm(reason, now) {
|
|
626
|
+
const state = read();
|
|
627
|
+
if (!state.armed) return { ok: true, changed: false };
|
|
628
|
+
const record = state.armed;
|
|
629
|
+
if (record.task) cancelSchedule(record.task);
|
|
630
|
+
state.history.push(Object.assign({}, record, { endedAt: Number.isFinite(now) ? now : Date.now(), outcome: reason || 'cancelled' }));
|
|
631
|
+
state.history = state.history.slice(-10);
|
|
632
|
+
state.armed = null;
|
|
633
|
+
write(state);
|
|
634
|
+
try {
|
|
635
|
+
fs.unlinkSync(planFile(record.id));
|
|
636
|
+
} catch (err) {
|
|
637
|
+
// The continuation file may never have been written.
|
|
638
|
+
}
|
|
639
|
+
note('disarmed ' + record.id + ': ' + (reason || 'cancelled'), now);
|
|
640
|
+
return { ok: true, changed: true, record };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function formatWait(ms) {
|
|
644
|
+
if (!Number.isFinite(ms) || ms <= 0) return 'now';
|
|
645
|
+
const minutes = Math.round(ms / MINUTE);
|
|
646
|
+
if (minutes < 60) return minutes + ' min';
|
|
647
|
+
const hours = Math.floor(minutes / 60);
|
|
648
|
+
return hours + 'h ' + (minutes % 60) + 'm';
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function status(now) {
|
|
652
|
+
const at = Number.isFinite(now) ? now : Date.now();
|
|
653
|
+
const state = read();
|
|
654
|
+
const config = settings(state);
|
|
655
|
+
const able = capabilities();
|
|
656
|
+
const lines = [];
|
|
657
|
+
lines.push(
|
|
658
|
+
'Relay is ' + (config.enabled ? 'ON' : 'OFF') + ', arming at ' + config.at + ' per cent, waking ' +
|
|
659
|
+
config.graceMinutes + ' min after the reset, delivery ' + config.mode + '.'
|
|
660
|
+
);
|
|
661
|
+
if (state.armed) {
|
|
662
|
+
lines.push(
|
|
663
|
+
'Armed: session ' + state.armed.id.slice(0, 8) + ' in ' + state.armed.cwd + ', wake in ' +
|
|
664
|
+
formatWait(state.armed.wakeAt - at) + ' (' + new Date(state.armed.wakeAt).toLocaleString() + '), via ' + state.armed.how + '.'
|
|
665
|
+
);
|
|
666
|
+
if (state.armed.warning) lines.push(' Caveat: ' + state.armed.warning + '.');
|
|
667
|
+
lines.push(' Continuation written: ' + (state.armed.continuation ? 'yes' : 'not yet'));
|
|
668
|
+
} else {
|
|
669
|
+
lines.push('Nothing armed.');
|
|
670
|
+
}
|
|
671
|
+
lines.push(
|
|
672
|
+
'Available here: ' + [
|
|
673
|
+
able.claude ? 'claude CLI' : null,
|
|
674
|
+
able.codex ? 'codex CLI' : null,
|
|
675
|
+
able.computerUse ? 'Computer Use' : null,
|
|
676
|
+
].filter(Boolean).join(', ') || 'no CLI found'
|
|
677
|
+
);
|
|
678
|
+
const last = state.history[state.history.length - 1];
|
|
679
|
+
if (last) lines.push('Last relay: ' + last.outcome + ' at ' + new Date(last.endedAt).toLocaleString() + '.');
|
|
680
|
+
return lines.join('\n');
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/* ------------------------------------------------------ always thinking --- */
|
|
684
|
+
|
|
685
|
+
// "Ultrathink all the time" is two different things and only one of them is a
|
|
686
|
+
// prompt. Claude Code recognises the word ultrathink in the text it is given,
|
|
687
|
+
// which is what the relay puts there; for every other turn the switch lives in
|
|
688
|
+
// settings as alwaysThinkingEnabled. Writing somebody's settings file is not
|
|
689
|
+
// something to do quietly, so it is backed up first and reported afterwards.
|
|
690
|
+
function settingsFile() {
|
|
691
|
+
return path.join(configDir(), 'settings.json');
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function applyAlwaysThinking(on) {
|
|
695
|
+
const file = settingsFile();
|
|
696
|
+
let parsed = {};
|
|
697
|
+
let existed = false;
|
|
698
|
+
try {
|
|
699
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
700
|
+
existed = true;
|
|
701
|
+
parsed = JSON.parse(raw);
|
|
702
|
+
fs.writeFileSync(file + '.bak-usage-limits', raw);
|
|
703
|
+
} catch (err) {
|
|
704
|
+
if (existed) return { ok: false, error: 'settings.json is not readable JSON; left untouched' };
|
|
705
|
+
}
|
|
706
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return { ok: false, error: 'settings.json is not an object; left untouched' };
|
|
707
|
+
if (on) parsed.alwaysThinkingEnabled = true;
|
|
708
|
+
else delete parsed.alwaysThinkingEnabled;
|
|
709
|
+
try {
|
|
710
|
+
fs.mkdirSync(configDir(), { recursive: true });
|
|
711
|
+
fs.writeFileSync(file, JSON.stringify(parsed, null, 2) + '\n');
|
|
712
|
+
} catch (err) {
|
|
713
|
+
return { ok: false, error: err.message };
|
|
714
|
+
}
|
|
715
|
+
return { ok: true, file, backup: existed ? file + '.bak-usage-limits' : null };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/* ----------------------------------------------------------------- cli ---- */
|
|
719
|
+
|
|
720
|
+
function main(argv) {
|
|
721
|
+
const args = argv || [];
|
|
722
|
+
const command = (args[0] || 'status').toLowerCase();
|
|
723
|
+
const rest = args.slice(1);
|
|
724
|
+
const value = rest.filter((item) => !item.startsWith('--'))[0];
|
|
725
|
+
|
|
726
|
+
if (command === 'status') return status(Date.now());
|
|
727
|
+
if (command === 'on' || command === 'off') {
|
|
728
|
+
const config = configure({ enabled: command === 'on' });
|
|
729
|
+
return (
|
|
730
|
+
'Relay ' + (config.enabled ? 'ON' : 'OFF') + '.\n' +
|
|
731
|
+
(config.enabled
|
|
732
|
+
? 'It arms once the binding window passes ' + config.at + ' per cent AND the session has an unfinished ' +
|
|
733
|
+
'todo list or an approved plan. Delivery is "' + config.mode + '"' +
|
|
734
|
+
(config.mode === 'notify' ? ' - it will notify you, not start anything by itself.' : ' - it will hand the plan back to the conversation itself.')
|
|
735
|
+
: 'Nothing will be scheduled.')
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
if (command === 'at') {
|
|
739
|
+
if (!value) return 'Give a percentage, for example: relay at 75';
|
|
740
|
+
return 'Arming at ' + configure({ at: Number(value) }).at + ' per cent.';
|
|
741
|
+
}
|
|
742
|
+
if (command === 'grace') {
|
|
743
|
+
if (!value) return 'Give minutes, for example: relay grace 5';
|
|
744
|
+
return 'Waking ' + configure({ graceMinutes: Number(value) }).graceMinutes + ' minutes after the reset.';
|
|
745
|
+
}
|
|
746
|
+
if (command === 'mode') {
|
|
747
|
+
if (!['notify', 'resume'].includes(String(value))) return 'Mode is notify or resume.';
|
|
748
|
+
const config = configure({ mode: value });
|
|
749
|
+
return (
|
|
750
|
+
'Delivery is now "' + config.mode + '".' +
|
|
751
|
+
(config.mode === 'resume'
|
|
752
|
+
? '\nAt the wake it will run the CLI itself in the project directory. Set a permission mode ' +
|
|
753
|
+
'(relay permission acceptEdits) or the resumed run will sit waiting for an approval nobody is there to give.'
|
|
754
|
+
: '')
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
if (command === 'permission') {
|
|
758
|
+
if (!value) return 'Give one of: manual, acceptEdits, auto, dontAsk, plan, bypassPermissions.';
|
|
759
|
+
return 'Resumed runs will use --permission-mode ' + configure({ permissionMode: value }).permissionMode + '.';
|
|
760
|
+
}
|
|
761
|
+
if (command === 'model') {
|
|
762
|
+
return 'Resumed runs will use --model ' + (configure({ model: value || null }).model || '(the default)') + '.';
|
|
763
|
+
}
|
|
764
|
+
if (command === 'thinking') {
|
|
765
|
+
if (!['off', 'resume', 'always'].includes(String(value))) return 'Thinking is off, resume or always.';
|
|
766
|
+
const config = configure({ thinking: value });
|
|
767
|
+
if (value !== 'always') {
|
|
768
|
+
return (
|
|
769
|
+
'Thinking: ' + config.thinking + '.' +
|
|
770
|
+
(config.thinking === 'resume' ? ' The word ultrathink goes into the prompt the relay delivers.' : '')
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
const applied = applyAlwaysThinking(true);
|
|
774
|
+
return applied.ok
|
|
775
|
+
? 'Thinking: always. Set alwaysThinkingEnabled in ' + applied.file +
|
|
776
|
+
(applied.backup ? ' (backup at ' + applied.backup + ')' : '') +
|
|
777
|
+
'.\nIt applies to new sessions. Note that adaptive-reasoning models decide their own budget, so this is a request, not a guarantee.'
|
|
778
|
+
: 'Could not set it: ' + applied.error;
|
|
779
|
+
}
|
|
780
|
+
if (command === 'note') {
|
|
781
|
+
const state = read();
|
|
782
|
+
if (!state.armed) return 'Nothing is armed, so there is nowhere to put a continuation yet.';
|
|
783
|
+
const fromFile = rest.indexOf('--file') !== -1 ? rest[rest.indexOf('--file') + 1] : null;
|
|
784
|
+
let text = fromFile ? '' : rest.filter((item) => item !== '--file').join(' ');
|
|
785
|
+
if (fromFile) {
|
|
786
|
+
try {
|
|
787
|
+
text = fs.readFileSync(fromFile, 'utf8');
|
|
788
|
+
} catch (err) {
|
|
789
|
+
return 'Could not read ' + fromFile;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
const written = saveContinuation(state.armed.id, text);
|
|
793
|
+
return written ? 'Continuation saved for the relay (' + written + ').' : 'Nothing to save.';
|
|
794
|
+
}
|
|
795
|
+
if (command === 'cancel') {
|
|
796
|
+
const result = disarm('cancelled by hand', Date.now());
|
|
797
|
+
return result.changed ? 'Relay cancelled and the scheduled wake removed.' : 'Nothing was armed.';
|
|
798
|
+
}
|
|
799
|
+
if (command === 'log') {
|
|
800
|
+
try {
|
|
801
|
+
return fs.readFileSync(logFile(), 'utf8').split('\n').slice(-20).join('\n');
|
|
802
|
+
} catch (err) {
|
|
803
|
+
return 'No relay log yet.';
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return [
|
|
807
|
+
'usage: relay.js [status|on|off|at N|grace N|mode notify|resume|permission MODE|model NAME|thinking off|resume|always|note TEXT|cancel|log]',
|
|
808
|
+
'',
|
|
809
|
+
status(Date.now()),
|
|
810
|
+
].join('\n');
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
if (require.main === module) {
|
|
814
|
+
try {
|
|
815
|
+
process.stdout.write(main(process.argv.slice(2)) + '\n');
|
|
816
|
+
} catch (err) {
|
|
817
|
+
process.stdout.write('relay: ' + err.message + '\n');
|
|
818
|
+
}
|
|
819
|
+
process.exit(0);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
module.exports = {
|
|
823
|
+
DEFAULTS,
|
|
824
|
+
MINUTE,
|
|
825
|
+
main,
|
|
826
|
+
settingsFile,
|
|
827
|
+
applyAlwaysThinking,
|
|
828
|
+
configDir,
|
|
829
|
+
relayFile,
|
|
830
|
+
logFile,
|
|
831
|
+
planFile,
|
|
832
|
+
note,
|
|
833
|
+
empty,
|
|
834
|
+
read,
|
|
835
|
+
write,
|
|
836
|
+
settings,
|
|
837
|
+
configure,
|
|
838
|
+
detectWork,
|
|
839
|
+
transcriptTail,
|
|
840
|
+
saveContinuation,
|
|
841
|
+
readContinuation,
|
|
842
|
+
compose,
|
|
843
|
+
capabilities,
|
|
844
|
+
findClaude,
|
|
845
|
+
findCodex,
|
|
846
|
+
findComputerUse,
|
|
847
|
+
wakeAt,
|
|
848
|
+
taskName,
|
|
849
|
+
psQuote,
|
|
850
|
+
schedule,
|
|
851
|
+
scheduleWindows,
|
|
852
|
+
schedulePosix,
|
|
853
|
+
cancelSchedule,
|
|
854
|
+
armable,
|
|
855
|
+
arm,
|
|
856
|
+
disarm,
|
|
857
|
+
status,
|
|
858
|
+
formatWait,
|
|
859
|
+
};
|