-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathprompt-queue.js
More file actions
152 lines (139 loc) · 5.46 KB
/
Copy pathprompt-queue.js
File metadata and controls
152 lines (139 loc) · 5.46 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
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
// Phase 2 chat queue: box claude.prompt in, claude.reply back.
// Poll not push; MCP is Claude-initiated (foundry_get_prompts drains).
import { existsSync, rmSync } from 'node:fs';
const LISTENER_TIMEOUT_MS = 45_000; /* "active" window, ~1.5x slow loop */
const SWEEP_INTERVAL_MS = 10_000;
// Long-poll cap: under MCP's 60s timeout, under LISTENER_TIMEOUT_MS
// so an idle polling loop still reads "ready".
const LONG_POLL_TIMEOUT_MS = 25_000;
// Typed stop words (DESIGN §10 Phase 2).
const TERMINATORS = new Set(['/exit', '/stop', '/quit']);
const TAB_CLOSE = '/close'; /* §14: closes one tab, stops its agent */
export class PromptQueue {
constructor({ dispatcher, audit, stopFilePath, tabs }) {
this.dispatcher = dispatcher;
this.audit = audit;
this.tabs = tabs; /* §14 tab table; prompts land in a tab */
// Box x, typed /close, or claude.tab.close: all end here as one prompt
// the loop handles in its normal per-prompt path (never missable).
if (tabs) tabs.onClose = (tabId) => {
this.queue.push({ promptId: `close-${tabId}-${Date.now()}`, text: TAB_CLOSE, tabId, close: true, ts: new Date().toISOString() });
this.audit.log('chat.close', { tabId });
this._wake();
};
this.stopFilePath = stopFilePath;
this.queue = [];
this.terminate = false;
this.listenerLastSeen = 0;
this.listenerActive = false;
this.activeListenerId = null;
this._sweep = null;
this._waiters = new Set(); // resolve fns for in-flight long-polls
dispatcher.subscribe('claude.prompt', (p) => this._onPrompt(p || {}));
// Box open/reconnect wants status immediately.
dispatcher.subscribe('claude.hello', () => this._broadcastStatus());
}
start() {
if (this._sweep) return;
// Quiet listener flips box to "no-listener".
this._sweep = setInterval(() => {
// Sweep catches .loop-stop dropped mid-idle-poll.
this._checkStopFile();
if (this.terminate) this._wake();
if (this.listenerActive && Date.now() - this.listenerLastSeen > LISTENER_TIMEOUT_MS) {
this.listenerActive = false;
this.activeListenerId = null; /* quiet loop frees the slot */
this._broadcastStatus();
}
}, SWEEP_INTERVAL_MS);
this._sweep.unref?.();
}
stop() {
if (this._sweep) { clearInterval(this._sweep); this._sweep = null; }
}
_onPrompt({ promptId, text, tabId }) {
const trimmed = (text || '').trim();
if (TERMINATORS.has(trimmed.toLowerCase())) {
this.terminate = true;
this.tabs?.reset('terminate');
this.audit.log('chat.terminate', { via: trimmed.toLowerCase() });
this._broadcastStatus();
this._wake();
return;
}
if (trimmed.toLowerCase() === TAB_CLOSE) {
if (this.tabs?.tabs.has(tabId)) this.tabs.close(tabId); /* onClose enqueues */
return;
}
const tab = this.tabs?.prompt(tabId, text ?? '');
this.queue.push({ promptId: promptId || `p-${Date.now()}`, text: text ?? '', tabId: tab, ts: new Date().toISOString() });
this.audit.log('chat.in', { promptId, tabId: tab, len: (text || '').length });
// Refresh status as the user types.
this._broadcastStatus();
this._wake(); // release in-flight long-polls immediately
}
// Local kill file; works even 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();
}
// §13.2 single-listener lock; second id refused (-33005).
// Frees on terminate/timeout. No more split-brain (2026-08-13).
claimListener(listenerId) {
if (this.listenerActive && this.activeListenerId && listenerId !== this.activeListenerId) return false;
this.activeListenerId = listenerId;
return true;
}
// Long-poll: resolve on work/terminate or timeoutMs.
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; box flips "ready".
drain() {
this.listenerLastSeen = Date.now();
if (!this.listenerActive) {
this.listenerActive = true;
this._broadcastStatus();
}
this._checkStopFile();
// Consume-once terminate; fresh loop survives stale flag.
const terminate = this.terminate;
this.terminate = false;
if (terminate) {
// Free slot now; relaunch never waits 45s.
this.activeListenerId = null;
this.listenerActive = false;
this.tabs?.reset('terminate'); /* .loop-stop path */
this._broadcastStatus();
}
const prompts = this.queue.splice(0, this.queue.length);
return { prompts, terminate };
}
_broadcastStatus() {
// Module localizes; 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 } });
}
}