unsnooze 1.19.0 → 1.19.2
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/CHANGELOG.md +218 -0
- package/README.md +41 -12
- package/bin/unsnooze.js +90 -64
- package/package.json +1 -1
- package/src/agents/codex.js +117 -12
- package/src/agents/opencode.js +2 -1
- package/src/cli.js +4 -0
- package/src/config.js +21 -0
- package/src/doctor.js +76 -0
- package/src/hook.js +6 -2
- package/src/install.js +104 -35
- package/src/launcher.js +52 -10
- package/src/monitor.js +11 -0
- package/src/multiplexers/headless.js +159 -11
- package/src/resumer.js +94 -11
- package/src/spawn.js +262 -9
- package/src/time-parser.js +6 -1
- package/src/usage.js +50 -21
- package/src/watcher.js +45 -12
- package/src/watchers/codex.js +202 -28
- package/src/which.js +63 -0
- package/src/wizard.js +8 -4
- package/src/workspace.js +4 -1
package/src/watchers/codex.js
CHANGED
|
@@ -1,20 +1,39 @@
|
|
|
1
1
|
// Codex rollout watcher: parses lines appended to session rollout files
|
|
2
2
|
// (~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl) into limit-stop candidates.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
4
|
+
// Two signals are persisted, and either one is a stop:
|
|
5
|
+
//
|
|
6
|
+
// 1. A token_count event per turn carrying a rate_limits snapshot —
|
|
7
|
+
// used_percent per window plus an exact resets_at epoch:
|
|
8
|
+
// {"type":"event_msg","payload":{"type":"token_count","rate_limits":{
|
|
9
|
+
// "primary":{"used_percent":100,"window_minutes":300,"resets_at":1778672230},
|
|
10
|
+
// "secondary":{"used_percent":1,"window_minutes":10080,"resets_at":...},
|
|
11
|
+
// "rate_limit_reached_type":null}}}
|
|
12
|
+
// That epoch is more precise than any scraped banner, so it governs
|
|
13
|
+
// whenever it is present.
|
|
14
|
+
//
|
|
15
|
+
// 2. Since codex-cli 0.145 the failed turn's task_complete carries the
|
|
16
|
+
// error itself, with the same banner text the TUI renders:
|
|
17
|
+
// {"type":"event_msg","payload":{"type":"task_complete","error":{
|
|
18
|
+
// "message":"You've hit your usage limit. … try again at Jul 30th, 2026 10:33 AM.",
|
|
19
|
+
// "codex_error_info":"usage_limit_exceeded"}}}
|
|
20
|
+
// This is the ONLY signal when Codex runs behind an OpenAI-compatible
|
|
21
|
+
// proxy (model_providers.<x>.base_url): the proxy answers with its own
|
|
22
|
+
// response, the X-Codex-* rate-limit headers never reach Codex, and every
|
|
23
|
+
// snapshot arrives as {primary:null, secondary:null}. The message goes
|
|
24
|
+
// through the same time-parser as a scraped pane, so the reset lands on
|
|
25
|
+
// the banner's own clock time (or the probe fallback for "Try again later.").
|
|
26
|
+
//
|
|
27
|
+
// Rollouts are shared by every Codex surface (CLI, IDE extension, desktop app).
|
|
28
|
+
// A bare 429 ("exceeded retry limit, last status: 429 Too Many Requests",
|
|
29
|
+
// codex_error_info.response_too_many_failed_attempts) is NOT a stop here —
|
|
30
|
+
// it carries no reset time, and the pane path files it under transient
|
|
31
|
+
// overload for the same reason.
|
|
14
32
|
|
|
15
33
|
import { openSync, readSync, closeSync } from 'node:fs';
|
|
16
34
|
import { basename } from 'node:path';
|
|
17
|
-
import { ROLLOUT_RE } from '../agents/codex.js';
|
|
35
|
+
import { ROLLOUT_RE, patterns as codexPatterns } from '../agents/codex.js';
|
|
36
|
+
import { detectLimit } from '../patterns.js';
|
|
18
37
|
// Usage extractor lives in usage.js (shared cold path + daemon); re-exported
|
|
19
38
|
// here so the plan's watcher surface is the documented import site.
|
|
20
39
|
export { extractCodexUsage } from '../usage.js';
|
|
@@ -33,11 +52,84 @@ function rolloutSnapshot(line) {
|
|
|
33
52
|
return entry;
|
|
34
53
|
}
|
|
35
54
|
|
|
55
|
+
// The failed turn's task_complete error, when it is a usage limit. The turn
|
|
56
|
+
// also ends in task_complete for stream errors, retry exhaustion and
|
|
57
|
+
// cancellations, so only the banner itself qualifies — not the structured
|
|
58
|
+
// marker alone: codex-rs (protocol/src/error.rs, to_codex_protocol_error)
|
|
59
|
+
// sends codex_error_info "usage_limit_exceeded" for "Quota exceeded. Check
|
|
60
|
+
// your plan and billing details." and "To use Codex with your ChatGPT plan,
|
|
61
|
+
// upgrade to Plus…" too, which no amount of waiting clears. Every real usage
|
|
62
|
+
// limit it words ("You’ve hit your usage limit…", "Your workspace is out of
|
|
63
|
+
// credits…", "You hit your spend cap…") carries a banner anchor.
|
|
64
|
+
// codex-rs words the workspace walls (UsageLimitReachedError's Display):
|
|
65
|
+
// "Your workspace is out of credits. …" and "You hit your spend cap set …".
|
|
66
|
+
const WORKSPACE_WALL_BANNER = /Your workspace is out of credits|hit your spend cap/i;
|
|
67
|
+
|
|
68
|
+
function rolloutLimitError(line) {
|
|
69
|
+
// Every line that is not a snapshot lands here, and rollout lines can be
|
|
70
|
+
// large (tool output): skip the second JSON.parse unless it can be a match.
|
|
71
|
+
if (!line || !line.includes('"task_complete"')) return null;
|
|
72
|
+
let entry;
|
|
73
|
+
try { entry = JSON.parse(line); } catch { return null; }
|
|
74
|
+
if (entry?.type !== 'event_msg' || entry.payload?.type !== 'task_complete') return null;
|
|
75
|
+
const error = entry.payload.error;
|
|
76
|
+
if (!error || typeof error !== 'object') return null;
|
|
77
|
+
const message = typeof error.message === 'string' ? error.message.trim() : '';
|
|
78
|
+
if (!message) return null;
|
|
79
|
+
// The message as pane text, with the same anchors and the same reset-line
|
|
80
|
+
// selection as the scraped TUI — so the two paths cannot disagree about a
|
|
81
|
+
// banner. All of it (tail 0): a message that wraps its reset time onto a
|
|
82
|
+
// second line must still be read whole.
|
|
83
|
+
const detected = detectLimit(message, 0, codexPatterns);
|
|
84
|
+
if (!detected.hit) return null;
|
|
85
|
+
const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
|
|
86
|
+
// A workspace wall says what it is in words, with no time to try again at.
|
|
87
|
+
// Behind a proxy no snapshot is there to classify it, so the banner files
|
|
88
|
+
// it the way parseSnapshot files a workspace reason with no spent window:
|
|
89
|
+
// a model limit — probed, then held for a human, never woken into.
|
|
90
|
+
const wall = WORKSPACE_WALL_BANNER.test(message);
|
|
91
|
+
return {
|
|
92
|
+
limitType: wall ? 'model' : detected.limitType,
|
|
93
|
+
resetAt: null,
|
|
94
|
+
resetLine: wall ? null : (detected.resetLine || message),
|
|
95
|
+
reachedType: null,
|
|
96
|
+
timestampMs: Number.isFinite(ts) ? ts : null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
36
100
|
function emptyPremium(rl) {
|
|
37
|
-
|
|
38
|
-
&& rl.credits?.has_credits === false && rl.credits?.unlimited === false
|
|
39
|
-
|
|
40
|
-
|
|
101
|
+
if (!(rl.limit_id === 'premium' && rl.primary === null && rl.secondary === null
|
|
102
|
+
&& rl.credits?.has_credits === false && rl.credits?.unlimited === false)) return false;
|
|
103
|
+
if (rl.credits?.balance != null && rl.credits.balance !== '') {
|
|
104
|
+
return Number(rl.credits.balance) === 0;
|
|
105
|
+
}
|
|
106
|
+
// Workspace plans (Business/Team) send the same empty bucket with no balance
|
|
107
|
+
// at all, and say why in rate_limit_reached_type instead.
|
|
108
|
+
return workspaceWall(rl.rate_limit_reached_type);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// rate_limit_reached_type names WHY the server refused, never a window. The
|
|
112
|
+
// enum (codex-rs/protocol) is rate_limit_reached and four workspace_* values:
|
|
113
|
+
// {owner,member}_credits_depleted and {owner,member}_usage_limit_reached. The
|
|
114
|
+
// workspace ones are a wall that no window reset takes down by itself: Codex
|
|
115
|
+
// words them "Your workspace is out of credits…" / "You hit your spend cap…",
|
|
116
|
+
// with no time to try again at. A window that is spent as well is another
|
|
117
|
+
// matter — its reset brings the plan's own allowance back (parseSnapshot).
|
|
118
|
+
function workspaceWall(reachedType) {
|
|
119
|
+
return typeof reachedType === 'string' && reachedType.startsWith('workspace_');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// A window counts as spent from 99%: the server reports fractions, and #20's
|
|
123
|
+
// real stop read 99.0 (the same line the empty-premium inference draws).
|
|
124
|
+
const SPENT_PERCENT = 99;
|
|
125
|
+
|
|
126
|
+
// The window a "limit reached" is about when none reads 100: the one nearest
|
|
127
|
+
// exhaustion, primary on a tie. The server reports fractional percentages and
|
|
128
|
+
// #20's real stop read 99.0, so ">= 100" alone is not the whole story. (The
|
|
129
|
+
// previous rule took the window with the LATEST reset, which for any
|
|
130
|
+
// rate_limit_reached under 100% was the weekly one — days out.)
|
|
131
|
+
function nearestExhausted(windows) {
|
|
132
|
+
return windows.reduce((a, b) => ((b.used_percent ?? 0) > (a?.used_percent ?? -1) ? b : a), null);
|
|
41
133
|
}
|
|
42
134
|
|
|
43
135
|
// With several exhausted windows, the latest reset governs: resuming at an
|
|
@@ -46,19 +138,45 @@ function parseSnapshot(entry, previous = null) {
|
|
|
46
138
|
if (!entry) return null;
|
|
47
139
|
const rl = entry.payload.rate_limits;
|
|
48
140
|
const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
|
|
141
|
+
const reachedType = rl.rate_limit_reached_type || null;
|
|
49
142
|
|
|
50
143
|
const windows = ['primary', 'secondary']
|
|
51
144
|
.map(k => rl[k])
|
|
52
145
|
.filter(w => w && typeof w === 'object');
|
|
53
146
|
let binding = null;
|
|
147
|
+
// The rate-limit bucket the binding window belongs to (Codex defaults a
|
|
148
|
+
// missing limit_id to "codex"). parseRolloutLines pairs errors by it.
|
|
149
|
+
let bucket = rl.limit_id ?? 'codex';
|
|
54
150
|
const exhausted = windows.filter(w => (w.used_percent ?? 0) >= 100);
|
|
55
151
|
if (exhausted.length > 0) {
|
|
56
152
|
binding = exhausted.reduce((a, b) => ((b.resets_at || 0) > (a.resets_at || 0) ? b : a));
|
|
57
|
-
} else if (
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
153
|
+
} else if (workspaceWall(reachedType) && windows.length > 0) {
|
|
154
|
+
const spent = windows.filter(w => (w.used_percent ?? 0) >= SPENT_PERCENT);
|
|
155
|
+
if (spent.length > 0) {
|
|
156
|
+
// The plan's window is spent too (99.x% is as spent as 100): its reset
|
|
157
|
+
// brings the allowance back, so it governs exactly like an exhausted
|
|
158
|
+
// one — otherwise the same stop reads as a waitable 5h stop at 100.0
|
|
159
|
+
// and as a wall to hold for a human at 99.9.
|
|
160
|
+
binding = spent.reduce((a, b) => ((b.resets_at || 0) > (a.resets_at || 0) ? b : a));
|
|
161
|
+
} else {
|
|
162
|
+
// Out of credits (or over the workspace cap) with no window spent:
|
|
163
|
+
// there is no reset to sleep until. Record it the way a model limit is
|
|
164
|
+
// recorded — no reset time, so the resumer probes and, at the ceiling,
|
|
165
|
+
// makes the stall visible with the adapter's remedy instead of waking
|
|
166
|
+
// into the same wall (#25 saw one of these scheduled as a 5h stop).
|
|
167
|
+
// Only from a snapshot that describes windows: a credits-only bucket
|
|
168
|
+
// (premium/null) carrying the reason must not re-file the 5h stop the
|
|
169
|
+
// account bucket recorded a moment earlier as a probe.
|
|
170
|
+
return {
|
|
171
|
+
limitType: 'model',
|
|
172
|
+
resetAt: null,
|
|
173
|
+
reachedType,
|
|
174
|
+
timestampMs: Number.isFinite(ts) ? ts : null,
|
|
175
|
+
bucket,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
} else if (reachedType) {
|
|
179
|
+
binding = nearestExhausted(windows);
|
|
62
180
|
}
|
|
63
181
|
// #20: Codex can stop at a reported 99%, then emit an empty premium bucket
|
|
64
182
|
// instead of a 100% snapshot. Infer a stop only for that transition,
|
|
@@ -73,6 +191,7 @@ function parseSnapshot(entry, previous = null) {
|
|
|
73
191
|
&& Number.isFinite(primary.used_percent) && primary.used_percent >= 99
|
|
74
192
|
&& Number.isFinite(primary.resets_at) && primary.resets_at * 1000 > ts) {
|
|
75
193
|
binding = primary;
|
|
194
|
+
bucket = prior.limit_id;
|
|
76
195
|
const secondary = prior.secondary;
|
|
77
196
|
if (Number.isFinite(secondary?.used_percent) && secondary.used_percent >= 100
|
|
78
197
|
&& Number.isFinite(secondary.resets_at) && secondary.resets_at > binding.resets_at) {
|
|
@@ -85,13 +204,14 @@ function parseSnapshot(entry, previous = null) {
|
|
|
85
204
|
return {
|
|
86
205
|
limitType: labelWindow(binding.window_minutes),
|
|
87
206
|
resetAt: binding.resets_at ? binding.resets_at * 1000 : null,
|
|
88
|
-
reachedType
|
|
207
|
+
reachedType,
|
|
89
208
|
timestampMs: Number.isFinite(ts) ? ts : null,
|
|
209
|
+
bucket,
|
|
90
210
|
};
|
|
91
211
|
}
|
|
92
212
|
|
|
93
213
|
export function parseRolloutLine(line) {
|
|
94
|
-
return parseSnapshot(rolloutSnapshot(line));
|
|
214
|
+
return parseSnapshot(rolloutSnapshot(line)) || rolloutLimitError(line);
|
|
95
215
|
}
|
|
96
216
|
|
|
97
217
|
// Look immediately before the appended batch, not at the file's current EOF:
|
|
@@ -116,18 +236,72 @@ function previousSnapshot(path, offset) {
|
|
|
116
236
|
return null;
|
|
117
237
|
}
|
|
118
238
|
|
|
239
|
+
// A snapshot stop and the task_complete error of the same turn describe one
|
|
240
|
+
// event: keep the epoch (exact) and let the banner only fill in what the
|
|
241
|
+
// snapshot lacks. Bound the pairing to the same batch and a short window so a
|
|
242
|
+
// stale exhausted snapshot never lends its epoch to a later, unrelated stop —
|
|
243
|
+
// and to a stop that still stands when the error is written: its epoch must
|
|
244
|
+
// not have passed yet, and no later reading of the same bucket may show it
|
|
245
|
+
// cleared. Only the same bucket: Codex writes one token_count line per
|
|
246
|
+
// rate-limit bucket per response, so a healthy `codex_other` line right after
|
|
247
|
+
// an exhausted account line says nothing about the account.
|
|
248
|
+
const SNAPSHOT_PAIR_WINDOW_MS = 5 * 60_000;
|
|
249
|
+
|
|
250
|
+
function describesWindows(rl) {
|
|
251
|
+
return ['primary', 'secondary'].some(k => rl[k] && typeof rl[k] === 'object');
|
|
252
|
+
}
|
|
253
|
+
|
|
119
254
|
export function parseRolloutLines(lines, { path, offset } = {}) {
|
|
120
255
|
let previous;
|
|
256
|
+
let lastSnapshotHit = null;
|
|
121
257
|
const hits = [];
|
|
122
258
|
for (const line of lines) {
|
|
123
259
|
const snapshot = rolloutSnapshot(line);
|
|
124
|
-
if (
|
|
125
|
-
|
|
126
|
-
|
|
260
|
+
if (snapshot) {
|
|
261
|
+
if (previous === undefined && emptyPremium(snapshot.payload.rate_limits)) {
|
|
262
|
+
previous = previousSnapshot(path, offset);
|
|
263
|
+
}
|
|
264
|
+
const hit = parseSnapshot(snapshot, previous);
|
|
265
|
+
if (hit) {
|
|
266
|
+
hits.push(hit);
|
|
267
|
+
lastSnapshotHit = hit;
|
|
268
|
+
} else if (lastSnapshotHit) {
|
|
269
|
+
const rl = snapshot.payload.rate_limits;
|
|
270
|
+
if ((rl.limit_id ?? 'codex') === lastSnapshotHit.bucket && describesWindows(rl)) {
|
|
271
|
+
lastSnapshotHit = null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
previous = snapshot;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const error = rolloutLimitError(line);
|
|
278
|
+
if (!error) continue;
|
|
279
|
+
const stop = lastSnapshotHit;
|
|
280
|
+
const paired = stop
|
|
281
|
+
&& Number.isFinite(error.timestampMs) && Number.isFinite(stop.timestampMs)
|
|
282
|
+
&& error.timestampMs >= stop.timestampMs
|
|
283
|
+
&& error.timestampMs - stop.timestampMs <= SNAPSHOT_PAIR_WINDOW_MS
|
|
284
|
+
// A workspace wall has no epoch to lend, and pairs anyway (below).
|
|
285
|
+
&& (stop.resetAt ? stop.resetAt > error.timestampMs : stop.limitType === 'model');
|
|
286
|
+
if (paired) {
|
|
287
|
+
error.resetAt = stop.resetAt;
|
|
288
|
+
error.reachedType = stop.reachedType;
|
|
289
|
+
if (stop.limitType === 'model') {
|
|
290
|
+
// The snapshot already said what this is: a workspace wall (credits
|
|
291
|
+
// depleted, workspace cap) that no reset takes down. The banner must
|
|
292
|
+
// not turn it back into a waitable stop — probe, then hold for a
|
|
293
|
+
// human, exactly as parseSnapshot filed it.
|
|
294
|
+
error.limitType = 'model';
|
|
295
|
+
error.resetLine = null;
|
|
296
|
+
} else if (error.limitType === 'unknown'
|
|
297
|
+
// A workspace-wall banner over a spent window: the snapshot already
|
|
298
|
+
// weighed the reason against the window, and the window's reset
|
|
299
|
+
// brings the allowance back. Other model limits keep their label.
|
|
300
|
+
|| (error.limitType === 'model' && workspaceWall(stop.reachedType))) {
|
|
301
|
+
error.limitType = stop.limitType;
|
|
302
|
+
}
|
|
127
303
|
}
|
|
128
|
-
|
|
129
|
-
if (hit) hits.push(hit);
|
|
130
|
-
previous = snapshot;
|
|
304
|
+
hits.push(error);
|
|
131
305
|
}
|
|
132
306
|
return hits;
|
|
133
307
|
}
|
package/src/which.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Where a bare command name would be found: the first PATH entry holding one
|
|
2
|
+
// of `names`, as {dir, name, path}, or null. Path rules follow the platform
|
|
3
|
+
// being resolved for, not the host: on Windows the delimiter is ';' and every
|
|
4
|
+
// absolute entry contains a ':' (C:\...), so a ':' split shreds the list into
|
|
5
|
+
// nonsense and always answers "not on PATH" there (#25).
|
|
6
|
+
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { win32, posix } from 'node:path';
|
|
9
|
+
|
|
10
|
+
export function findOnPath(names, { env = process.env, exists = existsSync, platform = process.platform } = {}) {
|
|
11
|
+
const api = platform === 'win32' ? win32 : posix;
|
|
12
|
+
for (const entry of (env.PATH || '').split(api.delimiter)) {
|
|
13
|
+
// Windows PATH entries may be quoted ("C:\Program Files\x"); spawn's own
|
|
14
|
+
// search (libuv) strips the quotes, so this one must too.
|
|
15
|
+
const dir = platform === 'win32' ? entry.replace(/^(["'])(.*)\1$/, '$2') : entry;
|
|
16
|
+
if (!dir) continue;
|
|
17
|
+
for (const name of names) {
|
|
18
|
+
const path = api.join(dir, name);
|
|
19
|
+
try { if (exists(path)) return { dir, name, path }; } catch { /* unreadable entry */ }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The spellings a bare name can take on this platform, launchable first.
|
|
26
|
+
// Node's spawn resolves .exe/.com itself; .cmd/.bat need a shell it is not
|
|
27
|
+
// given, so they are "found" in the sense that explains a failure.
|
|
28
|
+
export function candidateNames(name, platform = process.platform) {
|
|
29
|
+
if (platform !== 'win32') return [name];
|
|
30
|
+
if (/\.(exe|com|cmd|bat)$/i.test(name)) return [name];
|
|
31
|
+
return [`${name}.exe`, `${name}.com`, `${name}.cmd`, `${name}.bat`];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Resolve what spawn(bin) would actually run: an absolute or relative path is
|
|
35
|
+
// checked as-is; a bare name is searched on PATH. Returns
|
|
36
|
+
// { path, launchable } or null when nothing is found.
|
|
37
|
+
export function resolveBin(bin, { env = process.env, exists = existsSync, platform = process.platform } = {}) {
|
|
38
|
+
if (typeof bin !== 'string' || bin === '') return null;
|
|
39
|
+
const shim = p => /\.(cmd|bat)$/i.test(p);
|
|
40
|
+
// Anything with a separator of either flavour is a path, checked as given —
|
|
41
|
+
// spawn does the same, and a Windows-shaped path handed to a POSIX lookup
|
|
42
|
+
// (doctor's own tests simulate one platform on another) must not be
|
|
43
|
+
// searched on PATH as if it were a bare name.
|
|
44
|
+
if (win32.isAbsolute(bin) || posix.isAbsolute(bin) || bin.includes('/') || bin.includes('\\')) {
|
|
45
|
+
// On Windows spawn tries an extensionless path as .com then .exe
|
|
46
|
+
// (UNSNOOZE_CODEX_BIN=C:\tools\codex runs C:\tools\codex.exe). The literal
|
|
47
|
+
// path stays the fallback, for a POSIX-shaped path checked under Windows
|
|
48
|
+
// rules (doctor's tests simulate one platform on the other).
|
|
49
|
+
if (platform === 'win32' && !win32.extname(bin)) {
|
|
50
|
+
const hit = [`${bin}.com`, `${bin}.exe`].find(p => exists(p));
|
|
51
|
+
if (hit) return { path: hit, launchable: true };
|
|
52
|
+
}
|
|
53
|
+
return exists(bin) ? { path: bin, launchable: !shim(bin) } : null;
|
|
54
|
+
}
|
|
55
|
+
// Two passes, because spawn never tries a .cmd/.bat (libuv only appends
|
|
56
|
+
// .com and .exe): an .exe anywhere on PATH is what runs, even behind a shim
|
|
57
|
+
// in an earlier directory — the npm shim a native install left behind.
|
|
58
|
+
// Only when nothing launchable exists is a shim the answer, as the reason.
|
|
59
|
+
const names = candidateNames(bin, platform);
|
|
60
|
+
const opts = { env, exists, platform };
|
|
61
|
+
const hit = findOnPath(names.filter(n => !shim(n)), opts) || findOnPath(names.filter(shim), opts);
|
|
62
|
+
return hit ? { path: hit.path, launchable: !shim(hit.path) } : null;
|
|
63
|
+
}
|
package/src/wizard.js
CHANGED
|
@@ -77,14 +77,18 @@ export async function runWizard() {
|
|
|
77
77
|
});
|
|
78
78
|
if (p.isCancel(notifications)) return cancelled();
|
|
79
79
|
|
|
80
|
-
// GUI watching: only meaningful where
|
|
81
|
-
//
|
|
82
|
-
//
|
|
80
|
+
// GUI watching: only meaningful where the daemon can run. Windows needs it
|
|
81
|
+
// most, since headless has no pane monitor and the transcript watcher lives
|
|
82
|
+
// in the daemon — and there it is started on demand, with nothing registered
|
|
83
|
+
// to run at sign-in (see DAEMON_ON_DEMAND in config.js).
|
|
83
84
|
let guiWatch = false;
|
|
84
85
|
if (['darwin', 'linux', 'win32'].includes(process.platform)) {
|
|
86
|
+
const how = process.platform === 'win32'
|
|
87
|
+
? 'started on demand by your agents — nothing runs at sign-in'
|
|
88
|
+
: (process.platform === 'darwin' ? 'launchd' : 'systemd');
|
|
85
89
|
const answer = await p.confirm({
|
|
86
90
|
message: 'Also guard GUI sessions (Claude Code in VS Code/desktop, Codex app/IDE)?\n'
|
|
87
|
-
+
|
|
91
|
+
+ ` Runs a small background daemon (${how}) that watches session\n`
|
|
88
92
|
+ ' files for limit stops; revived sessions open in the configured multiplexer and stay visible in\n'
|
|
89
93
|
+ ' the GUI\'s own history.',
|
|
90
94
|
initialValue: DEFAULTS.guiWatch,
|
package/src/workspace.js
CHANGED
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
import { execFileSync } from 'node:child_process';
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
9
|
|
|
10
|
+
// windowsHide: this runs in the daemon and the resumer, which have no console
|
|
11
|
+
// on Windows, so git would otherwise flash a console window of its own at
|
|
12
|
+
// every stop and every wake.
|
|
10
13
|
function git(cwd, args) {
|
|
11
14
|
return execFileSync('git', ['-C', cwd, ...args], {
|
|
12
|
-
stdio: ['ignore', 'pipe', 'ignore'], timeout: 1500,
|
|
15
|
+
stdio: ['ignore', 'pipe', 'ignore'], timeout: 1500, windowsHide: true,
|
|
13
16
|
}).toString().trim();
|
|
14
17
|
}
|
|
15
18
|
|