unsnooze 1.20.1 → 1.21.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/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.21.0 — 2026-10-24
4
+
5
+ Codex sub-agents no longer show up as a second, unresumable session, a thread
6
+ Codex Desktop still holds fails once with a reason instead of five times,
7
+ `unsnooze update` works on Windows, and the dashboard can stay out of the way.
8
+
9
+ ### Codex
10
+
11
+ - A Codex session that spawns multi-agent sub-agents was tracked twice per
12
+ limit stop: once for the conversation, and once for the sub-agent's own
13
+ rollout, which Codex refuses to resume. That second record burned five
14
+ attempts and sent a "gave up" notification on every stop. A sub-agent's stop
15
+ is now recorded under its parent thread, so one stop is one record (#50).
16
+ - A headless revive of a thread that Codex Desktop still has open fails with
17
+ "already has an active writer" no matter how often it is retried. unsnooze
18
+ now stops after the first attempt and tells you to continue the thread in
19
+ Codex Desktop, or close it there so unsnooze can take over (#49).
20
+ - "Selected model is at capacity" is retried as an overload, like a stream
21
+ error, instead of leaving the session sitting at the prompt (#46).
22
+
23
+ ### Dashboard
24
+
25
+ - `unsnooze config set autoDashboard off` (or `UNSNOOZE_AUTO_DASHBOARD=off`)
26
+ keeps `status`, `usage` and `sessions` in their plain terminal view on a
27
+ TTY. `unsnooze dashboard` still opens the full dashboard (#54).
28
+ - The daemon and watchers no longer load React and Ink unless the dashboard
29
+ actually opens, roughly halving their memory use.
30
+
31
+ ### Install and update
32
+
33
+ - A shell rc file that is a symlink into a dotfiles repository stays a symlink:
34
+ install, update and uninstall edit the file it points to, and the backups
35
+ sit next to that file. This also fixes `EPERM` on a link with macOS `uchg`
36
+ set (#51, #53).
37
+ - `unsnooze update` and `unsnooze doctor` failed on Windows with
38
+ `spawnSync npm ENOENT`. They now run npm in a way Windows accepts (#49).
39
+
3
40
  ## 1.20.1 — 2026-10-08
4
41
 
5
42
  `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.20.1",
3
+ "version": "1.21.0",
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",
@@ -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" and must take
11
- // the overload path, never the ledger.
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
- if (!needle || fileHead(f.path).includes(needle)) return f.id;
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, runDashboard } from './dashboard/run.js';
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()) return runDashboard({ tab: 'status' });
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()) return runDashboard({ tab: 'sessions' });
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
+ }
@@ -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
- // Duplicated from cli.js's shortenHome rather than imported: cli.js pulls in
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 = runner('npm', ['root', '-g']);
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
- mkdirSync(dirname(path), { recursive: true }); // e.g. ~/.claude may not exist yet
57
- const tmp = join(dirname(path), `.${Date.now()}.tmp`);
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, path);
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 orig = `${path}.unsnooze-orig`;
67
- if (!existsSync(orig)) copyFileSync(path, orig);
68
- copyFileSync(path, `${path}.unsnooze-bak`);
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: ${opts.settings}.unsnooze-bak)`);
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`);
package/src/resumer.js CHANGED
@@ -999,6 +999,22 @@ export async function verifyOne(key, { resolveMux = resolveRecordMux } = {}) {
999
999
  : outcome.signal ? `killed by ${outcome.signal}` : 'failed to start';
1000
1000
  const why = outcome.output || outcome.error || '';
1001
1001
  const lastError = `revive died before it could resume (${how}${why ? `: ${why}` : ''})`;
1002
+ // Some refusals are structural: the same launch fails the same way on
1003
+ // every attempt (Codex Desktop still holding the thread, #49). Retrying
1004
+ // them only burns the attempts and ends in a vague give-up, so stop now
1005
+ // and say what the user has to do.
1006
+ const refusal = (agent.reviveRefusals || []).find(r => r.pattern.test(why));
1007
+ if (refusal) {
1008
+ if (!transitionStopEpisode(rec, 'failed', {
1009
+ attempts, lastError: `${refusal.reason} (${how})`, verifyRetries: 0, resumeEpisodeAt: null,
1010
+ }, { expect: ['resuming'] })) {
1011
+ releaseSupersededResume(key);
1012
+ return 'stale';
1013
+ }
1014
+ log(`${key}: ${lastError} — not retrying: ${refusal.reason}`);
1015
+ notify('unsnooze can\'t resume this one ⚠️', `${rec.cwd}: ${refusal.reason}`, { context: ctxOf(rec), priority: 4 });
1016
+ return 'held';
1017
+ }
1002
1018
  const applied = transitionStopEpisode(rec, 'stopped', {
1003
1019
  attempts,
1004
1020
  // 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',
@@ -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 = runner('npm', args, { stdio: 'inherit' });
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, runDashboard } from './dashboard/run.js';
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()) return runDashboard({ tab: 'usage' });
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();
@@ -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
  }