-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathprompt-queue.js
More file actions
113 lines (101 loc) · 3.91 KB
/
Copy pathprompt-queue.js
File metadata and controls
113 lines (101 loc) · 3.91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
/* Phase 2 queue: box prompts wait for the polling /loop.
MCP is Claude-initiated, so push is impossible - poll it is. */
import { existsSync, rmSync } from 'node:fs';
const LISTENER_TIMEOUT_MS = 45_000; // active window, ~1.5x slow loop cadence
const SWEEP_INTERVAL_MS = 10_000;
const LONG_POLL_TIMEOUT_MS = 25_000; // under MCP's 60s and LISTENER_TIMEOUT_MS
const TERMINATORS = new Set(['/exit', '/stop', '/quit']); // DESIGN §10 stop words
export class PromptQueue {
constructor({ dispatcher, audit, stopFilePath }) {
this.dispatcher = dispatcher;
this.audit = audit;
this.stopFilePath = stopFilePath;
this.queue = [];
this.terminate = false;
this.listenerLastSeen = 0;
this.listenerActive = false;
this._sweep = null;
this._waiters = new Set(); // in-flight long-poll resolvers
dispatcher.subscribe('claude.prompt', (p) => this._onPrompt(p || {}));
dispatcher.subscribe('claude.hello', () => this._broadcastStatus()); // fresh box wants status now
}
start() {
if (this._sweep) return;
// Quiet listener flips the box to "no-listener" - no typing into the void.
this._sweep = setInterval(() => {
this._checkStopFile(); // catch .loop-stop mid-idle, not next timeout
if (this.terminate) this._wake();
if (this.listenerActive && Date.now() - this.listenerLastSeen > LISTENER_TIMEOUT_MS) {
this.listenerActive = false;
this._broadcastStatus();
}
}, SWEEP_INTERVAL_MS);
this._sweep.unref?.();
}
stop() {
if (this._sweep) { clearInterval(this._sweep); this._sweep = null; }
}
_onPrompt({ promptId, text }) {
const trimmed = (text || '').trim();
if (TERMINATORS.has(trimmed.toLowerCase())) {
this.terminate = true;
this.audit.log('chat.terminate', { via: trimmed.toLowerCase() });
this._broadcastStatus();
this._wake();
return;
}
this.queue.push({ promptId: promptId || `p-${Date.now()}`, text: text ?? '', ts: new Date().toISOString() });
this.audit.log('chat.in', { promptId, len: (text || '').length });
this._broadcastStatus(); // reflect listener presence as the user types
this._wake(); // release any in-flight long-poll immediately
}
// Kill file works even with the box/relay link down. Idempotent.
_checkStopFile() {
if (!this.stopFilePath || !existsSync(this.stopFilePath)) return;
if (!this.terminate) {
this.terminate = true;
this.audit.log('chat.terminate', { via: '.loop-stop' });
}
try { rmSync(this.stopFilePath); } catch { /* best-effort; flag already set */ }
}
_wake() {
if (this._waiters.size === 0) return;
for (const w of [...this._waiters]) w();
}
// Long-poll: resolves on work or timeout; drain() follows.
async waitForWork({ timeoutMs = LONG_POLL_TIMEOUT_MS } = {}) {
this._checkStopFile();
if (this.terminate || this.queue.length) return;
await new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timer);
this._waiters.delete(finish);
resolve();
};
const timer = setTimeout(finish, timeoutMs);
this._waiters.add(finish);
});
}
// Draining counts as a poll; the box flips "ready".
drain() {
this.listenerLastSeen = Date.now();
if (!this.listenerActive) {
this.listenerActive = true;
this._broadcastStatus();
}
this._checkStopFile();
// Consume-once terminate: a stale flag never kills a fresh loop.
const terminate = this.terminate;
this.terminate = false;
const prompts = this.queue.splice(0, this.queue.length);
return { prompts, terminate };
}
_broadcastStatus() {
// Strings live in lang/en.json; 'disconnected' is detected box-side.
const state = this.listenerActive ? 'ready' : 'no-listener';
this.dispatcher.notifyBridge({ capabilitySet: 'debug', method: 'claude.status', params: { state } });
}
}