unsnooze 1.12.2 → 1.14.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +286 -22
  3. package/bin/unsnooze.js +52 -3
  4. package/package.json +7 -4
  5. package/src/agents/agy.js +2 -0
  6. package/src/agents/claude.js +2 -0
  7. package/src/agents/codex.js +2 -0
  8. package/src/agents/grok.js +2 -0
  9. package/src/agents/index.js +7 -1
  10. package/src/agents/kimi.js +2 -0
  11. package/src/agents/opencode.js +2 -0
  12. package/src/agents/qwen.js +2 -0
  13. package/src/askpass.js +188 -0
  14. package/src/cli.js +149 -25
  15. package/src/config.js +15 -0
  16. package/src/dashboard/App.js +390 -0
  17. package/src/dashboard/Logo.js +74 -0
  18. package/src/dashboard/components/TextInput.js +63 -0
  19. package/src/dashboard/data.js +143 -0
  20. package/src/dashboard/mark.js +51 -0
  21. package/src/dashboard/mouse-protocol.js +75 -0
  22. package/src/dashboard/mouse.js +125 -0
  23. package/src/dashboard/run.js +108 -0
  24. package/src/dashboard/tabs/DoctorTab.js +35 -0
  25. package/src/dashboard/tabs/FleetTab.js +94 -0
  26. package/src/dashboard/tabs/LogsTab.js +28 -0
  27. package/src/dashboard/tabs/PromptsTab.js +406 -0
  28. package/src/dashboard/tabs/SessionsTab.js +33 -0
  29. package/src/dashboard/tabs/StatusTab.js +58 -0
  30. package/src/dashboard/tabs/UsageTab.js +68 -0
  31. package/src/dashboard/theme.js +32 -0
  32. package/src/doctor.js +22 -16
  33. package/src/fleet.js +927 -0
  34. package/src/hook.js +19 -0
  35. package/src/install.js +9 -0
  36. package/src/monitor.js +14 -0
  37. package/src/prompt-queue.js +561 -0
  38. package/src/prompt.js +411 -0
  39. package/src/remote.js +223 -0
  40. package/src/resumer.js +60 -16
  41. package/src/settings.js +13 -0
  42. package/src/state.js +33 -9
  43. package/src/time-parser.js +15 -1
  44. package/src/tui.js +395 -0
  45. package/src/usage.js +1537 -0
  46. package/src/watcher.js +79 -8
  47. package/src/watchers/claude.js +3 -0
  48. package/src/watchers/codex.js +8 -8
  49. package/src/wizard.js +2 -1
@@ -101,6 +101,8 @@ export default {
101
101
  resumeArgs(sessionId) {
102
102
  return { args: sessionId ? ['--resume', sessionId] : ['--continue'], messageViaPane: true };
103
103
  },
104
+ // v1: every agent launches the bare TUI and gets the prompt typed once idle.
105
+ launchArgs(message) { return { args: [], messageViaPane: true }; },
104
106
  latestSessionId,
105
107
  isForegroundCommand(cmd) {
106
108
  return cmd === 'qwen' || cmd === 'node' || cmd === 'unsnooze';
package/src/askpass.js ADDED
@@ -0,0 +1,188 @@
1
+ // Password source resolvers for fleet auth. Each is dependency-injected
2
+ // (platform/run/env/isTTY/readSecret) so every OS branch is testable
3
+ // anywhere. The SERVICE/ACCOUNT/VAR *names* may appear on argv; the secret
4
+ // itself never does — it only ever comes back on stdout/stdin.
5
+ import { execFileSync, spawnSync } from 'node:child_process';
6
+ import { writeFileSync, chmodSync, mkdirSync, renameSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+
9
+ export class AuthError extends Error {
10
+ constructor(msg) { super(msg); this.name = 'AuthError'; this.needsAuth = true; }
11
+ }
12
+
13
+ function defaultRun(bin, args, opts) {
14
+ return execFileSync(bin, args, { encoding: 'utf-8', ...opts });
15
+ }
16
+
17
+ // Shell-based runner for the `command` source only: entry.cmd is the user's
18
+ // own configured, trusted string (git-credential-helper style, e.g.
19
+ // `credential.helper = !cmd`) — there is no untrusted input crossing this
20
+ // boundary, so shell-execution is safe and lets users write pipelines,
21
+ // quoted args, etc. Node picks /bin/sh on unix, cmd.exe on Windows.
22
+ function defaultShellRun(cmd, opts) {
23
+ const res = spawnSync(cmd, { shell: true, encoding: 'utf-8', ...opts });
24
+ if (res.error) throw res.error;
25
+ // Carry the exit code but NOT the command's stderr: a secret tool that
26
+ // debug-prints the password to stderr must not leak it into an error
27
+ // message. resolveCommand builds its message from `.status` alone.
28
+ if (res.status !== 0) {
29
+ const err = new Error(`command source exited ${res.status}`);
30
+ err.status = res.status;
31
+ throw err;
32
+ }
33
+ return res.stdout;
34
+ }
35
+
36
+ const trimPw = (s) => String(s ?? '').replace(/\r?\n$/, '');
37
+
38
+ export function resolveEnv(entry, { env = process.env } = {}) {
39
+ const v = env[entry.env];
40
+ if (v == null || v === '') throw new AuthError(`env var ${entry.env} is not set`);
41
+ return trimPw(v);
42
+ }
43
+
44
+ // entry.cmd is the user's own trusted command (git-credential-helper style)
45
+ // — shell-executed for flexibility (quoting, pipes), not argv-split.
46
+ export function resolveCommand(entry, { run = defaultShellRun } = {}) {
47
+ const cmd = String(entry.cmd || '').trim();
48
+ if (!cmd) throw new AuthError('empty command source');
49
+ let out;
50
+ try { out = run(cmd, { shell: true }); }
51
+ catch (e) {
52
+ // Build the message from the exit code ONLY — never from e.message, which
53
+ // may embed the command's own stderr (a secret tool debug-printing the
54
+ // password to stderr would otherwise leak it into `hosts test`/logs).
55
+ const code = Number.isInteger(e?.status) ? `exit ${e.status}` : 'could not run';
56
+ throw new AuthError(`command source failed (${code}) — check the --cmd command`);
57
+ }
58
+ const pw = trimPw(out);
59
+ if (!pw) throw new AuthError('command source produced no output');
60
+ return pw;
61
+ }
62
+
63
+ // Built-in keychain support is macOS-only (Keychain via `security`). On
64
+ // Windows and Linux there is no safe, dependency-free built-in retrieval
65
+ // path, so callers are pointed at --source command with a platform-native
66
+ // one-liner (pass/secret-tool/powershell).
67
+ export function resolveKeychain(entry, { platform = process.platform, run = defaultRun } = {}) {
68
+ if (platform === 'darwin') {
69
+ try {
70
+ return trimPw(run('security', ['find-generic-password', '-s', entry.service, '-a', entry.account, '-w']));
71
+ } catch {
72
+ throw new AuthError(`keychain miss for ${entry.service}/${entry.account}`);
73
+ }
74
+ }
75
+ throw new AuthError(
76
+ `no built-in keychain on ${platform} — use --source command (e.g. \`pass show\`, \`secret-tool lookup\`, or \`powershell -Command "..."\`)`,
77
+ );
78
+ }
79
+
80
+ export async function resolvePrompt(entry, { isTTY = process.stdin.isTTY, readSecret } = {}) {
81
+ if (!isTTY) throw new AuthError('prompt source needs an interactive terminal — use env/command/keychain for the daemon');
82
+ return await readSecret(`password for ${entry.dest || 'host'}: `);
83
+ }
84
+
85
+ export async function resolveSecret(entry, deps = {}) {
86
+ switch (entry.source) {
87
+ case 'env': return resolveEnv(entry, deps);
88
+ case 'command': return resolveCommand(entry, deps);
89
+ case 'keychain': return resolveKeychain(entry, deps);
90
+ case 'prompt': return await resolvePrompt(entry, deps);
91
+ default: throw new AuthError(`unknown source ${entry.source}`);
92
+ }
93
+ }
94
+
95
+ // No-echo password read from the controlling terminal. Rejects when stdin is
96
+ // not a TTY (piped/daemon) — the caller must fall back to a stored source.
97
+ // Every rejection is an AuthError (never a bare Error) so resolvePrompt's
98
+ // caller can uniformly treat a failed read as "needs auth", not a crash.
99
+ export function readSecret(prompt, { input = process.stdin, output = process.stderr } = {}) {
100
+ return new Promise((resolve, reject) => {
101
+ if (!input.isTTY) return reject(new AuthError('no terminal for prompt'));
102
+ output.write(prompt);
103
+ let buf = '';
104
+ let settled = false;
105
+ const done = (err, val) => {
106
+ if (settled) return;
107
+ settled = true;
108
+ try { input.setRawMode(false); } catch { /* ignore */ }
109
+ input.pause();
110
+ input.removeListener('data', onData);
111
+ input.removeListener('error', onError);
112
+ output.write('\n');
113
+ err ? reject(err) : resolve(val);
114
+ };
115
+ const onError = (e) => done(e instanceof AuthError ? e : new AuthError(`cannot read password: ${e.message}`));
116
+ const onData = (d) => {
117
+ const s = d.toString('utf8');
118
+ for (const ch of s) {
119
+ if (ch === '\n' || ch === '\r' || ch === '\x04') return done(null, buf);
120
+ if (ch === '\x03') return done(new AuthError('cancelled'));
121
+ if (ch === '\x7f' || ch === '\b') { buf = buf.slice(0, -1); continue; }
122
+ buf += ch;
123
+ }
124
+ };
125
+ input.resume();
126
+ input.setEncoding('utf8');
127
+ try { input.setRawMode(true); } catch (e) { return reject(new AuthError(`cannot read password: ${e.message}`)); }
128
+ input.on('data', onData);
129
+ input.on('error', onError);
130
+ });
131
+ }
132
+
133
+ export async function cmdAskpass(args = []) {
134
+ const host = process.env.UNSNOOZE_ASKPASS_HOST ?? args[0];
135
+ try {
136
+ // C1 part 2: dropping BatchMode for password hosts (sshEnvForHost) means
137
+ // ssh can now reach a yes/no host-key confirmation for an unknown host,
138
+ // not just a password request. OpenSSH >=8.4 sets
139
+ // SSH_ASKPASS_PROMPT=confirm for that case. Never hand back the secret
140
+ // here — fail closed with a hint instead; the user has to `ssh <host>`
141
+ // once manually to accept the host key before password auth can work.
142
+ if (process.env.SSH_ASKPASS_PROMPT === 'confirm') {
143
+ process.stderr.write(`unsnooze _askpass: host key not yet trusted for ${host} — run \`ssh ${host}\` once manually to accept it\n`);
144
+ return 1;
145
+ }
146
+ const { readHosts } = await import('./fleet.js');
147
+ const entry = readHosts()[host];
148
+ if (!entry || entry.auth !== 'password') {
149
+ process.stderr.write(`unsnooze _askpass: no password host ${host}\n`);
150
+ return 1;
151
+ }
152
+ const secret = await resolveSecret(entry, { readSecret });
153
+ process.stdout.write(secret); // bare secret, ssh reads first line — nothing else to stdout
154
+ return 0;
155
+ } catch (e) {
156
+ process.stderr.write(`unsnooze _askpass: ${e.message}\n`);
157
+ return 1;
158
+ }
159
+ }
160
+
161
+ // Provision the file SSH_ASKPASS points at (see design §5c). The helper
162
+ // itself reads the host from UNSNOOZE_ASKPASS_HOST (set in the ssh child env
163
+ // by Task 5) because ssh controls the helper's argv — it passes only the
164
+ // prompt text, never the host.
165
+ export function ensureAskpassHelper({ platform = process.platform, stateDir, nodePath = process.execPath, scriptPath }) {
166
+ mkdirSync(stateDir, { recursive: true });
167
+ if (platform === 'win32') {
168
+ // Native ssh.exe needs a real exe; unix-like win ssh (Git/WSL) accepts a script.
169
+ // The resolution ladder (design §5c) is finalized in Task 7's cross-platform pass;
170
+ // here we write the unix-style .cmd wrapper used by Git-Bash/WSL and MSYS ssh.
171
+ const p = join(stateDir, 'askpass.cmd');
172
+ const tmp = p + `.tmp.${process.pid}`;
173
+ writeFileSync(tmp, `@echo off\r\n"${nodePath}" "${scriptPath}" _askpass %UNSNOOZE_ASKPASS_HOST%\r\n`);
174
+ renameSync(tmp, p); // atomic: never a torn/partial file visible at p
175
+ return p;
176
+ }
177
+ const p = join(stateDir, 'askpass.sh');
178
+ // Write to a temp file then rename over p — a crash/concurrent read never
179
+ // observes a partially-written helper. Mode is applied to the tmp file at
180
+ // creation (writeFileSync's mode option only takes effect on a new file);
181
+ // chmodSync after the rename additionally corrects a pre-existing p left
182
+ // with the wrong mode by an older run.
183
+ const tmp = p + `.tmp.${process.pid}`;
184
+ writeFileSync(tmp, `#!/bin/sh\nexec "${nodePath}" "${scriptPath}" _askpass "$UNSNOOZE_ASKPASS_HOST"\n`, { mode: 0o700 });
185
+ renameSync(tmp, p);
186
+ chmodSync(p, 0o700);
187
+ return p;
188
+ }
package/src/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { spawnSync } from 'node:child_process';
5
+ import { homedir } from 'node:os';
5
6
  import { LOG_FILE, MAX_RESUME_ATTEMPTS } from './config.js';
6
7
  import { readState, setStatus, updateState } from './state.js';
7
8
  import { getAgent } from './agents/index.js';
@@ -11,6 +12,11 @@ import { spawnResumerIfNeeded } from './spawn.js';
11
12
  import { getMultiplexer } from './multiplexer.js';
12
13
  import { listOwnedSessions, reap, attachHint } from './reap.js';
13
14
  import { planFor } from './resumer.js';
15
+ import { queueList } from './prompt-queue.js';
16
+ import {
17
+ shouldUseTui, formatStatusTui, formatSessionsTui, formatPreviewTui, logoBlock,
18
+ } from './tui.js';
19
+ import { shouldUseDashboard, runDashboard } from './dashboard/run.js';
14
20
 
15
21
  function fmtCountdown(ms) {
16
22
  if (ms <= 0) return 'due now';
@@ -20,6 +26,42 @@ function fmtCountdown(ms) {
20
26
  return `${h}h ${m % 60}m`;
21
27
  }
22
28
 
29
+ // Shared by status's cwd column and prompt.js's queue listings — collapse the
30
+ // home dir prefix to '~' the way most shells display it.
31
+ export function shortenHome(p) {
32
+ if (typeof p !== 'string' || !p) return p;
33
+ const home = homedir();
34
+ if (p === home) return '~';
35
+ return p.startsWith(`${home}/`) ? `~${p.slice(home.length)}` : p;
36
+ }
37
+
38
+ // pending/launching are the only non-terminal prompt-queue statuses
39
+ // (delivered/failed/cancelled are terminal — see prompt-queue.js).
40
+ function pendingPromptEntries() {
41
+ return queueList().filter(e => e.status === 'pending' || e.status === 'launching');
42
+ }
43
+
44
+ function fmtQueueDue(e, now) {
45
+ if (Number.isFinite(e.notBefore) && e.notBefore > now) {
46
+ return `backoff until ${new Date(e.notBefore).toLocaleString()}`;
47
+ }
48
+ if (e.mode === 'now') return 'now';
49
+ if (e.mode === 'at') return new Date(e.atMs).toLocaleString();
50
+ return 'next reset';
51
+ }
52
+
53
+ // Printed after the sessions report in the plain-text `unsnooze status` —
54
+ // silent (no section at all) when the queue has nothing pending, so the
55
+ // existing status output is byte-identical for everyone not using the queue.
56
+ function printPromptQueueSection(entries) {
57
+ if (entries.length === 0) return;
58
+ const now = Date.now();
59
+ console.log(`\nqueued prompts: ${entries.length}\n`);
60
+ for (const e of entries) {
61
+ console.log(` ${e.id} ${(e.agent || 'claude').padEnd(8)} ${fmtQueueDue(e, now).padEnd(28)} ${e.status.padEnd(9)} ${shortenHome(e.cwd)}`);
62
+ }
63
+ }
64
+
23
65
 
24
66
  // Surface how the reset estimate was derived so a silent wrong guess can't hide.
25
67
  export function fmtResetProvenance(s) {
@@ -33,15 +75,80 @@ export function fmtResetProvenance(s) {
33
75
  return via ? `${s.resetSource}, from ${via}` : s.resetSource;
34
76
  }
35
77
 
36
- export async function cmdStatus() {
78
+ export async function cmdStatus(args = []) {
79
+ if (args.includes('--json')) {
80
+ const state = readState();
81
+ const daemonRunning = (() => {
82
+ if (!state.resumerPid) return false;
83
+ try { process.kill(state.resumerPid, 0); return true; } catch { return false; }
84
+ })();
85
+ console.log(JSON.stringify({
86
+ version: 1,
87
+ resumerPid: daemonRunning ? state.resumerPid : null,
88
+ daemonRunning,
89
+ paused: !getConfig('autoResume'),
90
+ sessions: Object.values(state.sessions).map(s => ({
91
+ key: s.key, sessionId: s.sessionId ?? null, agent: s.agent ?? 'claude',
92
+ cwd: s.cwd ?? null, status: s.status, limitType: s.limitType ?? null,
93
+ resetAt: s.resetAt ?? null, resetSource: s.resetSource ?? null,
94
+ mux: s.mux ?? null, pane: s.pane ?? null, muxSession: s.muxSession ?? null,
95
+ attempts: s.attempts ?? 0, lastError: s.lastError ?? null,
96
+ workspaceHold: !!s.workspaceHold,
97
+ })),
98
+ promptQueue: queueList(),
99
+ }, null, 2));
100
+ return 0;
101
+ }
102
+ // Interactive TTY → live dashboard (until q). Pipes stay plain.
103
+ if (shouldUseDashboard()) return runDashboard({ tab: 'status' });
104
+
37
105
  const state = readState();
38
106
  const sessions = Object.values(state.sessions);
39
107
  const paused = !getConfig('autoResume');
108
+ const now = Date.now();
109
+ const useTui = shouldUseTui();
110
+
111
+ async function attachHintFor(s) {
112
+ if (!s.muxSession) return null;
113
+ try {
114
+ const mux = getMultiplexer(s.mux || 'tmux');
115
+ if (typeof mux.sessionExists === 'function' && await mux.sessionExists(s.muxSession)) {
116
+ return attachHint(s.mux, s.muxSession) || null;
117
+ }
118
+ } catch { /* omit */ }
119
+ return null;
120
+ }
121
+ function contextTokensFor(s) {
122
+ return getAgent(s.agent).contextTokens?.(s);
123
+ }
124
+
125
+ if (useTui) {
126
+ // Pre-resolve attach hints (async) for TUI cards.
127
+ const attachMap = new Map();
128
+ for (const s of sessions) {
129
+ const h = await attachHintFor(s);
130
+ if (h) attachMap.set(s.key, h);
131
+ }
132
+ console.log(formatStatusTui({
133
+ sessions,
134
+ resumerPid: state.resumerPid,
135
+ paused,
136
+ now,
137
+ fmtCountdown,
138
+ fmtResetProvenance,
139
+ approxTokens,
140
+ contextTokensFor,
141
+ attachHintFor: s => attachMap.get(s.key) || null,
142
+ color: true,
143
+ }));
144
+ return 0;
145
+ }
146
+
40
147
  if (sessions.length === 0) {
41
148
  console.log(`unsnooze: no tracked sessions.${paused ? ' (PAUSED — auto-resume off)' : ''}`);
149
+ printPromptQueueSection(pendingPromptEntries());
42
150
  return 0;
43
151
  }
44
- const now = Date.now();
45
152
  const pausedNote = paused ? ' PAUSED — auto-resume off (`unsnooze config set autoResume on`)' : '';
46
153
  console.log(`unsnooze: ${sessions.length} tracked session(s) (resumer pid: ${state.resumerPid ?? 'not running'})${pausedNote}\n`);
47
154
  for (const s of sessions.sort((a, b) => (a.resetAt || 0) - (b.resetAt || 0))) {
@@ -58,32 +165,24 @@ export async function cmdStatus() {
58
165
  const hold = s.workspaceHold
59
166
  ? ` · held: ${s.holdReason ?? '?'} — resume-now to wake`
60
167
  : '';
61
- // The price of waking: estimated context tokens the API re-reads cold.
62
168
  let ctx = '';
63
169
  if (s.status === 'stopped') {
64
170
  try {
65
- const t = getAgent(s.agent).contextTokens?.(s);
171
+ const t = contextTokensFor(s);
66
172
  if (t != null) ctx = ` · ctx ${approxTokens(t)} tok`;
67
173
  } catch { /* estimate unavailable — omit */ }
68
174
  }
69
- // Attach hint when the mux session the record points at is still live.
70
175
  let attach = '';
71
- if (s.muxSession) {
72
- try {
73
- const mux = getMultiplexer(s.mux || 'tmux');
74
- if (typeof mux.sessionExists === 'function' && await mux.sessionExists(s.muxSession)) {
75
- const hint = attachHint(s.mux, s.muxSession);
76
- if (hint) attach = ` · attach: ${hint}`;
77
- }
78
- } catch { /* mux unavailable — omit */ }
79
- }
176
+ const hint = await attachHintFor(s);
177
+ if (hint) attach = ` · attach: ${hint}`;
80
178
  console.log(` [${s.status.toUpperCase().padEnd(9)}] ${id} ${(s.agent || 'claude').padEnd(6)} ${s.limitType?.padEnd(7) ?? 'unknown'} ${s.cwd}`);
81
179
  console.log(` mux ${s.mux ?? '-'} · pane ${pane} · session ${s.muxSession ?? '-'} · via ${origin} · resets ${reset} · attempts ${s.attempts ?? 0}/${MAX_RESUME_ATTEMPTS}${s.lastError ? ` · last error: ${s.lastError}` : ''}${msg}${ctx}${hold}${attach}`);
82
180
  }
181
+ printPromptQueueSection(pendingPromptEntries());
83
182
  return 0;
84
183
  }
85
184
 
86
- function selectKeys(state, idOrAll, statuses = ['stopped']) {
185
+ export function selectKeys(state, idOrAll, statuses = ['stopped']) {
87
186
  const candidates = Object.values(state.sessions).filter(s => statuses.includes(s.status));
88
187
  if (idOrAll === '--all' || idOrAll === undefined) return candidates.map(s => s.key);
89
188
  const match = candidates.filter(s => s.key.startsWith(idOrAll) || (s.sessionId || '').startsWith(idOrAll));
@@ -107,6 +206,16 @@ export async function cmdResumeNow(idOrAll) {
107
206
  } catch { /* repo gone or git unhappy — proceed anyway */ }
108
207
  }
109
208
  }
209
+ markResumeNow(keys);
210
+ console.log(`unsnooze: marked ${keys.length} session(s) due now; resumer dispatched.`);
211
+ return 0;
212
+ }
213
+
214
+ // Shared core: mark sessions due now + manual (bypasses autoResume off and
215
+ // workspace/context guards), clear any hold, dispatch the resumer. Used by
216
+ // local `resume-now` and (future) remote-trigger fan-out — keep this the
217
+ // single place that owns the mutation semantics.
218
+ export function markResumeNow(keys) {
110
219
  updateState(s => {
111
220
  for (const key of keys) {
112
221
  if (s.sessions[key]) {
@@ -118,8 +227,7 @@ export async function cmdResumeNow(idOrAll) {
118
227
  }
119
228
  });
120
229
  spawnResumerIfNeeded();
121
- console.log(`unsnooze: marked ${keys.length} session(s) due now; resumer dispatched.`);
122
- return 0;
230
+ return keys;
123
231
  }
124
232
 
125
233
  export function cmdCancel(idOrAll) {
@@ -228,7 +336,6 @@ export async function cmdPreview(rest = [], {
228
336
  print(idOrAll ? `unsnooze preview: no session matching "${idOrAll}".` : 'unsnooze preview: no tracked sessions.');
229
337
  return 0;
230
338
  }
231
- print('unsnooze preview — what WOULD happen right now (nothing is sent)\n');
232
339
  const VERBS = {
233
340
  inject: p => `would TYPE the wake message into pane ${p.target?.pane}`,
234
341
  'drive-menu': p => `would SELECT "Stop and wait for limit to reset" in pane ${p.target?.pane}`,
@@ -245,6 +352,9 @@ export async function cmdPreview(rest = [], {
245
352
  none: () => 'nothing to do',
246
353
  };
247
354
  let actionable = false;
355
+ const tuiEntries = [];
356
+ const useTui = shouldUseTui() && print === console.log;
357
+ if (!useTui) print('unsnooze preview — what WOULD happen right now (nothing is sent)\n');
248
358
  for (const rec of records.sort((x, y) => (x.resetAt || 0) - (y.resetAt || 0))) {
249
359
  let plan;
250
360
  try {
@@ -254,20 +364,29 @@ export async function cmdPreview(rest = [], {
254
364
  now,
255
365
  });
256
366
  } catch (err) {
257
- print(` ${rec.key.slice(0, 12)}: preview failed: ${err.message}`);
367
+ if (useTui) tuiEntries.push({ status: rec.status, id: rec.key.slice(0, 12), agent: rec.agent, cwd: rec.cwd, verb: `preview failed: ${err.message}`, gates: [] });
368
+ else print(` ${rec.key.slice(0, 12)}: preview failed: ${err.message}`);
258
369
  continue;
259
370
  }
260
371
  const id = rec.sessionId ? rec.sessionId.slice(0, 8) : '(no id)';
261
- print(` [${(rec.status || '?').toUpperCase().padEnd(9)}] ${id} ${(rec.agent || 'claude').padEnd(6)} ${rec.cwd || ''}`);
262
- print(` ${(VERBS[plan.action] || (() => plan.action))(plan)}`);
263
- for (const g of plan.gates) print(` · ${g}`);
372
+ const verb = (VERBS[plan.action] || (() => plan.action))(plan);
373
+ let message = null;
264
374
  if (plan.message && ['inject', 'reopen'].includes(plan.action)) {
265
- const m = plan.message.length > 140 ? `${plan.message.slice(0, 140)}…` : plan.message;
266
- print(` message: "${m.replace(/\n+/g, ' ⏎ ')}"`);
375
+ message = plan.message.length > 140 ? `${plan.message.slice(0, 140)}…` : plan.message;
376
+ message = message.replace(/\n+/g, ' ⏎ ');
377
+ }
378
+ if (useTui) {
379
+ tuiEntries.push({ status: rec.status, id, agent: rec.agent, cwd: rec.cwd, verb, gates: plan.gates || [], message });
380
+ } else {
381
+ print(` [${(rec.status || '?').toUpperCase().padEnd(9)}] ${id} ${(rec.agent || 'claude').padEnd(6)} ${rec.cwd || ''}`);
382
+ print(` ${verb}`);
383
+ for (const g of plan.gates) print(` · ${g}`);
384
+ if (message) print(` message: "${message}"`);
267
385
  }
268
386
  if (['inject', 'drive-menu', 'reopen'].includes(plan.action)) actionable = true;
269
387
  }
270
- print('\nNothing was typed, opened, or modified. `unsnooze resume-now <id>` wakes a session immediately.');
388
+ if (useTui) print(formatPreviewTui(tuiEntries, { color: true }));
389
+ else print('\nNothing was typed, opened, or modified. `unsnooze resume-now <id>` wakes a session immediately.');
271
390
  return actionable ? 2 : 0;
272
391
  } catch (err) {
273
392
  console.error(`unsnooze preview: ${err.message}`);
@@ -277,7 +396,12 @@ export async function cmdPreview(rest = [], {
277
396
 
278
397
  // `unsnooze sessions` — list unsnooze-owned mux sessions with panes + records.
279
398
  export async function cmdSessions() {
399
+ if (shouldUseDashboard()) return runDashboard({ tab: 'sessions' });
280
400
  const owned = await listOwnedSessions();
401
+ if (shouldUseTui()) {
402
+ console.log(formatSessionsTui(owned, { color: true }));
403
+ return 0;
404
+ }
281
405
  if (owned.length === 0) {
282
406
  console.log('unsnooze: no unsnooze-owned mux sessions found.');
283
407
  return 0;
package/src/config.js CHANGED
@@ -12,14 +12,29 @@ export const LOCK_DIR = join(STATE_DIR, 'state.lock');
12
12
  export const LOG_FILE = join(STATE_DIR, 'unsnooze.log');
13
13
  export const EVENTS_DIR = join(STATE_DIR, 'events');
14
14
  export const RESUMER_LOCK = join(STATE_DIR, 'resumer.lock');
15
+ // High-frequency burn accumulator + warn-dedup (daemon single-writer).
16
+ export const USAGE_FILE = join(STATE_DIR, 'usage.json');
15
17
 
16
18
  export const CLAUDE_DIR = process.env.UNSNOOZE_CLAUDE_DIR || join(homedir(), '.claude');
17
19
  export const CLAUDE_SETTINGS = join(CLAUDE_DIR, 'settings.json');
18
20
  export const CODEX_DIR = process.env.UNSNOOZE_CODEX_DIR || join(homedir(), '.codex');
21
+ // Opt-in statusline shim drop dir for exact Claude rate_limits.
22
+ export const USAGE_STATUSLINE_DIR = join(CLAUDE_DIR, 'unsnooze');
19
23
 
20
24
  // Transcript/rollout watcher (GUI detection channel)
21
25
  export const WATCH_OFFSETS_FILE = join(STATE_DIR, 'watch-offsets.json');
22
26
 
27
+ // Usage forecast (1.13)
28
+ export const USAGE_BURN_LOOKBACK_MS = envInt('UNSNOOZE_USAGE_BURN_LOOKBACK_MS', 60 * 60_000);
29
+ export const USAGE_BURN_MIN_COVERAGE_MS = envInt('UNSNOOZE_USAGE_BURN_MIN_MS', 10 * 60_000);
30
+ export const USAGE_IDLE_GAP_MS = envInt('UNSNOOZE_USAGE_IDLE_GAP_MS', 5 * 60_000);
31
+ export const USAGE_WINDOW_IDLE_MS = envInt('UNSNOOZE_USAGE_WINDOW_IDLE_MS', 5 * 3_600_000);
32
+ export const USAGE_CALIBRATION_RING = envInt('UNSNOOZE_USAGE_CAL_RING', 20);
33
+ export const USAGE_CALIBRATION_MEDIAN_N = envInt('UNSNOOZE_USAGE_CAL_N', 5);
34
+ // Time-to-wall warn tiers (minutes); comma-separated, not a config-file key.
35
+ export const USAGE_ETA_WARN_MIN = (process.env.UNSNOOZE_USAGE_ETA_WARN_MIN || '30,10')
36
+ .split(',').map(Number).filter(n => Number.isFinite(n) && n > 0);
37
+
23
38
  // Interactive launcher base session name. The daemon never CREATES this name
24
39
  // (see RESUME_SESSION_NAME); it may only join it when already live.
25
40
  export const MUX_SESSION_NAME = process.env.UNSNOOZE_SESSION_NAME