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
package/src/prompt.js ADDED
@@ -0,0 +1,411 @@
1
+ // `unsnooze prompt` — local prompt-queue CLI: queue a one-shot prompt that
2
+ // spawns a NEW agent session in a project cwd once a usage limit clears.
3
+ // `--host <name>` fans the same subcommands out over the fleet transport
4
+ // (src/fleet.js's remoteQueue* functions -> src/remote.js's queue-* verbs on
5
+ // the target host) instead of touching local state.
6
+
7
+ import { existsSync, statSync } from 'node:fs';
8
+ import { resolve, isAbsolute } from 'node:path';
9
+ import * as p from '@clack/prompts';
10
+ import {
11
+ queueAdd, queueList, queueRemove, queueClear, resolveAgentResetAnchor,
12
+ } from './prompt-queue.js';
13
+ import { listAgents } from './agents/index.js';
14
+ import { getConfig } from './settings.js';
15
+ import { parseGoDuration } from './time-parser.js';
16
+ import { shortenHome } from './cli.js';
17
+ import {
18
+ readHosts, remoteQueueAdd, remoteQueueList, remoteQueueRemove, remoteQueueClear,
19
+ } from './fleet.js';
20
+
21
+ function enabledAgentIds() {
22
+ return listAgents().map(a => a.id).filter(id => getConfig(`agents.${id}`));
23
+ }
24
+
25
+ // Interactive default: @clack/prompts select, mirroring wizard.js's usage
26
+ // (p.select + p.isCancel). Injectable via cmdPrompt(rest, { prompter }) so
27
+ // tests can simulate a pick or a cancel without a real TTY.
28
+ async function defaultPrompter(ids) {
29
+ const picked = await p.select({
30
+ message: 'Which agent should run this prompt?',
31
+ options: ids.map(id => ({ value: id, label: id })),
32
+ initialValue: ids.includes('claude') ? 'claude' : ids[0],
33
+ });
34
+ return p.isCancel(picked) ? null : picked;
35
+ }
36
+
37
+ // --- --at parsing ----------------------------------------------------------
38
+
39
+ const INTEGER_RE = /^\d+$/;
40
+ const AMPM_CLOCK_RE = /^(\d{1,2})(?::([0-5]\d))?\s*(am|pm)$/i;
41
+ const CLOCK_24H_RE = /^([01]?\d|2[0-3]):([0-5]\d)$/;
42
+
43
+ function to24h(hour, ampm) {
44
+ if (ampm === 'pm' && hour !== 12) return hour + 12;
45
+ if (ampm === 'am' && hour === 12) return 0;
46
+ return hour;
47
+ }
48
+
49
+ // Next occurrence of hour:minute in LOCAL time — today if still ahead of
50
+ // `now`, else tomorrow. Never returns a time <= now.
51
+ function nextOccurrence(hour, minute, now) {
52
+ const d = new Date(now);
53
+ d.setHours(hour, minute, 0, 0);
54
+ if (d.getTime() <= now) d.setDate(d.getDate() + 1);
55
+ return d.getTime();
56
+ }
57
+
58
+ function parseClockTime(text, now) {
59
+ const ampm = text.match(AMPM_CLOCK_RE);
60
+ if (ampm) {
61
+ const hour = parseInt(ampm[1], 10);
62
+ if (hour < 1 || hour > 12) return null;
63
+ const minute = ampm[2] ? parseInt(ampm[2], 10) : 0;
64
+ return nextOccurrence(to24h(hour, ampm[3].toLowerCase()), minute, now);
65
+ }
66
+ const h24 = text.match(CLOCK_24H_RE);
67
+ if (h24) return nextOccurrence(parseInt(h24[1], 10), parseInt(h24[2], 10), now);
68
+ return null;
69
+ }
70
+
71
+ // Accepts, in order of attempt: epoch ms/seconds, ISO-8601, "+2h30m"/"+45m"
72
+ // Go-style durations, or a bare clock time ("14:30" / "2:05pm" / "7pm")
73
+ // rolled forward to its next local occurrence. Returns epoch ms, or null if
74
+ // nothing matched — the caller is responsible for erroring on null.
75
+ export function parseAtTime(text, now = Date.now()) {
76
+ if (typeof text !== 'string') return null;
77
+ const t = text.trim();
78
+ if (!t) return null;
79
+
80
+ if (INTEGER_RE.test(t)) {
81
+ const n = Number(t);
82
+ if (n > 1e12) return n;
83
+ if (n >= 1e9) return n * 1000;
84
+ return null;
85
+ }
86
+
87
+ if (t.includes('-') && t.includes(':')) {
88
+ const parsed = Date.parse(t);
89
+ if (!Number.isNaN(parsed)) return parsed;
90
+ }
91
+
92
+ if (t.startsWith('+')) {
93
+ const waitMs = parseGoDuration(t.slice(1));
94
+ if (waitMs > 0) return now + waitMs;
95
+ }
96
+
97
+ return parseClockTime(t, now);
98
+ }
99
+
100
+ // --- add ---------------------------------------------------------------
101
+
102
+ function parseAddArgs(args) {
103
+ const flags = { agent: undefined, project: undefined, at: undefined, now: false };
104
+ const textParts = [];
105
+ for (let i = 0; i < args.length; i++) {
106
+ const a = args[i];
107
+ if (a === '--agent') { flags.agent = args[++i]; continue; }
108
+ if (a === '--project') { flags.project = args[++i]; continue; }
109
+ if (a === '--at') { flags.at = args[++i]; continue; }
110
+ if (a === '--now') { flags.now = true; continue; }
111
+ textParts.push(a);
112
+ }
113
+ return { flags, text: textParts.join(' ').trim() };
114
+ }
115
+
116
+ async function cmdPromptAdd(args, { prompter, spawn = true, spawnFn } = {}) {
117
+ const { flags, text } = parseAddArgs(args);
118
+
119
+ if (flags.at !== undefined && flags.now) {
120
+ console.error('unsnooze prompt add: --at and --now cannot both be given');
121
+ return 1;
122
+ }
123
+
124
+ const projectPath = resolve(flags.project || process.cwd());
125
+ if (!existsSync(projectPath) || !statSync(projectPath).isDirectory()) {
126
+ console.error(`unsnooze prompt add: --project "${projectPath}" does not exist or is not a directory`);
127
+ return 1;
128
+ }
129
+
130
+ const enabled = enabledAgentIds();
131
+ let agentId = flags.agent;
132
+ if (agentId !== undefined) {
133
+ if (!enabled.includes(agentId)) {
134
+ console.error(`unsnooze prompt add: unknown or disabled agent "${agentId}" (valid: ${enabled.join(', ')})`);
135
+ return 1;
136
+ }
137
+ } else if (prompter) {
138
+ // Explicit prompter always wins over the TTY gate — this is the test
139
+ // seam (real picker or a scripted cancel), independent of stdin/stdout.
140
+ const chosen = await prompter(enabled);
141
+ if (chosen == null) {
142
+ console.error('unsnooze prompt add: cancelled');
143
+ return 1;
144
+ }
145
+ agentId = chosen;
146
+ } else if (process.stdin.isTTY && process.stdout.isTTY) {
147
+ const chosen = await defaultPrompter(enabled);
148
+ if (chosen == null) {
149
+ console.error('unsnooze prompt add: cancelled');
150
+ return 1;
151
+ }
152
+ agentId = chosen;
153
+ } else {
154
+ agentId = 'claude';
155
+ }
156
+
157
+ let mode = 'next-reset';
158
+ let atMs = null;
159
+ if (flags.at !== undefined) {
160
+ atMs = parseAtTime(flags.at);
161
+ if (atMs == null) {
162
+ console.error(`unsnooze prompt add: could not parse --at "${flags.at}"`);
163
+ return 1;
164
+ }
165
+ mode = 'at';
166
+ } else if (flags.now) {
167
+ mode = 'now';
168
+ }
169
+
170
+ const result = queueAdd({
171
+ cwd: projectPath, agent: agentId, prompt: text, mode, atMs,
172
+ spawn, ...(spawnFn ? { spawnFn } : {}),
173
+ });
174
+ if (!result.ok) {
175
+ console.error(result.error === 'duplicate'
176
+ ? `unsnooze prompt add: duplicate — matches existing queued prompt ${result.existing.id}`
177
+ : `unsnooze prompt add: ${result.error}`);
178
+ return 1;
179
+ }
180
+
181
+ console.log(`unsnooze: queued prompt ${result.entry.id} for ${agentId} in ${shortenHome(projectPath)}`);
182
+ if (mode === 'next-reset') {
183
+ const { resetAtMs: at } = resolveAgentResetAnchor(agentId);
184
+ console.log(at == null || at <= Date.now()
185
+ ? `unsnooze: no active limit detected for ${agentId} — the prompt will be delivered on the next daemon tick; use --at to schedule a specific time.`
186
+ : `unsnooze: delivery ETA ~ ${new Date(at).toLocaleString()}`);
187
+ }
188
+ return 0;
189
+ }
190
+
191
+ // --- list / remove / clear ----------------------------------------------
192
+
193
+ function fmtDue(e, now) {
194
+ if (Number.isFinite(e.notBefore) && e.notBefore > now) {
195
+ return `backoff until ${new Date(e.notBefore).toLocaleString()}`;
196
+ }
197
+ if (e.mode === 'now') return 'now';
198
+ if (e.mode === 'at') return new Date(e.atMs).toLocaleString();
199
+ return 'next reset';
200
+ }
201
+
202
+ // Shared by the local and --host list paths — an entry has either a full
203
+ // `prompt` (local, from queueList()) or a pre-truncated `promptPreview`
204
+ // (remote, sanitized on ingest by remoteQueueList); either shape renders the
205
+ // same table.
206
+ function printQueueEntries(entries, { host } = {}) {
207
+ if (entries.length === 0) {
208
+ console.log('no queued prompts');
209
+ return;
210
+ }
211
+ const now = Date.now();
212
+ const header = host ? `unsnooze: ${entries.length} queued prompt(s) on ${host}\n` : `unsnooze: ${entries.length} queued prompt(s)\n`;
213
+ console.log(header);
214
+ for (const e of entries) {
215
+ const preview = typeof e.prompt === 'string'
216
+ ? (e.prompt.length > 60 ? `${e.prompt.slice(0, 60)}…` : e.prompt)
217
+ : (e.promptPreview ?? '');
218
+ console.log(` ${e.id} ${(e.agent || 'claude').padEnd(8)} ${fmtDue(e, now).padEnd(28)} ${e.status.padEnd(9)} ${shortenHome(e.cwd)}`);
219
+ console.log(` "${preview}"`);
220
+ }
221
+ }
222
+
223
+ function cmdPromptList(args) {
224
+ const entries = queueList();
225
+ if (args.includes('--json')) {
226
+ console.log(JSON.stringify(entries, null, 2));
227
+ return 0;
228
+ }
229
+ printQueueEntries(entries);
230
+ return 0;
231
+ }
232
+
233
+ function cmdPromptRemove(args) {
234
+ const [id] = args;
235
+ if (!id) { console.error('unsnooze prompt remove <id>'); return 1; }
236
+ if (!queueRemove(id)) {
237
+ console.error(`unsnooze prompt remove: no pending/launching prompt with id "${id}"`);
238
+ return 1;
239
+ }
240
+ console.log(`unsnooze: removed prompt ${id}`);
241
+ return 0;
242
+ }
243
+
244
+ function cmdPromptClear() {
245
+ const count = queueClear();
246
+ console.log(`unsnooze: cleared ${count} queued prompt(s)`);
247
+ return 0;
248
+ }
249
+
250
+ // --- --host fan-out ---------------------------------------------------------
251
+ // Same four subcommands, routed to a remote host over the fleet transport
252
+ // instead of local state. Every remote failure funnels through one of two
253
+ // printers: needs-auth (a resolvable-credential problem — same "needs-setup"
254
+ // vocabulary `unsnooze hosts test` uses) or a flat error (bad-request,
255
+ // disabled, too-old-remote, ssh failure, ...).
256
+
257
+ function printHostNeedsAuth(hostName, result) {
258
+ console.error(`unsnooze: ${hostName} needs-setup: ${result.error} (see \`unsnooze hosts test ${hostName}\`)`);
259
+ return 1;
260
+ }
261
+
262
+ function extractHostFlag(args) {
263
+ const idx = args.indexOf('--host');
264
+ if (idx === -1) return { host: undefined, rest: args };
265
+ const host = args[idx + 1];
266
+ // A bare trailing --host (no value follows) must never silently fall
267
+ // through to local routing — that would make e.g. `prompt clear --host`
268
+ // clear the LOCAL queue instead of erroring, a destructive misdirection.
269
+ if (host === undefined) return { host: undefined, rest: args, error: true };
270
+ return { host, rest: [...args.slice(0, idx), ...args.slice(idx + 2)] };
271
+ }
272
+
273
+ // --project is required (there is no local cwd to default to on a remote
274
+ // filesystem) and --agent is required (remote enablement is unknown ahead of
275
+ // the round trip, so there is no interactive picker over ssh — see the
276
+ // module header). The cwd is passed through as-is: it names a path on the
277
+ // REMOTE host, so no local resolve()/existsSync() applies to it — the remote
278
+ // queue-add handler is the one that stats it (see src/remote.js).
279
+ async function cmdPromptHostAdd(hostName, args, remoteOpts) {
280
+ const { flags, text } = parseAddArgs(args);
281
+
282
+ if (flags.at !== undefined && flags.now) {
283
+ console.error('unsnooze prompt add: --at and --now cannot both be given');
284
+ return 1;
285
+ }
286
+ if (!flags.project) {
287
+ console.error('unsnooze prompt add --host: --project is required (no local cwd to default to on a remote host)');
288
+ return 1;
289
+ }
290
+ if (!isAbsolute(flags.project)) {
291
+ console.error(`unsnooze prompt add --host: --project "${flags.project}" must be an absolute remote path`);
292
+ return 1;
293
+ }
294
+ if (!flags.agent) {
295
+ console.error('unsnooze prompt add --host: --agent is required (remote agent availability is unknown — no interactive picker over ssh)');
296
+ return 1;
297
+ }
298
+
299
+ let mode = 'next-reset';
300
+ let atMs = null;
301
+ if (flags.at !== undefined) {
302
+ atMs = parseAtTime(flags.at);
303
+ if (atMs == null) {
304
+ console.error(`unsnooze prompt add: could not parse --at "${flags.at}"`);
305
+ return 1;
306
+ }
307
+ mode = 'at';
308
+ } else if (flags.now) {
309
+ mode = 'now';
310
+ }
311
+
312
+ const result = await remoteQueueAdd(hostName, { cwd: flags.project, agent: flags.agent, prompt: text, mode, atMs }, remoteOpts);
313
+ if (result.needsAuth) return printHostNeedsAuth(hostName, result);
314
+ if (!result.ok) {
315
+ console.error(`unsnooze prompt add --host ${hostName}: ${result.error}`);
316
+ return 1;
317
+ }
318
+ console.log(`unsnooze: queued prompt ${result.id} for ${flags.agent} on ${hostName}:${flags.project}`);
319
+ return 0;
320
+ }
321
+
322
+ async function cmdPromptHostList(hostName, args, remoteOpts) {
323
+ const result = await remoteQueueList(hostName, remoteOpts);
324
+ if (result.needsAuth) return printHostNeedsAuth(hostName, result);
325
+ if (!result.ok) {
326
+ console.error(`unsnooze prompt list --host ${hostName}: ${result.error}`);
327
+ return 1;
328
+ }
329
+ if (args.includes('--json')) {
330
+ console.log(JSON.stringify(result.queue, null, 2));
331
+ return 0;
332
+ }
333
+ printQueueEntries(result.queue, { host: hostName });
334
+ return 0;
335
+ }
336
+
337
+ async function cmdPromptHostRemove(hostName, args, remoteOpts) {
338
+ const [id] = args;
339
+ if (!id) { console.error('unsnooze prompt remove <id> --host <name>'); return 1; }
340
+ const result = await remoteQueueRemove(hostName, id, remoteOpts);
341
+ if (result.needsAuth) return printHostNeedsAuth(hostName, result);
342
+ if (!result.ok) {
343
+ console.error(result.notFound
344
+ ? `unsnooze prompt remove --host ${hostName}: no pending/launching prompt with id "${id}"`
345
+ : `unsnooze prompt remove --host ${hostName}: ${result.error}`);
346
+ return 1;
347
+ }
348
+ console.log(`unsnooze: removed prompt ${id} on ${hostName}`);
349
+ return 0;
350
+ }
351
+
352
+ async function cmdPromptHostClear(hostName, remoteOpts) {
353
+ const result = await remoteQueueClear(hostName, remoteOpts);
354
+ if (result.needsAuth) return printHostNeedsAuth(hostName, result);
355
+ if (!result.ok) {
356
+ console.error(`unsnooze prompt clear --host ${hostName}: ${result.error}`);
357
+ return 1;
358
+ }
359
+ console.log(`unsnooze: cleared ${result.cleared} queued prompt(s) on ${hostName}`);
360
+ return 0;
361
+ }
362
+
363
+ async function cmdPromptHost(sub, hostName, args, opts) {
364
+ const hosts = readHosts();
365
+ const entry = hosts[hostName];
366
+ if (!entry) {
367
+ console.error(`unsnooze: no such host: ${hostName}`);
368
+ return 1;
369
+ }
370
+ const remoteOpts = {
371
+ entryOrDest: entry,
372
+ interactive: !!process.stdin.isTTY,
373
+ ...(opts.remote || {}),
374
+ };
375
+
376
+ switch (sub) {
377
+ case 'add': return cmdPromptHostAdd(hostName, args, remoteOpts);
378
+ case 'list': return cmdPromptHostList(hostName, args, remoteOpts);
379
+ case 'remove': return cmdPromptHostRemove(hostName, args, remoteOpts);
380
+ case 'clear': return cmdPromptHostClear(hostName, remoteOpts);
381
+ default:
382
+ console.error('unsnooze prompt <add|list|remove|clear> ...');
383
+ return 1;
384
+ }
385
+ }
386
+
387
+ // --- entry point ----------------------------------------------------------
388
+
389
+ export async function cmdPrompt(rest = [], opts = {}) {
390
+ const [sub, ...args] = rest;
391
+ const { host, rest: cleanArgs, error } = extractHostFlag(args);
392
+
393
+ if (error) {
394
+ console.error('unsnooze prompt: --host requires a host name');
395
+ return 1;
396
+ }
397
+
398
+ if (host !== undefined) {
399
+ return cmdPromptHost(sub, host, cleanArgs, opts);
400
+ }
401
+
402
+ switch (sub) {
403
+ case 'add': return cmdPromptAdd(cleanArgs, opts);
404
+ case 'list': return cmdPromptList(cleanArgs);
405
+ case 'remove': return cmdPromptRemove(cleanArgs);
406
+ case 'clear': return cmdPromptClear();
407
+ default:
408
+ console.error('unsnooze prompt <add|list|remove|clear> ...');
409
+ return 1;
410
+ }
411
+ }
package/src/remote.js ADDED
@@ -0,0 +1,223 @@
1
+ // The single remote entrypoint for fleet control. Borg-style: under a
2
+ // forced command (authorized_keys `command="unsnooze _remote",restrict`),
3
+ // the client's request arrives in SSH_ORIGINAL_COMMAND and is re-validated
4
+ // against the same closed verb set — client env/argv is never trusted.
5
+ // Output is exactly one sentinel-framed JSON line; typing NEVER happens
6
+ // here (resume only marks state; the local daemon's gates do the rest).
7
+ import { hostname } from 'node:os';
8
+ import { isAbsolute } from 'node:path';
9
+ import { existsSync, statSync } from 'node:fs';
10
+ import { frameEnvelope, KEY_RE, PAYLOAD_RE, QUEUE_ID_RE, SCHEMA, MIN_SCHEMA } from './fleet.js';
11
+ import { readState, setStatus } from './state.js';
12
+ import { selectKeys, markResumeNow } from './cli.js';
13
+ import { PKG_VERSION } from './update-check.js';
14
+ import { queueAdd, queueList, queueRemove, queueClear, sanitizePrompt } from './prompt-queue.js';
15
+ import { listAgents } from './agents/index.js';
16
+ import { getConfig } from './settings.js';
17
+
18
+ const VERBS = new Set(['status', 'resume', 'cancel', 'queue-add', 'queue-list', 'queue-remove', 'queue-clear']);
19
+ const QUEUE_VERBS = new Set(['queue-add', 'queue-list', 'queue-remove', 'queue-clear']);
20
+ // Verbs that take no argument at all — everything else takes exactly one.
21
+ const NO_ARG_VERBS = new Set(['status', 'queue-list', 'queue-clear']);
22
+ // Per-verb single-arg shape. resume/cancel keep the original session-key
23
+ // regex; the two new verbs that carry an arg get their own (see fleet.js).
24
+ const ARG_VALIDATORS = { resume: KEY_RE, cancel: KEY_RE, 'queue-add': PAYLOAD_RE, 'queue-remove': QUEUE_ID_RE };
25
+
26
+ // Mirrors prompt-queue.js's own AGENT_IDS/MODES/MAX_CWD_LEN — deliberately
27
+ // re-declared here rather than imported: this module re-validates every
28
+ // field independently of queueAdd's own checks (defense in depth for
29
+ // attacker-shaped SSH input), and a shared constant would blur that these
30
+ // are two separate gates, not one.
31
+ const AGENT_IDS = new Set(listAgents().map(a => a.id));
32
+ const QUEUE_MODES = ['next-reset', 'at', 'now'];
33
+ const MAX_CWD_LEN = 1024;
34
+ const QUEUE_STATUS_CAP = 50;
35
+
36
+ function handshake() {
37
+ return {
38
+ schema: SCHEMA,
39
+ minSchema: MIN_SCHEMA,
40
+ cli: PKG_VERSION,
41
+ host: hostname(),
42
+ caps: ['resume', 'cancel', 'queue'],
43
+ };
44
+ }
45
+
46
+ function sessionsPayload(state) {
47
+ return Object.values(state.sessions ?? {}).map(s => ({
48
+ key: s.key, sessionId: s.sessionId ?? null, agent: s.agent ?? 'claude',
49
+ cwd: s.cwd ?? null, status: s.status, limitType: s.limitType ?? null,
50
+ resetAt: s.resetAt ?? null, resetSource: s.resetSource ?? null,
51
+ mux: s.mux ?? null, pane: s.pane ?? null, muxSession: s.muxSession ?? null,
52
+ attempts: s.attempts ?? 0, lastError: s.lastError ?? null,
53
+ workspaceHold: !!s.workspaceHold,
54
+ }));
55
+ }
56
+
57
+ // Minimal, capped subset for the wire — shared by the `status` envelope's
58
+ // `queue` field and the `queue-list` verb's reply. Every field here is
59
+ // already sanitized at creation time (sanitizePrompt in queueAdd), but the
60
+ // emitter's subset stays minimal on top of that rather than leaning on
61
+ // "the client re-sanitizes anyway" as the only guard.
62
+ function queueEnvelopeEntries() {
63
+ return queueList().slice(0, QUEUE_STATUS_CAP).map(e => ({
64
+ id: e.id,
65
+ agent: e.agent,
66
+ cwd: e.cwd,
67
+ status: e.status,
68
+ mode: e.mode,
69
+ atMs: Number.isFinite(e.atMs) ? e.atMs : null,
70
+ notBefore: Number.isFinite(e.notBefore) ? e.notBefore : null,
71
+ attempts: Number.isFinite(e.attempts) ? e.attempts : 0,
72
+ deliveredAt: Number.isFinite(e.deliveredAt) ? e.deliveredAt : null,
73
+ lastError: e.lastError ?? null,
74
+ promptPreview: typeof e.prompt === 'string' ? e.prompt.slice(0, 80) : '',
75
+ }));
76
+ }
77
+
78
+ // base64url-decode -> JSON.parse -> re-validate every field into a fresh
79
+ // object literal. Never spreads the parsed object (prototype-pollution /
80
+ // field-smuggling defense, same rule fleet.js's sanitizeSession follows) —
81
+ // only these five named properties are ever read off `parsed`, so a hostile
82
+ // `__proto__`/`constructor`/extra-field payload has nothing to smuggle
83
+ // through. Returns null for anything that doesn't check out; the caller
84
+ // turns that into a flat 'bad-request'.
85
+ function decodeQueueAddPayload(payload) {
86
+ let json;
87
+ try {
88
+ json = Buffer.from(payload, 'base64url').toString('utf8');
89
+ } catch {
90
+ return null;
91
+ }
92
+ let parsed;
93
+ try {
94
+ parsed = JSON.parse(json);
95
+ } catch {
96
+ return null;
97
+ }
98
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
99
+
100
+ const cwd = parsed.cwd;
101
+ if (typeof cwd !== 'string' || !isAbsolute(cwd) || cwd.length > MAX_CWD_LEN) return null;
102
+ try {
103
+ if (!existsSync(cwd) || !statSync(cwd).isDirectory()) return null;
104
+ } catch {
105
+ return null;
106
+ }
107
+
108
+ const agent = parsed.agent;
109
+ if (typeof agent !== 'string' || !AGENT_IDS.has(agent)) return null;
110
+
111
+ const prompt = sanitizePrompt(parsed.prompt);
112
+ if (!prompt) return null;
113
+
114
+ const mode = parsed.mode;
115
+ if (!QUEUE_MODES.includes(mode)) return null;
116
+
117
+ let atMs = null;
118
+ if (mode === 'at') {
119
+ if (!Number.isFinite(parsed.atMs)) return null;
120
+ atMs = parsed.atMs;
121
+ }
122
+
123
+ return { cwd, agent, prompt, mode, atMs };
124
+ }
125
+
126
+ // Parse the verb either from our argv or, under a forced command, from
127
+ // SSH_ORIGINAL_COMMAND ("unsnooze _remote <verb> [arg]"). Reject anything
128
+ // that isn't exactly that shape — no shell ever sees these tokens again,
129
+ // but defense in depth costs one regex.
130
+ function resolveRequest(args) {
131
+ let tokens = args;
132
+ if (tokens.length === 0 && process.env.SSH_ORIGINAL_COMMAND) {
133
+ const parts = process.env.SSH_ORIGINAL_COMMAND.trim().split(/\s+/);
134
+ if (parts.length < 2 || parts.length > 4) return null;
135
+ if (!/(^|\/)unsnooze$/.test(parts[0]) || parts[1] !== '_remote') return null;
136
+ tokens = parts.slice(2);
137
+ }
138
+ const [verb, arg, ...extra] = tokens;
139
+ if (!VERBS.has(verb)) return null;
140
+ if (extra.length > 0) return null; // no verb takes more than one arg
141
+ if (NO_ARG_VERBS.has(verb)) {
142
+ if (arg != null) return null;
143
+ return { verb, arg: null };
144
+ }
145
+ const validator = ARG_VALIDATORS[verb];
146
+ if (arg == null || !validator.test(arg)) return null;
147
+ return { verb, arg };
148
+ }
149
+
150
+ export async function cmdRemote(args = []) {
151
+ const emit = (extra) => console.log(frameEnvelope({ ...handshake(), ...extra }));
152
+ try {
153
+ const req = resolveRequest(args);
154
+ if (!req) {
155
+ emit({ result: 'bad-request' });
156
+ return 1;
157
+ }
158
+
159
+ // Per-host opt-out: a host can refuse all four queue verbs regardless of
160
+ // what the controller asks for. Still a valid framed envelope — the
161
+ // controller needs a real, typed answer to tell "disabled" apart from
162
+ // "too old to know the verb" (see fleet.js's queueVerbCall).
163
+ if (QUEUE_VERBS.has(req.verb) && !getConfig('remoteQueue')) {
164
+ emit({ result: 'disabled' });
165
+ return 1;
166
+ }
167
+
168
+ if (req.verb === 'queue-add') {
169
+ const fresh = decodeQueueAddPayload(req.arg);
170
+ if (!fresh) {
171
+ emit({ result: 'bad-request' });
172
+ return 1;
173
+ }
174
+ // UNSNOOZE_REMOTE_TEST_NO_SPAWN is remote.js's own test-only escape
175
+ // hatch: queueAdd's spawn-on-success side effect (spawnResumerIfNeeded)
176
+ // is normal, wanted production behavior here (a remote host needs a
177
+ // resumer running to ever deliver the prompt) but has no wire-protocol
178
+ // way to opt out, unlike the local CLI's `spawn: false`. Tests that
179
+ // exercise this verb via a real subprocess (execFileSync) must not let
180
+ // that subprocess fork a real detached daemon — see remote.test.js.
181
+ const spawn = process.env.UNSNOOZE_REMOTE_TEST_NO_SPAWN !== '1';
182
+ const result = queueAdd({ ...fresh, createdBy: 'remote', spawn });
183
+ if (!result.ok) {
184
+ emit({ result: 'error', error: result.error });
185
+ return 1;
186
+ }
187
+ emit({ result: 'ok', id: result.entry.id });
188
+ return 0;
189
+ }
190
+ if (req.verb === 'queue-list') {
191
+ emit({ result: 'ok', queue: queueEnvelopeEntries() });
192
+ return 0;
193
+ }
194
+ if (req.verb === 'queue-remove') {
195
+ const removed = queueRemove(req.arg);
196
+ emit({ result: removed ? 'ok' : 'not-found' });
197
+ return removed ? 0 : 1;
198
+ }
199
+ if (req.verb === 'queue-clear') {
200
+ const cleared = queueClear();
201
+ emit({ result: 'ok', cleared });
202
+ return 0;
203
+ }
204
+
205
+ const state = readState();
206
+ if (req.verb === 'status') {
207
+ emit({ resumerAlive: !!state.resumerPid, sessions: sessionsPayload(state), queue: queueEnvelopeEntries() });
208
+ return 0;
209
+ }
210
+ const keys = selectKeys(state, req.arg);
211
+ if (keys.length === 0) {
212
+ emit({ result: 'no-match', matched: [] });
213
+ return 1;
214
+ }
215
+ if (req.verb === 'resume') markResumeNow(keys);
216
+ else for (const k of keys) setStatus(k, 'cancelled');
217
+ emit({ result: 'ok', matched: keys });
218
+ return 0;
219
+ } catch {
220
+ emit({ result: 'error' });
221
+ return 1;
222
+ }
223
+ }