unsnooze 1.16.1 → 1.16.3

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,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.3 — 2026-08-22
4
+
5
+ - **A dashboard left open overnight no longer runs the machine out of memory.**
6
+ ([#16](https://github.com/saaranshM/unsnooze/issues/16)) `unsnooze status` in
7
+ a spare pane is exactly what it is for, and after nine or ten hours it was
8
+ dying on *"FATAL ERROR: Ineffective mark-compacts near heap limit — JavaScript
9
+ heap out of memory"*. The dashboard itself was innocent: React's development
10
+ build files a `performance.measure()` entry for every render, commit and
11
+ `setState` — the performance track it publishes for devtools — and Node
12
+ buffers user-timing entries forever with nothing to drain them. The status tab
13
+ repaints about three times a second (a one-second data tick plus the
14
+ 450ms logo animation), each repaint left roughly 40 entries behind, and a
15
+ night of that walks into Node's 2GB heap ceiling. Measured at ~12kB retained
16
+ per render, surviving a forced garbage collection. The dashboard now drains
17
+ that buffer while it is mounted; heap use went from climbing without limit to
18
+ flat. Nothing else in unsnooze records user timing, and no other command
19
+ renders long enough to have been affected.
20
+
21
+ - **Every release now creates its GitHub release automatically.** They were
22
+ written by hand afterwards, so 1.16.2 shipped to npm and to the website while
23
+ the repository's Releases tab still showed 1.16.1. The publish workflow now
24
+ creates the release from that version's CHANGELOG section, and waits for the
25
+ npm registry to actually serve the new version before rebuilding the website —
26
+ `npm publish` returns before the registry does, which is why 1.16.2's own
27
+ changelog entry was briefly invisible on unsnooze.dev.
28
+
29
+ ## 1.16.2 — 2026-08-22
30
+
31
+ - **A herdr revival now opens a tab in your project's workspace, not a new
32
+ workspace of its own.** ([#15](https://github.com/saaranshM/unsnooze/issues/15))
33
+ `newWindow` means "one more terminal for this work" — a window in tmux, a tab
34
+ in Zellij — and it was mapped to `herdr workspace create`. But a herdr
35
+ workspace is the *project*: it is per repo, the way a tmux session is. So
36
+ every reset left another top-level workspace for the same repo, and a week of
37
+ them was a sidebar full of duplicates. unsnooze now asks herdr which
38
+ workspace is already open on that directory and adds a tab there, creating a
39
+ workspace only when there is genuinely none — a session it just started, or a
40
+ directory herdr has nothing open on. A tab rather than a split, because a
41
+ revived agent draws a full-screen TUI and halving the pane you are looking at
42
+ would leave two terminals too narrow to use; neither takes focus away from
43
+ what you are doing.
44
+
3
45
  ## 1.16.1 — 2026-08-16
4
46
 
5
47
  - **`unsnooze status` no longer offers an attach command for headless sessions.**
package/README.md CHANGED
@@ -751,7 +751,7 @@ explicitly with `unsnooze config set multiplexer tmux`, `zellij`, or `herdr`.
751
751
 
752
752
  #### herdr specifics
753
753
 
754
- herdr differs from tmux and Zellij in three ways that are visible in use:
754
+ herdr differs from tmux and Zellij in four ways that are visible in use:
755
755
 
756
756
  - **unsnooze never restarts a stopped herdr session.** herdr restores saved
757
757
  agent panes when a session restarts (`resume_agents_on_restore` is on by
@@ -761,10 +761,17 @@ herdr differs from tmux and Zellij in three ways that are visible in use:
761
761
  reusing the name. If you restart a stopped session yourself and herdr brings
762
762
  the agent back, unsnooze may still open a fresh pane for it; that case is not
763
763
  yet detected.
764
+ - **A revived session opens a tab, not a workspace.** A herdr workspace is the
765
+ project — per repo, the way a tmux *session* is — while a tab is what tmux
766
+ calls a window. So a revival opens a new tab in whichever workspace herdr
767
+ already has open on that directory, and only creates a workspace when there
768
+ is none. (A tab rather than a split: a revived agent draws a full-screen TUI,
769
+ and halving the pane you are looking at would leave two terminals too narrow
770
+ to use. Neither steals focus.)
764
771
  - **Multi-line resume messages are carried, not typed.** herdr starts a pane
765
772
  command by typing it into the pane's shell, where a newline would submit half
766
773
  a command and a tab would be a completion request. Any such argument is passed
767
- through the workspace environment instead and referenced by the typed line, so
774
+ through the new tab's environment instead and referenced by the typed line, so
768
775
  a multi-paragraph `resumeMessage` reaches the agent as one argument, byte for
769
776
  byte. (A NUL byte is still refused — an environment value cannot hold one.)
770
777
  - **A custom `HERDR_SOCKET_PATH` that does not match the session being
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.16.1",
3
+ "version": "1.16.3",
4
4
  "description": "Automatically resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode, and Antigravity 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",
@@ -62,6 +62,41 @@ export function installMouseCleanup(stdout = process.stdout) {
62
62
  };
63
63
  }
64
64
 
65
+ // React's development build records a `performance.measure()` entry for every
66
+ // render, commit and setState — the React Performance Track it publishes for
67
+ // devtools. Node buffers user-timing entries and nothing ever drains that
68
+ // buffer, so on a long-lived dashboard it IS the leak: the status tab repaints
69
+ // roughly three times a second (a 1s data tick plus the 450ms logo animation),
70
+ // each repaint leaves ~40 entries behind, and an overnight `unsnooze status`
71
+ // walked into Node's 2GB heap ceiling and aborted (#16). Measured at ~12 kB
72
+ // retained per render, surviving a forced GC.
73
+ //
74
+ // The entries cannot be capped (Node caps resource timing, not user timing) and
75
+ // switching React to its production build would mean setting NODE_ENV for the
76
+ // whole CLI before anything imports React. Draining is the narrow fix: nothing
77
+ // in unsnooze reads user timing, and the sweep lives exactly as long as the
78
+ // render does.
79
+ export const TIMING_SWEEP_MS = 5_000;
80
+
81
+ export function sweepReactTiming(perf = globalThis.performance) {
82
+ try {
83
+ perf?.clearMeasures?.();
84
+ perf?.clearMarks?.();
85
+ } catch { /* a runtime with no user timing has nothing to leak */ }
86
+ }
87
+
88
+ // Returns the stop function. The timer is unref'd: draining a buffer is never
89
+ // a reason to hold the process open.
90
+ export function startTimingSweep({ intervalMs = TIMING_SWEEP_MS, perf } = {}) {
91
+ sweepReactTiming(perf);
92
+ const timer = setInterval(() => sweepReactTiming(perf), intervalMs);
93
+ timer.unref?.();
94
+ return () => {
95
+ clearInterval(timer);
96
+ sweepReactTiming(perf);
97
+ };
98
+ }
99
+
65
100
  export function shouldUseDashboard({
66
101
  force = null,
67
102
  json = false,
@@ -87,13 +122,18 @@ export async function runDashboard({ tab = 'status' } = {}) {
87
122
  }
88
123
 
89
124
  installMouseCleanup(process.stdout);
90
- const instance = render(h(App, { initialTab: tab, mouseEnabled: getConfig('mouse') !== false }), {
91
- exitOnCtrlC: true,
92
- // Full-screen: separate buffer like vim / htop / less — original scrollback restored on quit
93
- alternateScreen: true,
94
- });
125
+ const stopTimingSweep = startTimingSweep();
126
+ try {
127
+ const instance = render(h(App, { initialTab: tab, mouseEnabled: getConfig('mouse') !== false }), {
128
+ exitOnCtrlC: true,
129
+ // Full-screen: separate buffer like vim / htop / less — original scrollback restored on quit
130
+ alternateScreen: true,
131
+ });
95
132
 
96
- await instance.waitUntilExit();
133
+ await instance.waitUntilExit();
134
+ } finally {
135
+ stopTimingSweep();
136
+ }
97
137
  // Belt and braces: normal quit also clears modes (provider already did).
98
138
  process.stdout.write(MOUSE_DISABLE_ALL);
99
139
  return 0;
@@ -2,7 +2,7 @@
2
2
  // remains alive with its shell prompt. The resumer must therefore verify
3
3
  // agent ownership before injecting, and revival can reuse a surviving pane
4
4
  // only when Task 3 wires that path deliberately.
5
- // Launch env is delivered via workspace create --env. Keep this narrow because
5
+ // Launch env is delivered via tab/workspace create --env. Keep this narrow because
6
6
  // pane run types argv into the pane shell, but preserve the two Claude config
7
7
  // roots: desktop/isolated Claude sessions cannot be resumed without them.
8
8
 
@@ -276,6 +276,66 @@ export function createHerdr({ spawner = defaultSpawner, env = process.env } = {}
276
276
  return syncOutput(result);
277
277
  };
278
278
 
279
+ const listPaneRows = async session => {
280
+ const result = parseResult(await inSession(session, 'pane', 'list'));
281
+ return Array.isArray(result?.panes) ? result.panes : [];
282
+ };
283
+
284
+ const samePath = (a, b) => {
285
+ if (typeof a !== 'string' || typeof b !== 'string' || !a || !b) return false;
286
+ const trim = value => (value.length > 1 ? value.replace(/\/+$/, '') : value);
287
+ return trim(a) === trim(b);
288
+ };
289
+
290
+ // The workspace herdr already has open on this project, if any.
291
+ //
292
+ // A herdr workspace is the project, not the window: it is where a repo
293
+ // lives, the way a tmux SESSION is, and creating one per revival left a
294
+ // user with N top-level workspaces for one repo (#15). herdr reports no cwd
295
+ // on a workspace, so the project is read off its panes instead — `pane
296
+ // list` walks workspaces in order, so the first match is the oldest
297
+ // workspace on this path, which is the user's own rather than one of ours.
298
+ const workspaceOnProject = async (session, cwd) => {
299
+ try {
300
+ const match = (await listPaneRows(session)).find(pane =>
301
+ samePath(pane?.cwd, cwd) || samePath(pane?.foreground_cwd, cwd));
302
+ return match?.workspace_id ? String(match.workspace_id) : null;
303
+ } catch {
304
+ return null;
305
+ }
306
+ };
307
+
308
+ const rootPaneId = (created, what) => {
309
+ const pane = created?.root_pane?.pane_id;
310
+ if (!pane) throw new Error(`unsnooze: unexpected herdr ${what} shape: no root_pane`);
311
+ return String(pane);
312
+ };
313
+
314
+ // One more full-size terminal for `cwd`, in the workspace that project
315
+ // already occupies. A TAB, not a pane split: `newWindow` means the same
316
+ // thing on every backend — a tmux window, a zellij tab — and a revived
317
+ // agent draws a full-screen TUI, so halving the pane the user is looking at
318
+ // would leave two terminals too narrow to use. Only when herdr has no
319
+ // workspace on this project (a session we just started has none at all)
320
+ // does a new workspace get created.
321
+ const openTerminal = async (session, cwd, envArgs) => {
322
+ const workspace = await workspaceOnProject(session, cwd);
323
+ if (workspace) {
324
+ try {
325
+ return rootPaneId(parseResult(await inSession(
326
+ session, 'tab', 'create', '--workspace', workspace,
327
+ '--cwd', cwd, '--label', 'unsnooze', ...envArgs,
328
+ )), 'tab');
329
+ } catch {
330
+ // The workspace can close between the listing and the create. A
331
+ // revival into a fresh workspace still revives; failing does not.
332
+ }
333
+ }
334
+ return rootPaneId(parseResult(await inSession(
335
+ session, 'workspace', 'create', '--cwd', cwd, '--label', 'unsnooze', ...envArgs,
336
+ )), 'workspace');
337
+ };
338
+
279
339
  const backend = {
280
340
  name: 'herdr',
281
341
  owner,
@@ -477,9 +537,8 @@ export function createHerdr({ spawner = defaultSpawner, env = process.env } = {}
477
537
 
478
538
  async listSessionPanes(sessionName) {
479
539
  try {
480
- const result = parseResult(await inSession(sessionName, 'pane', 'list'));
481
- const panes = Array.isArray(result?.panes) ? result.panes : [];
482
- return panes.map(entry => String(entry.pane_id)).filter(Boolean);
540
+ return (await listPaneRows(sessionName))
541
+ .map(entry => String(entry.pane_id)).filter(Boolean);
483
542
  } catch {
484
543
  return [];
485
544
  }
@@ -515,14 +574,11 @@ export function createHerdr({ spawner = defaultSpawner, env = process.env } = {}
515
574
  }
516
575
  // Build the command first: an argument that cannot be typed (a
517
576
  // multi-line resume message) travels as environment instead, and that
518
- // environment has to be on the workspace before the pane exists.
577
+ // environment has to be on the tab before its pane exists.
519
578
  const { line, env: argEnv } = shellCommand(launchSpec.file, launchSpec.args || []);
520
- const workspace = parseResult(await inSession(
521
- target, 'workspace', 'create', '--cwd', cwd, '--label', 'unsnooze',
522
- ...envFlags({ ...launchSpec?.env, ...argEnv }),
523
- ));
524
- const pane = workspace?.root_pane?.pane_id;
525
- if (!pane) throw new Error('unsnooze: unexpected herdr workspace shape: no root_pane');
579
+ const pane = await openTerminal(
580
+ target, cwd, envFlags({ ...launchSpec?.env, ...argEnv }),
581
+ );
526
582
  // One argument, already quoted: `pane run` joins its operands with
527
583
  // spaces and types them into the pane's shell, so the quoting has to be
528
584
  // ours. It also submits the line itself (PaneSendInput carries