unsnooze 1.20.1 → 1.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/README.md +4 -0
- package/package.json +1 -1
- package/src/agents/codex.js +33 -5
- package/src/cli.js +9 -3
- package/src/dashboard/gate.js +20 -0
- package/src/dashboard/run.js +4 -16
- package/src/dashboard/tabs/PromptsTab.js +1 -4
- package/src/doctor.js +4 -3
- package/src/install.js +27 -10
- package/src/multiplexers/herdr.js +42 -9
- package/src/resumer.js +172 -3
- package/src/settings.js +2 -0
- package/src/update-check.js +19 -2
- package/src/usage.js +5 -2
- package/src/watchers/codex.js +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,59 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.21.1 — 2026-10-26
|
|
4
|
+
|
|
5
|
+
A herdr session that is still open is now resumed in its own pane instead of a
|
|
6
|
+
new tab.
|
|
7
|
+
|
|
8
|
+
### herdr
|
|
9
|
+
|
|
10
|
+
- When a stopped session has no pane on record (it was detected from its
|
|
11
|
+
transcript), or its recorded pane is gone, unsnooze now finds the herdr pane
|
|
12
|
+
already holding that conversation by its session id and resumes there,
|
|
13
|
+
instead of opening a new tab that duplicated the conversation. If herdr
|
|
14
|
+
reports the agent there as working, unsnooze waits (#47, #48).
|
|
15
|
+
- unsnooze never guesses. If two panes claim the session, or the pane holding
|
|
16
|
+
it isn't safe to type into, the stop is marked failed with the reason and
|
|
17
|
+
one notification. If another unsnooze owns the pane, or herdr can't be
|
|
18
|
+
read, it counts as a failed attempt, so the stop still ends normally.
|
|
19
|
+
|
|
20
|
+
## 1.21.0 — 2026-10-24
|
|
21
|
+
|
|
22
|
+
Codex sub-agents no longer show up as a second, unresumable session, a thread
|
|
23
|
+
Codex Desktop still holds fails once with a reason instead of five times,
|
|
24
|
+
`unsnooze update` works on Windows, and the dashboard can stay out of the way.
|
|
25
|
+
|
|
26
|
+
### Codex
|
|
27
|
+
|
|
28
|
+
- A Codex session that spawns multi-agent sub-agents was tracked twice per
|
|
29
|
+
limit stop: once for the conversation, and once for the sub-agent's own
|
|
30
|
+
rollout, which Codex refuses to resume. That second record burned five
|
|
31
|
+
attempts and sent a "gave up" notification on every stop. A sub-agent's stop
|
|
32
|
+
is now recorded under its parent thread, so one stop is one record (#50).
|
|
33
|
+
- A headless revive of a thread that Codex Desktop still has open fails with
|
|
34
|
+
"already has an active writer" no matter how often it is retried. unsnooze
|
|
35
|
+
now stops after the first attempt and tells you to continue the thread in
|
|
36
|
+
Codex Desktop, or close it there so unsnooze can take over (#49).
|
|
37
|
+
- "Selected model is at capacity" is retried as an overload, like a stream
|
|
38
|
+
error, instead of leaving the session sitting at the prompt (#46).
|
|
39
|
+
|
|
40
|
+
### Dashboard
|
|
41
|
+
|
|
42
|
+
- `unsnooze config set autoDashboard off` (or `UNSNOOZE_AUTO_DASHBOARD=off`)
|
|
43
|
+
keeps `status`, `usage` and `sessions` in their plain terminal view on a
|
|
44
|
+
TTY. `unsnooze dashboard` still opens the full dashboard (#54).
|
|
45
|
+
- The daemon and watchers no longer load React and Ink unless the dashboard
|
|
46
|
+
actually opens, roughly halving their memory use.
|
|
47
|
+
|
|
48
|
+
### Install and update
|
|
49
|
+
|
|
50
|
+
- A shell rc file that is a symlink into a dotfiles repository stays a symlink:
|
|
51
|
+
install, update and uninstall edit the file it points to, and the backups
|
|
52
|
+
sit next to that file. This also fixes `EPERM` on a link with macOS `uchg`
|
|
53
|
+
set (#51, #53).
|
|
54
|
+
- `unsnooze update` and `unsnooze doctor` failed on Windows with
|
|
55
|
+
`spawnSync npm ENOENT`. They now run npm in a way Windows accepts (#49).
|
|
56
|
+
|
|
3
57
|
## 1.20.1 — 2026-10-08
|
|
4
58
|
|
|
5
59
|
`unsnooze help` lists the usage flags in the right place again, and the docs'
|
package/README.md
CHANGED
|
@@ -62,6 +62,10 @@ unsnooze usage # burn rate and time-to-limit, before you hit the
|
|
|
62
62
|
unsnooze doctor [--fix] # install health check
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
+
Prefer a text view? Run `unsnooze config set autoDashboard off` to make
|
|
66
|
+
`status`, `usage`, and `sessions` print their terminal view without opening the
|
|
67
|
+
full-screen dashboard. `unsnooze dashboard` still opens it explicitly.
|
|
68
|
+
|
|
65
69
|
Needs Node ≥ 20.12 on macOS, Linux or Windows. tmux ≥ 3.2, Zellij, herdr ≥ 0.8
|
|
66
70
|
or cmux give pane-level watching; without one, unsnooze runs headless and still
|
|
67
71
|
catches and resumes stops. Every command: [command reference](https://unsnooze.dev/docs/commands/).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unsnooze",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.1",
|
|
4
4
|
"description": "Automatically resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode, Antigravity, and Cursor CLI sessions when 5-hour or weekly usage limits reset—on macOS, Linux and Windows, with or without a terminal multiplexer.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/src/agents/codex.js
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
// Reset-time tails vary by plan: "try again at 3:51 PM." (same day),
|
|
8
8
|
// "try again at Feb 23rd, 2026 9:01 PM." (cross-day), "Try again in 4 days
|
|
9
9
|
// 20 hours 9 minutes." (older builds), or "Try again later." (no timestamp).
|
|
10
|
-
// Transient errors render "stream error: … retrying 4/5 in 1.4s"
|
|
11
|
-
//
|
|
10
|
+
// Transient errors render "stream error: … retrying 4/5 in 1.4s" or "Selected
|
|
11
|
+
// model is at capacity. Please try a different model." (ServerOverloaded, which
|
|
12
|
+
// Codex does not retry itself) and must take the overload path, never the ledger.
|
|
12
13
|
|
|
13
14
|
import { openSync, readSync, closeSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
14
15
|
import { join, basename } from 'node:path';
|
|
@@ -104,8 +105,8 @@ export const patterns = {
|
|
|
104
105
|
/retrying\s+\d+\/\d+/i, // internal stream-error retry — don't inject
|
|
105
106
|
],
|
|
106
107
|
idleRegex: /›/, // composer: "› Ask Codex to do anything"
|
|
107
|
-
overloadPatterns: [/stream error/i, /exceeded retry limit/i],
|
|
108
|
-
transientPatterns: [/stream error/i, /exceeded retry limit/i],
|
|
108
|
+
overloadPatterns: [/stream error/i, /exceeded retry limit/i, /model is at capacity/i],
|
|
109
|
+
transientPatterns: [/stream error/i, /exceeded retry limit/i, /model is at capacity/i],
|
|
109
110
|
};
|
|
110
111
|
|
|
111
112
|
// A submitted user turn uses the same › glyph as the composer. Only accept
|
|
@@ -151,6 +152,19 @@ export function rolloutId(path) {
|
|
|
151
152
|
return m ? m[1].toLowerCase() : null;
|
|
152
153
|
}
|
|
153
154
|
|
|
155
|
+
// A multi-agent v2 sub-agent writes its own rollout, in the same cwd, whose
|
|
156
|
+
// session_meta names the thread that spawned it (top-level parent_thread_id,
|
|
157
|
+
// and source.subagent.thread_spawn.parent_thread_id). Codex refuses to resume
|
|
158
|
+
// a sub-agent on its own ("resume the parent first"), so the conversation's
|
|
159
|
+
// resumable id is the parent's (#49, #50).
|
|
160
|
+
export function subagentParent(payload) {
|
|
161
|
+
const id = payload?.parent_thread_id
|
|
162
|
+
?? payload?.source?.subagent?.thread_spawn?.parent_thread_id;
|
|
163
|
+
return typeof id === 'string' && id ? id : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const PARENT_IN_HEAD_RE = new RegExp(`"parent_thread_id"\\s*:\\s*"(${UUID})"`, 'i');
|
|
167
|
+
|
|
154
168
|
function fileHead(path, bytes = 4096) {
|
|
155
169
|
let fd;
|
|
156
170
|
try {
|
|
@@ -186,7 +200,11 @@ export function latestSessionId(cwd, aroundTs = null, sessionsRoot = join(CODEX_
|
|
|
186
200
|
// JSON-escape the cwd the way it appears inside the meta line.
|
|
187
201
|
const needle = cwd ? JSON.stringify(cwd).slice(1, -1) : null;
|
|
188
202
|
for (const f of files.slice(0, 20)) {
|
|
189
|
-
|
|
203
|
+
const head = fileHead(f.path);
|
|
204
|
+
if (needle && !head.includes(needle)) continue;
|
|
205
|
+
// A sub-agent's rollout is usually the newest in the cwd; it names the
|
|
206
|
+
// pane's conversation through its parent.
|
|
207
|
+
return head.match(PARENT_IN_HEAD_RE)?.[1] ?? f.id;
|
|
190
208
|
}
|
|
191
209
|
return null;
|
|
192
210
|
}
|
|
@@ -228,6 +246,16 @@ export default {
|
|
|
228
246
|
// resume-now only wakes stopped ones — it would answer "no matching stopped
|
|
229
247
|
// sessions" to the one command the notification told the user to run.
|
|
230
248
|
modelRemedy: 'add credits or raise the spend cap in the ChatGPT workspace (or ask its owner to), then continue the session in Codex',
|
|
249
|
+
// Revival refusals no retry can fix (codex-cli 0.158–0.163 wording, #49):
|
|
250
|
+
// another Codex process, usually the Desktop app's app-server, owns the
|
|
251
|
+
// thread; or the id is a multi-agent v2 sub-agent, resumable only through
|
|
252
|
+
// its parent.
|
|
253
|
+
reviveRefusals: [
|
|
254
|
+
{ pattern: /already has an active writer/i,
|
|
255
|
+
reason: 'the thread is still open in another Codex process (usually Codex Desktop) — continue it there, or close it there so unsnooze can resume it' },
|
|
256
|
+
{ pattern: /cannot resume an unloaded multi-agent v2 sub-agent/i,
|
|
257
|
+
reason: 'this is a Codex sub-agent thread; it continues when its parent session does' },
|
|
258
|
+
],
|
|
231
259
|
// Resume takes the prompt in argv — `codex resume <id> "msg"` starts the turn
|
|
232
260
|
// immediately, nothing to type into the TUI.
|
|
233
261
|
//
|
package/src/cli.js
CHANGED
|
@@ -16,7 +16,7 @@ import { queueList } from './prompt-queue.js';
|
|
|
16
16
|
import {
|
|
17
17
|
shouldUseTui, formatStatusTui, formatSessionsTui, formatPreviewTui, logoBlock,
|
|
18
18
|
} from './tui.js';
|
|
19
|
-
import { shouldUseDashboard
|
|
19
|
+
import { shouldUseDashboard } from './dashboard/gate.js';
|
|
20
20
|
|
|
21
21
|
function fmtCountdown(ms) {
|
|
22
22
|
if (ms <= 0) return 'due now';
|
|
@@ -109,7 +109,10 @@ export async function cmdStatus(args = []) {
|
|
|
109
109
|
return 0;
|
|
110
110
|
}
|
|
111
111
|
// Interactive TTY → live dashboard (until q). Pipes stay plain.
|
|
112
|
-
if (shouldUseDashboard())
|
|
112
|
+
if (shouldUseDashboard()) {
|
|
113
|
+
const { runDashboard } = await import('./dashboard/run.js');
|
|
114
|
+
return runDashboard({ tab: 'status' });
|
|
115
|
+
}
|
|
113
116
|
|
|
114
117
|
const state = readState();
|
|
115
118
|
const sessions = Object.values(state.sessions);
|
|
@@ -411,7 +414,10 @@ export async function cmdPreview(rest = [], {
|
|
|
411
414
|
|
|
412
415
|
// `unsnooze sessions` — list unsnooze-owned mux sessions with panes + records.
|
|
413
416
|
export async function cmdSessions() {
|
|
414
|
-
if (shouldUseDashboard())
|
|
417
|
+
if (shouldUseDashboard()) {
|
|
418
|
+
const { runDashboard } = await import('./dashboard/run.js');
|
|
419
|
+
return runDashboard({ tab: 'sessions' });
|
|
420
|
+
}
|
|
415
421
|
const owned = await listOwnedSessions();
|
|
416
422
|
if (shouldUseTui()) {
|
|
417
423
|
console.log(formatSessionsTui(owned, { color: true }));
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Keep the dashboard decision separate from React/Ink. Watchers import CLI and
|
|
2
|
+
// usage helpers, so they must be able to check this without loading the UI.
|
|
3
|
+
import { getConfig } from '../settings.js';
|
|
4
|
+
|
|
5
|
+
export function shouldUseDashboard({
|
|
6
|
+
force = null,
|
|
7
|
+
json = false,
|
|
8
|
+
isTTY = process.stdout?.isTTY && process.stdin?.isTTY,
|
|
9
|
+
env = process.env,
|
|
10
|
+
automatic = true,
|
|
11
|
+
} = {}) {
|
|
12
|
+
if (force === true) return true;
|
|
13
|
+
if (force === false) return false;
|
|
14
|
+
if (automatic && !getConfig('autoDashboard')) return false;
|
|
15
|
+
if (json) return false;
|
|
16
|
+
if (env.NO_COLOR != null && env.NO_COLOR !== '') return false;
|
|
17
|
+
if (env.CI === 'true' || env.CI === '1') return false;
|
|
18
|
+
if (env.TERM === 'dumb') return false;
|
|
19
|
+
return !!isTTY;
|
|
20
|
+
}
|
package/src/dashboard/run.js
CHANGED
|
@@ -4,6 +4,9 @@ import { render } from 'ink';
|
|
|
4
4
|
import { App } from './App.js';
|
|
5
5
|
import { MOUSE_DISABLE_ALL } from './mouse-protocol.js';
|
|
6
6
|
import { getConfig } from '../settings.js';
|
|
7
|
+
import { shouldUseDashboard } from './gate.js';
|
|
8
|
+
|
|
9
|
+
export { shouldUseDashboard } from './gate.js';
|
|
7
10
|
|
|
8
11
|
const h = React.createElement;
|
|
9
12
|
|
|
@@ -97,23 +100,8 @@ export function startTimingSweep({ intervalMs = TIMING_SWEEP_MS, perf } = {}) {
|
|
|
97
100
|
};
|
|
98
101
|
}
|
|
99
102
|
|
|
100
|
-
export function shouldUseDashboard({
|
|
101
|
-
force = null,
|
|
102
|
-
json = false,
|
|
103
|
-
isTTY = process.stdout?.isTTY && process.stdin?.isTTY,
|
|
104
|
-
env = process.env,
|
|
105
|
-
} = {}) {
|
|
106
|
-
if (force === true) return true;
|
|
107
|
-
if (force === false) return false;
|
|
108
|
-
if (json) return false;
|
|
109
|
-
if (env.NO_COLOR != null && env.NO_COLOR !== '') return false;
|
|
110
|
-
if (env.CI === 'true' || env.CI === '1') return false;
|
|
111
|
-
if (env.TERM === 'dumb') return false;
|
|
112
|
-
return !!isTTY;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
103
|
export async function runDashboard({ tab = 'status' } = {}) {
|
|
116
|
-
if (!shouldUseDashboard()) {
|
|
104
|
+
if (!shouldUseDashboard({ automatic: false })) {
|
|
117
105
|
console.error('unsnooze dashboard: requires an interactive TTY (not a pipe/CI).');
|
|
118
106
|
// Still show the brand so users see the logo even when falling back
|
|
119
107
|
const { logoPlainText } = await import('./Logo.js');
|
|
@@ -19,10 +19,7 @@ const h = React.createElement;
|
|
|
19
19
|
|
|
20
20
|
// -- small local helpers ---------------------------------------------------
|
|
21
21
|
|
|
22
|
-
//
|
|
23
|
-
// this dashboard module transitively (cli.js -> dashboard/run.js -> App.js),
|
|
24
|
-
// so importing it back here would be a real import cycle, not just a scary
|
|
25
|
-
// one. Five lines is cheaper than untangling that.
|
|
22
|
+
// Keep this local so the dashboard does not import the full CLI command graph.
|
|
26
23
|
function shortenCwd(p) {
|
|
27
24
|
if (typeof p !== 'string' || !p) return p;
|
|
28
25
|
const home = homedir();
|
package/src/doctor.js
CHANGED
|
@@ -20,6 +20,7 @@ import { fishConfigPath } from './fish.js';
|
|
|
20
20
|
import { listAgents } from './agents/index.js';
|
|
21
21
|
import { getConfig } from './settings.js';
|
|
22
22
|
import { resolveBin } from './which.js';
|
|
23
|
+
import { runNpm } from './update-check.js';
|
|
23
24
|
import { CLAUDE_DIR, CODEX_DIR } from './config.js';
|
|
24
25
|
|
|
25
26
|
const log = makeLogger('doctor');
|
|
@@ -35,9 +36,9 @@ function isCsgUnit(content) {
|
|
|
35
36
|
return /claude-session-guard/.test(content) && !/unsnooze\.js|com\.unsnooze/.test(content);
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
function defaultRunner(cmd, args) {
|
|
39
|
+
function defaultRunner(cmd, args, opts = {}) {
|
|
39
40
|
try {
|
|
40
|
-
return spawnSync(cmd, args, { encoding: 'utf-8' });
|
|
41
|
+
return spawnSync(cmd, args, { encoding: 'utf-8', ...opts });
|
|
41
42
|
} catch (err) {
|
|
42
43
|
return { status: 1, stdout: '', error: err };
|
|
43
44
|
}
|
|
@@ -90,7 +91,7 @@ export function defaultCsgPackagePath({
|
|
|
90
91
|
execPathRoots = [join(dirname(dirname(process.execPath)), 'lib', 'node_modules')],
|
|
91
92
|
} = {}) {
|
|
92
93
|
const roots = [];
|
|
93
|
-
const r =
|
|
94
|
+
const r = runNpm(['root', '-g'], {}, { runner });
|
|
94
95
|
if (!r.error && r.status === 0 && r.stdout) roots.push(String(r.stdout).trim());
|
|
95
96
|
roots.push(...execPathRoots); // nvm/unix prefix layout fallback when npm is slow/absent
|
|
96
97
|
for (const root of roots) {
|
package/src/install.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// --settings <path> / --zshrc <path> / --fishrc <path> override targets (used
|
|
12
12
|
// by tests).
|
|
13
13
|
|
|
14
|
-
import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync, rmSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
14
|
+
import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync, rmSync, mkdirSync, unlinkSync, lstatSync, realpathSync } from 'node:fs';
|
|
15
15
|
import { execFileSync } from 'node:child_process';
|
|
16
16
|
import { homedir, userInfo } from 'node:os';
|
|
17
17
|
import { join, dirname, basename, delimiter } from 'node:path';
|
|
@@ -52,20 +52,37 @@ function parseArgs(rest) {
|
|
|
52
52
|
return opts;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
function resolveWriteTarget(path) {
|
|
56
|
+
let target = path;
|
|
57
|
+
try {
|
|
58
|
+
if (lstatSync(path).isSymbolicLink()) target = realpathSync(path);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (err.code !== 'ENOENT') throw err;
|
|
61
|
+
}
|
|
62
|
+
return target;
|
|
63
|
+
}
|
|
64
|
+
|
|
55
65
|
function atomicWrite(path, content) {
|
|
56
|
-
|
|
57
|
-
|
|
66
|
+
// Shell rc files are sometimes symlinks into a dotfiles repository. Rename
|
|
67
|
+
// over the link would replace it (and fails when the link has macOS uchg),
|
|
68
|
+
// so atomically replace the real file while leaving the link itself intact.
|
|
69
|
+
const target = resolveWriteTarget(path);
|
|
70
|
+
mkdirSync(dirname(target), { recursive: true }); // e.g. ~/.claude may not exist yet
|
|
71
|
+
const tmp = join(dirname(target), `.${Date.now()}.tmp`);
|
|
58
72
|
writeFileSync(tmp, content);
|
|
59
|
-
renameSync(tmp,
|
|
73
|
+
renameSync(tmp, target);
|
|
60
74
|
}
|
|
61
75
|
|
|
62
76
|
// Two backup tiers: `.unsnooze-orig` is the pristine pre-unsnooze snapshot,
|
|
63
77
|
// written exactly once (a re-run must never overwrite it with an already-
|
|
64
78
|
// modified file); `.unsnooze-bak` rolls forward on every run.
|
|
65
79
|
function backupOnce(path) {
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
80
|
+
const target = resolveWriteTarget(path);
|
|
81
|
+
const orig = `${target}.unsnooze-orig`;
|
|
82
|
+
const backup = `${target}.unsnooze-bak`;
|
|
83
|
+
if (!existsSync(orig)) copyFileSync(target, orig);
|
|
84
|
+
copyFileSync(target, backup);
|
|
85
|
+
return backup;
|
|
69
86
|
}
|
|
70
87
|
|
|
71
88
|
// --- settings.json hook management ---
|
|
@@ -621,7 +638,7 @@ function qwenSettingsPath() {
|
|
|
621
638
|
export function installQwenHooks() {
|
|
622
639
|
const path = qwenSettingsPath();
|
|
623
640
|
if (existsSync(path)) {
|
|
624
|
-
copyFileSync(path, `${path}.unsnooze-bak`);
|
|
641
|
+
copyFileSync(path, `${resolveWriteTarget(path)}.unsnooze-bak`); // beside the file a symlink points at, like backupOnce
|
|
625
642
|
atomicWrite(path, mergeHookIntoSettings(readFileSync(path, 'utf-8'), QWEN_HOOK_OPTS));
|
|
626
643
|
} else {
|
|
627
644
|
atomicWrite(path, mergeHookIntoSettings('{}', QWEN_HOOK_OPTS));
|
|
@@ -675,10 +692,10 @@ export function cmdInstall(rest, { agents = enabledAgents() } = {}) {
|
|
|
675
692
|
// when installed below).
|
|
676
693
|
if (agents.includes('claude')) {
|
|
677
694
|
if (existsSync(opts.settings)) {
|
|
678
|
-
backupOnce(opts.settings);
|
|
695
|
+
const backup = backupOnce(opts.settings);
|
|
679
696
|
const before = readFileSync(opts.settings, 'utf-8');
|
|
680
697
|
atomicWrite(opts.settings, mergeHookIntoSettings(before));
|
|
681
|
-
console.log(`unsnooze: StopFailure hook installed in ${opts.settings} (backup: ${
|
|
698
|
+
console.log(`unsnooze: StopFailure hook installed in ${opts.settings} (backup: ${backup})`);
|
|
682
699
|
} else {
|
|
683
700
|
atomicWrite(opts.settings, mergeHookIntoSettings('{}'));
|
|
684
701
|
console.log(`unsnooze: created ${opts.settings} with StopFailure hook`);
|
|
@@ -524,15 +524,17 @@ export function createHerdr({ spawner = defaultSpawner, env = process.env } = {}
|
|
|
524
524
|
},
|
|
525
525
|
|
|
526
526
|
async listSessions() {
|
|
527
|
-
try {
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
527
|
+
try { return await backend.readSessions(); } catch { return []; }
|
|
528
|
+
},
|
|
529
|
+
|
|
530
|
+
// listSessions, but a failed `session list` throws instead of reading as
|
|
531
|
+
// "no sessions".
|
|
532
|
+
async readSessions() {
|
|
533
|
+
const result = parseResult(await run(['session', 'list', '--json']));
|
|
534
|
+
const rows = Array.isArray(result?.sessions) ? result.sessions : [];
|
|
535
|
+
return rows
|
|
536
|
+
.filter(row => typeof row?.name === 'string' && row.name)
|
|
537
|
+
.map(row => ({ name: row.name, exited: row.running === false }));
|
|
536
538
|
},
|
|
537
539
|
|
|
538
540
|
async listSessionPanes(sessionName) {
|
|
@@ -544,6 +546,37 @@ export function createHerdr({ spawner = defaultSpawner, env = process.env } = {}
|
|
|
544
546
|
}
|
|
545
547
|
},
|
|
546
548
|
|
|
549
|
+
// Every live pane, in every running session, whose agent reports this
|
|
550
|
+
// conversation id. herdr tracks `agent_session` per pane, so this finds a
|
|
551
|
+
// session that is still open somewhere even when unsnooze never recorded
|
|
552
|
+
// its pane (transcript-only detection) or recorded a stale id. The cwd is
|
|
553
|
+
// deliberately not consulted: several conversations share a repository.
|
|
554
|
+
async findSessionPanes(sessionId, { agent = null } = {}) {
|
|
555
|
+
if (!sessionId) return [];
|
|
556
|
+
const found = [];
|
|
557
|
+
let failure = null;
|
|
558
|
+
for (const row of await backend.readSessions()) {
|
|
559
|
+
if (row.exited) continue;
|
|
560
|
+
let panes;
|
|
561
|
+
try { panes = await listPaneRows(row.name); } catch (err) { failure ??= err; continue; }
|
|
562
|
+
for (const entry of panes) {
|
|
563
|
+
const identity = entry?.agent_session;
|
|
564
|
+
if (!identity || identity.value !== sessionId || !entry.pane_id) continue;
|
|
565
|
+
if (agent && identity.agent !== agent) continue;
|
|
566
|
+
found.push({
|
|
567
|
+
pane: String(entry.pane_id),
|
|
568
|
+
paneOwner: row.name,
|
|
569
|
+
status: entry.agent_status ?? null,
|
|
570
|
+
stamp: entry.tokens?.unsnooze_owner ?? null,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
// An unreadable session may hold the conversation (or a second pane
|
|
575
|
+
// for it): a partial answer must not read as "no other pane".
|
|
576
|
+
if (failure) throw failure;
|
|
577
|
+
return found;
|
|
578
|
+
},
|
|
579
|
+
|
|
547
580
|
async closePane(pane) {
|
|
548
581
|
await owned('pane', 'close', String(pane));
|
|
549
582
|
},
|
package/src/resumer.js
CHANGED
|
@@ -598,6 +598,28 @@ export async function planFor(rec, {
|
|
|
598
598
|
+ ' knows its session id — reviving both would clone the conversation');
|
|
599
599
|
return { ...base, action: 'superseded', target: { key: superseded.key } };
|
|
600
600
|
}
|
|
601
|
+
const { matches, failed } = await lookupExistingPane(rec, agent, mux);
|
|
602
|
+
if (failed) {
|
|
603
|
+
gates.push('existing-pane lookup failed — dispatch counts it as a failed attempt');
|
|
604
|
+
return { ...base, action: 'retry', target: { pane: rec.pane } };
|
|
605
|
+
}
|
|
606
|
+
if (matches.length > 1) {
|
|
607
|
+
gates.push(`${matches.length} panes claim this session — would mark failed and notify rather than guess`);
|
|
608
|
+
return { ...base, action: 'give-up' };
|
|
609
|
+
}
|
|
610
|
+
if (matches.length === 1) {
|
|
611
|
+
const [hit] = matches;
|
|
612
|
+
if (foreignStamp(hit, rec)) {
|
|
613
|
+
gates.push(`existing pane ${hit.paneOwner}:${hit.pane} is owned by another unsnooze — left to it, dispatch counts it as a failed attempt`);
|
|
614
|
+
return { ...base, action: 'retry', target: { pane: hit.pane } };
|
|
615
|
+
}
|
|
616
|
+
if (hit.status === 'working') {
|
|
617
|
+
gates.push(`existing pane ${hit.paneOwner}:${hit.pane} holds this session and herdr reports it working — deferring`);
|
|
618
|
+
return { ...base, action: 'busy', target: { pane: hit.pane } };
|
|
619
|
+
}
|
|
620
|
+
gates.push(`found existing pane ${hit.paneOwner}:${hit.pane} by session id — would resume there if idle (no new tab)`);
|
|
621
|
+
return { ...base, action: 'inject', target: { pane: hit.pane, session: hit.paneOwner }, message };
|
|
622
|
+
}
|
|
601
623
|
const target = await reviveTarget(mux, rec);
|
|
602
624
|
// The same argv reopen() builds: on a backend with no pane the prompt rides
|
|
603
625
|
// in argv, and for codex the command itself changes (`exec resume`).
|
|
@@ -609,12 +631,113 @@ export async function planFor(rec, {
|
|
|
609
631
|
};
|
|
610
632
|
}
|
|
611
633
|
|
|
634
|
+
// An existing pane that already holds this conversation, found by agent
|
|
635
|
+
// session id (never by cwd). Only multiplexers that expose findSessionPanes
|
|
636
|
+
// (herdr) can answer; a record with no recorded mux is also tried against herdr
|
|
637
|
+
// when it is installed, since transcript detection never learns the pane —
|
|
638
|
+
// unless its origin is a GUI app, which herdr cannot be hosting.
|
|
639
|
+
const GUI_ORIGIN = /desktop|vscode|jetbrains|ide/i;
|
|
640
|
+
|
|
641
|
+
function existingPaneFinder(rec, mux) {
|
|
642
|
+
if (!rec.sessionId) return null;
|
|
643
|
+
if (typeof mux?.findSessionPanes === 'function') return mux;
|
|
644
|
+
// Transcript records carry the default backend's name but no pane.
|
|
645
|
+
if (rec.mux && rec.mux !== 'herdr' && rec.pane) return null;
|
|
646
|
+
if (rec.origin && GUI_ORIGIN.test(rec.origin)) return null;
|
|
647
|
+
try {
|
|
648
|
+
const herdr = getMultiplexer('herdr');
|
|
649
|
+
return herdr.available() ? herdr : null;
|
|
650
|
+
} catch { return null; }
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async function lookupExistingPane(rec, agent, mux) {
|
|
654
|
+
const finder = existingPaneFinder(rec, mux);
|
|
655
|
+
if (!finder) return { finder: null, matches: [] };
|
|
656
|
+
try {
|
|
657
|
+
return { finder, matches: await finder.findSessionPanes(rec.sessionId, { agent: agent.id }) };
|
|
658
|
+
} catch (err) {
|
|
659
|
+
log(`${rec.key}: existing-pane lookup failed: ${err.message}`);
|
|
660
|
+
return { finder, matches: [], failed: err };
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// A pane stamped by a different unsnooze owner is tracked (and will be
|
|
665
|
+
// resumed) by that owner: another launcher on this box, or a fleet host.
|
|
666
|
+
function foreignStamp(hit, rec) {
|
|
667
|
+
return Boolean(hit.stamp) && hit.stamp !== rec.leaseId;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function giveUpOnExistingPane(rec, reason, notifier = notify) {
|
|
671
|
+
if (!transitionStopEpisode(rec, 'failed', {
|
|
672
|
+
lastError: reason, verifyRetries: 0, resumeEpisodeAt: null,
|
|
673
|
+
}, { expect: ['stopped'] })) return 'stale';
|
|
674
|
+
log(`${rec.key}: ${reason}`);
|
|
675
|
+
notifier('unsnooze needs you ⚠️', `${rec.cwd}: ${reason}`, { context: ctxOf(rec), priority: 4 });
|
|
676
|
+
return 'skipped';
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Point the stop at the pane that already holds the conversation so the normal
|
|
680
|
+
// live-pane gates (busy, menu, idle prompt, ownership) decide what happens.
|
|
681
|
+
// Adopted only when the pane carries no unsnooze stamp or this record's own;
|
|
682
|
+
// identity is then the exact agent session id plus the agent still running in
|
|
683
|
+
// the pane (a shell is refused downstream). Returns { rec, mux } to resume
|
|
684
|
+
// into, a dispatch outcome when this stop ends here, or null to reopen.
|
|
685
|
+
async function adoptExistingPane(rec, agent, mux, notifier) {
|
|
686
|
+
const { finder, matches, failed } = await lookupExistingPane(rec, agent, mux);
|
|
687
|
+
if (failed) {
|
|
688
|
+
// Charged as an attempt, so a herdr that keeps failing reaches the normal
|
|
689
|
+
// give-up path instead of silently holding the record.
|
|
690
|
+
if (!transitionStopEpisode(rec, 'stopped', {
|
|
691
|
+
lastError: `existing-pane lookup: ${failed.message}`,
|
|
692
|
+
}, { expect: ['stopped'] })) return { outcome: 'stale' };
|
|
693
|
+
return { outcome: 'retry' };
|
|
694
|
+
}
|
|
695
|
+
if (matches.length === 0) return null;
|
|
696
|
+
if (matches.length > 1) {
|
|
697
|
+
const panes = matches.map(m => `${m.paneOwner}:${m.pane}`).join(', ');
|
|
698
|
+
return { outcome: giveUpOnExistingPane(rec,
|
|
699
|
+
`found ${matches.length} herdr panes for this session (${panes}) — not guessing, resume it by hand`, notifier) };
|
|
700
|
+
}
|
|
701
|
+
const [hit] = matches;
|
|
702
|
+
if (foreignStamp(hit, rec)) {
|
|
703
|
+
// Not typed into, and charged as an attempt: if that owner never resumes
|
|
704
|
+
// it, the normal give-up path still ends the stop.
|
|
705
|
+
if (!transitionStopEpisode(rec, 'stopped', {
|
|
706
|
+
lastError: `existing pane ${hit.paneOwner}:${hit.pane} is owned by another unsnooze — left to it`,
|
|
707
|
+
}, { expect: ['stopped'] })) return { outcome: 'stale' };
|
|
708
|
+
return { outcome: 'retry' };
|
|
709
|
+
}
|
|
710
|
+
// herdr's own agent status catches a busy agent the screen scrape can miss.
|
|
711
|
+
if (hit.status === 'working') return { outcome: 'busy' };
|
|
712
|
+
// Already pointed at it and it was not safe to resume: a new tab would clone it.
|
|
713
|
+
if (hit.pane === rec.pane && hit.paneOwner === rec.paneOwner) {
|
|
714
|
+
return { outcome: giveUpOnExistingPane(rec,
|
|
715
|
+
`herdr pane ${hit.paneOwner}:${hit.pane} holds this session but is not safe to type into — resume it there by hand`, notifier) };
|
|
716
|
+
}
|
|
717
|
+
const cutoff = stopEpisodeAt(rec);
|
|
718
|
+
let adopted = null;
|
|
719
|
+
updateState(state => {
|
|
720
|
+
const current = state.sessions[rec.key];
|
|
721
|
+
if (current?.status !== 'stopped' || stopEpisodeAt(current) !== cutoff) return state;
|
|
722
|
+
Object.assign(current, {
|
|
723
|
+
mux: 'herdr', pane: hit.pane, paneOwner: hit.paneOwner, muxSession: hit.paneOwner,
|
|
724
|
+
leaseId: hit.stamp,
|
|
725
|
+
});
|
|
726
|
+
adopted = { ...current };
|
|
727
|
+
return state;
|
|
728
|
+
});
|
|
729
|
+
if (!adopted) return null;
|
|
730
|
+
log(`${rec.key}: found existing herdr pane ${hit.paneOwner}:${hit.pane} by session id — resuming there`);
|
|
731
|
+
return { rec: adopted, mux: finder.bind ? finder.bind(hit.paneOwner) : finder };
|
|
732
|
+
}
|
|
733
|
+
|
|
612
734
|
// Ordered safety decision. Returns: already-resumed | busy | retry | progress |
|
|
613
735
|
// held | injected | reopen | probe.
|
|
614
736
|
export async function dispatchOne(rec, {
|
|
615
737
|
mux = resolveRecordMux(rec), resolveMux = null,
|
|
616
738
|
resumeMessage, selfCmd = selfCommand(), fingerprint = workspaceFingerprint,
|
|
617
739
|
notifier = notify, matchesLease = leaseMatches, contextTokens = null,
|
|
740
|
+
adoptExisting = true, adopted = false,
|
|
618
741
|
} = {}) {
|
|
619
742
|
resolveMux ||= () => mux;
|
|
620
743
|
const key = rec.key;
|
|
@@ -626,6 +749,8 @@ export async function dispatchOne(rec, {
|
|
|
626
749
|
// explicit option → per-agent (`resumeMessages.<id>`) → global. Applies to
|
|
627
750
|
// both the live-pane sendText and the argv reopen path.
|
|
628
751
|
resumeMessage = rec.resumeMessage ?? resumeMessage ?? resolveResumeMessage(agent.id);
|
|
752
|
+
// Guards below suffix the message; an adopted-pane retry re-runs them from the original.
|
|
753
|
+
const baseMessage = resumeMessage;
|
|
629
754
|
|
|
630
755
|
// §4: fallback records probe cheaply before a real resume attempt.
|
|
631
756
|
if (rec.resetSource === 'fallback' && !rec.manual) {
|
|
@@ -677,12 +802,40 @@ export async function dispatchOne(rec, {
|
|
|
677
802
|
const notifyContext = () => {
|
|
678
803
|
if (contextNote) notifier('unsnooze: big-context wake', contextNote, { context: ctxOf(rec) });
|
|
679
804
|
};
|
|
680
|
-
|
|
681
|
-
|
|
805
|
+
let a = null;
|
|
806
|
+
// Before opening a new tab, look for the conversation's own pane. The
|
|
807
|
+
// retry re-enters with adoptExisting off so it cannot loop.
|
|
808
|
+
const reopenGuarded = async () => {
|
|
809
|
+
// An adopted pane that is not safe to type into (a shell, a refused menu)
|
|
810
|
+
// still holds the conversation: a new tab would clone it.
|
|
811
|
+
if (adopted) {
|
|
812
|
+
if (!a?.alive) {
|
|
813
|
+
// Closed between lookup and assessment: retry, the next lookup decides.
|
|
814
|
+
if (!transitionStopEpisode(rec, 'stopped', {
|
|
815
|
+
lastError: `existing pane ${rec.paneOwner}:${rec.pane} closed before resume`,
|
|
816
|
+
}, { expect: ['stopped'] })) return 'stale';
|
|
817
|
+
return 'retry';
|
|
818
|
+
}
|
|
819
|
+
const why = agent.isForegroundCommand(a.cmd) ? 'is not safe to type into' : 'no longer runs the agent';
|
|
820
|
+
return giveUpOnExistingPane(rec,
|
|
821
|
+
`herdr pane ${rec.paneOwner}:${rec.pane} holds this session but ${why} — resume it there by hand`, notifier);
|
|
822
|
+
}
|
|
823
|
+
if (adoptExisting) {
|
|
824
|
+
const found = await adoptExistingPane(rec, agent, mux, notifier);
|
|
825
|
+
if (found?.outcome) return found.outcome;
|
|
826
|
+
if (found) {
|
|
827
|
+
return dispatchOne(found.rec, {
|
|
828
|
+
mux: found.mux, resolveMux, resumeMessage: baseMessage, selfCmd,
|
|
829
|
+
fingerprint, notifier, matchesLease, contextTokens, adoptExisting: false, adopted: true,
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
return reopen(rec, { mux, resolveMux, agent, resumeMessage, selfCmd, onDelivered: notifyContext });
|
|
834
|
+
};
|
|
682
835
|
|
|
683
836
|
// Live-pane path: classification comes from the shared assessPane (planFor
|
|
684
837
|
// narrates the identical assessment); dispatch owns every side effect.
|
|
685
|
-
|
|
838
|
+
a = await assessPane(rec, agent, { mux, matchesLease });
|
|
686
839
|
if (a.alive) {
|
|
687
840
|
if (a.captureError) {
|
|
688
841
|
if (!transitionStopEpisode(rec, 'stopped', {
|
|
@@ -999,6 +1152,22 @@ export async function verifyOne(key, { resolveMux = resolveRecordMux } = {}) {
|
|
|
999
1152
|
: outcome.signal ? `killed by ${outcome.signal}` : 'failed to start';
|
|
1000
1153
|
const why = outcome.output || outcome.error || '';
|
|
1001
1154
|
const lastError = `revive died before it could resume (${how}${why ? `: ${why}` : ''})`;
|
|
1155
|
+
// Some refusals are structural: the same launch fails the same way on
|
|
1156
|
+
// every attempt (Codex Desktop still holding the thread, #49). Retrying
|
|
1157
|
+
// them only burns the attempts and ends in a vague give-up, so stop now
|
|
1158
|
+
// and say what the user has to do.
|
|
1159
|
+
const refusal = (agent.reviveRefusals || []).find(r => r.pattern.test(why));
|
|
1160
|
+
if (refusal) {
|
|
1161
|
+
if (!transitionStopEpisode(rec, 'failed', {
|
|
1162
|
+
attempts, lastError: `${refusal.reason} (${how})`, verifyRetries: 0, resumeEpisodeAt: null,
|
|
1163
|
+
}, { expect: ['resuming'] })) {
|
|
1164
|
+
releaseSupersededResume(key);
|
|
1165
|
+
return 'stale';
|
|
1166
|
+
}
|
|
1167
|
+
log(`${key}: ${lastError} — not retrying: ${refusal.reason}`);
|
|
1168
|
+
notify('unsnooze can\'t resume this one ⚠️', `${rec.cwd}: ${refusal.reason}`, { context: ctxOf(rec), priority: 4 });
|
|
1169
|
+
return 'held';
|
|
1170
|
+
}
|
|
1002
1171
|
const applied = transitionStopEpisode(rec, 'stopped', {
|
|
1003
1172
|
attempts,
|
|
1004
1173
|
// Same backoff (and same manual exemption) as every other retry.
|
package/src/settings.js
CHANGED
|
@@ -34,6 +34,7 @@ export const DEFAULTS = {
|
|
|
34
34
|
// Pre-wall usage warnings (1.13): daemon notifies at % bands + ETA tiers.
|
|
35
35
|
usageWarn: 'notify', // off | notify
|
|
36
36
|
usageWarnAt: '80,95', // % thresholds (comma-separated); garbage → default
|
|
37
|
+
autoDashboard: true, // status/usage/sessions open dashboard on a TTY
|
|
37
38
|
mouse: true, // dashboard mouse support (click tabs/rows, wheel scroll)
|
|
38
39
|
// Opt-in: auto-close `resumed` panes idle longer than reapIdleAfter (ms).
|
|
39
40
|
// Off by default — an idle revived TUI is indistinguishable from one the
|
|
@@ -84,6 +85,7 @@ const ENV_NAMES = {
|
|
|
84
85
|
contextGuardTokens: 'UNSNOOZE_CONTEXT_GUARD_TOKENS',
|
|
85
86
|
usageWarn: 'UNSNOOZE_USAGE_WARN',
|
|
86
87
|
usageWarnAt: 'UNSNOOZE_USAGE_WARN_AT',
|
|
88
|
+
autoDashboard: 'UNSNOOZE_AUTO_DASHBOARD',
|
|
87
89
|
mouse: 'UNSNOOZE_MOUSE',
|
|
88
90
|
reapResumed: 'UNSNOOZE_REAP_RESUMED',
|
|
89
91
|
reapIdleAfter: 'UNSNOOZE_REAP_IDLE_AFTER',
|
package/src/update-check.js
CHANGED
|
@@ -247,16 +247,33 @@ export function installPrefix({ root = ROOT, platform = process.platform, exists
|
|
|
247
247
|
return exists(path.join(prefix, 'bin', 'unsnooze')) ? prefix : null;
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
+
// How to run npm. On Windows `npm` is `npm.cmd`, which spawnSync cannot run
|
|
251
|
+
// without a shell (bare `npm` is ENOENT, `npm.cmd` is EINVAL since Node's
|
|
252
|
+
// CVE-2024-27980 fix, #49). Run the npm-cli.js that ships beside node.exe
|
|
253
|
+
// directly instead: no shell, so a prefix with spaces needs no quoting. A
|
|
254
|
+
// node without a bundled npm falls back to `npm.cmd` through the shell.
|
|
255
|
+
export function npmCommand({ platform = process.platform, execPath = process.execPath, exists = existsSync } = {}) {
|
|
256
|
+
if (platform !== 'win32') return { file: 'npm', pre: [], opts: {} };
|
|
257
|
+
const cli = win32.join(win32.dirname(execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js');
|
|
258
|
+
if (exists(cli)) return { file: execPath, pre: [cli], opts: {} };
|
|
259
|
+
return { file: 'npm.cmd', pre: [], opts: { shell: true }, quote: true };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function runNpm(args, opts = {}, { runner = spawnSync, npm = npmCommand() } = {}) {
|
|
263
|
+
const argv = npm.quote ? args.map(a => (/[\s"]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)) : args;
|
|
264
|
+
return runner(npm.file, [...npm.pre, ...argv], { ...npm.opts, ...opts });
|
|
265
|
+
}
|
|
266
|
+
|
|
250
267
|
// `unsnooze update` — self-update via npm, then show what changed. With
|
|
251
268
|
// --prefix pointed at this copy's own prefix, npm -g overwrites this install
|
|
252
269
|
// in place, so re-reading package.json/CHANGELOG.md after a successful
|
|
253
270
|
// install yields the NEW version's info.
|
|
254
|
-
export function runSelfUpdate({ runner = spawnSync, print = console.log, root = ROOT, current = PKG_VERSION } = {}) {
|
|
271
|
+
export function runSelfUpdate({ runner = spawnSync, print = console.log, root = ROOT, current = PKG_VERSION, npm = npmCommand() } = {}) {
|
|
255
272
|
const prefix = installPrefix({ root });
|
|
256
273
|
const args = ['install', '-g', ...(prefix ? ['--prefix', prefix] : []), 'unsnooze@latest'];
|
|
257
274
|
const command = ['npm', ...args].map(a => (/\s/.test(a) ? `"${a}"` : a)).join(' ');
|
|
258
275
|
print(`unsnooze ${current} — updating via ${command} …`);
|
|
259
|
-
const r =
|
|
276
|
+
const r = runNpm(args, { stdio: 'inherit' }, { runner, npm });
|
|
260
277
|
if (r.error || r.status !== 0) {
|
|
261
278
|
print(`unsnooze: update failed (${r.error ? r.error.message : `npm exited ${r.status}`}).`);
|
|
262
279
|
print(`unsnooze: try it manually: ${command}`);
|
package/src/usage.js
CHANGED
|
@@ -17,7 +17,7 @@ import { readState, updateState } from './state.js';
|
|
|
17
17
|
import { getConfig } from './settings.js';
|
|
18
18
|
import { ROLLOUT_RE, rolloutId } from './agents/codex.js';
|
|
19
19
|
import { shouldUseTui, formatUsageTui, bar as tuiBar } from './tui.js';
|
|
20
|
-
import { shouldUseDashboard
|
|
20
|
+
import { shouldUseDashboard } from './dashboard/gate.js';
|
|
21
21
|
|
|
22
22
|
// ---------------------------------------------------------------------------
|
|
23
23
|
// Constants & pure math
|
|
@@ -1441,7 +1441,10 @@ export async function cmdUsage(args = []) {
|
|
|
1441
1441
|
|
|
1442
1442
|
const asJson = args.includes('--json');
|
|
1443
1443
|
// Interactive TTY (human path) → live dashboard Usage tab
|
|
1444
|
-
if (!asJson && shouldUseDashboard())
|
|
1444
|
+
if (!asJson && shouldUseDashboard()) {
|
|
1445
|
+
const { runDashboard } = await import('./dashboard/run.js');
|
|
1446
|
+
return runDashboard({ tab: 'usage' });
|
|
1447
|
+
}
|
|
1445
1448
|
try {
|
|
1446
1449
|
const now = Date.now();
|
|
1447
1450
|
const state = readState();
|
package/src/watchers/codex.js
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
|
|
33
33
|
import { openSync, readSync, closeSync, statSync } from 'node:fs';
|
|
34
34
|
import { basename } from 'node:path';
|
|
35
|
-
import { ROLLOUT_RE, rolloutPathFor, patterns as codexPatterns } from '../agents/codex.js';
|
|
35
|
+
import { ROLLOUT_RE, rolloutPathFor, subagentParent, patterns as codexPatterns } from '../agents/codex.js';
|
|
36
36
|
import { detectLimit } from '../patterns.js';
|
|
37
37
|
// Usage extractor lives in usage.js (shared cold path + daemon); re-exported
|
|
38
38
|
// here so the plan's watcher surface is the documented import site.
|
|
@@ -332,7 +332,9 @@ function readFirstLine(path, maxBytes = 256 * 1024) {
|
|
|
332
332
|
}
|
|
333
333
|
|
|
334
334
|
// Session identity for a rollout file: the session_meta head line when
|
|
335
|
-
// parseable, else the uuid embedded in the filename.
|
|
335
|
+
// parseable, else the uuid embedded in the filename. A sub-agent's rollout
|
|
336
|
+
// resolves to its parent thread, so its stop merges with the conversation's
|
|
337
|
+
// own record instead of tracking a second, unresumable one (#50).
|
|
336
338
|
export function rolloutMeta(path) {
|
|
337
339
|
let sessionId = null;
|
|
338
340
|
let cwd = null;
|
|
@@ -340,7 +342,7 @@ export function rolloutMeta(path) {
|
|
|
340
342
|
try {
|
|
341
343
|
const meta = JSON.parse(readFirstLine(path));
|
|
342
344
|
if (meta?.type === 'session_meta') {
|
|
343
|
-
sessionId = meta.payload?.id || null;
|
|
345
|
+
sessionId = subagentParent(meta.payload) || meta.payload?.id || null;
|
|
344
346
|
cwd = meta.payload?.cwd || null;
|
|
345
347
|
originator = meta.payload?.originator || null;
|
|
346
348
|
}
|