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.
- package/CHANGELOG.md +84 -0
- package/README.md +286 -22
- package/bin/unsnooze.js +52 -3
- package/package.json +7 -4
- package/src/agents/agy.js +2 -0
- package/src/agents/claude.js +2 -0
- package/src/agents/codex.js +2 -0
- package/src/agents/grok.js +2 -0
- package/src/agents/index.js +7 -1
- package/src/agents/kimi.js +2 -0
- package/src/agents/opencode.js +2 -0
- package/src/agents/qwen.js +2 -0
- package/src/askpass.js +188 -0
- package/src/cli.js +149 -25
- package/src/config.js +15 -0
- package/src/dashboard/App.js +390 -0
- package/src/dashboard/Logo.js +74 -0
- package/src/dashboard/components/TextInput.js +63 -0
- package/src/dashboard/data.js +143 -0
- package/src/dashboard/mark.js +51 -0
- package/src/dashboard/mouse-protocol.js +75 -0
- package/src/dashboard/mouse.js +125 -0
- package/src/dashboard/run.js +108 -0
- package/src/dashboard/tabs/DoctorTab.js +35 -0
- package/src/dashboard/tabs/FleetTab.js +94 -0
- package/src/dashboard/tabs/LogsTab.js +28 -0
- package/src/dashboard/tabs/PromptsTab.js +406 -0
- package/src/dashboard/tabs/SessionsTab.js +33 -0
- package/src/dashboard/tabs/StatusTab.js +58 -0
- package/src/dashboard/tabs/UsageTab.js +68 -0
- package/src/dashboard/theme.js +32 -0
- package/src/doctor.js +22 -16
- package/src/fleet.js +927 -0
- package/src/hook.js +19 -0
- package/src/install.js +9 -0
- package/src/monitor.js +14 -0
- package/src/prompt-queue.js +561 -0
- package/src/prompt.js +411 -0
- package/src/remote.js +223 -0
- package/src/resumer.js +60 -16
- package/src/settings.js +13 -0
- package/src/state.js +33 -9
- package/src/time-parser.js +15 -1
- package/src/tui.js +395 -0
- package/src/usage.js +1537 -0
- package/src/watcher.js +79 -8
- package/src/watchers/claude.js +3 -0
- package/src/watchers/codex.js +8 -8
- package/src/wizard.js +2 -1
package/src/agents/qwen.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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(
|
|
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
|