cc-viewer 1.9.14 → 1.9.16
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/cli.js +1 -0
- package/concepts/ar/UltraPlan.md +9 -9
- package/concepts/da/UltraPlan.md +9 -9
- package/concepts/de/UltraPlan.md +9 -9
- package/concepts/en/UltraPlan.md +9 -9
- package/concepts/es/UltraPlan.md +9 -9
- package/concepts/fr/UltraPlan.md +9 -9
- package/concepts/it/UltraPlan.md +9 -9
- package/concepts/ja/UltraPlan.md +9 -9
- package/concepts/ko/UltraPlan.md +9 -9
- package/concepts/no/UltraPlan.md +9 -9
- package/concepts/pl/UltraPlan.md +9 -9
- package/concepts/pt-BR/UltraPlan.md +9 -9
- package/concepts/ru/UltraPlan.md +9 -9
- package/concepts/th/UltraPlan.md +9 -9
- package/concepts/tr/UltraPlan.md +9 -9
- package/concepts/uk/UltraPlan.md +9 -9
- package/concepts/zh/UltraPlan.md +9 -9
- package/concepts/zh-TW/UltraPlan.md +9 -9
- package/dist/assets/App-BvVdj099.css +1 -0
- package/dist/assets/App-CYdOioAz.js +2 -0
- package/dist/assets/{MdxEditorPanel-DI3B4gsM.js → MdxEditorPanel-CuCAe1OG.js} +1 -1
- package/dist/assets/Mobile-CfNYOgRa.js +1 -0
- package/dist/assets/{ProxyStatsModal-B8K0AtAq.js → ProxyStatsModal-Dhj4EVnh.js} +1 -1
- package/dist/assets/index-sgW8MlD3.js +2 -0
- package/dist/assets/seqResourceLoaders-B-5xGs9q.css +41 -0
- package/dist/assets/seqResourceLoaders-Cj0pdz-l.js +2 -0
- package/dist/index.html +1 -1
- package/node_modules/@ccv/core/src/contentFilter.js +3 -0
- package/node_modules/@ccv/core/src/queued-prompt.js +66 -0
- package/node_modules/@ccv/core/src/v2-transcript-normalizer.js +78 -14
- package/package.json +1 -1
- package/server/lib/ask/perm-bridge.js +4 -1
- package/server/lib/auto-approve.js +160 -0
- package/server/lib/chat-queue.js +350 -120
- package/server/lib/file-access-policy.js +24 -2
- package/server/lib/scheduled-task-executor.js +3 -1
- package/server/lib/sdk-manager.js +377 -76
- package/server/lib/v2/conversation-store.js +15 -11
- package/server/lib/v2/v2-writer.js +1 -1
- package/server/lib/view-root.js +4 -4
- package/server/pty-manager.js +8 -8
- package/server/routes/ask-perm.js +54 -3
- package/server/routes/files-content.js +352 -16
- package/server/routes/files-fs.js +122 -36
- package/server/routes/resume.js +1 -1
- package/server/routes/sdk-context.js +13 -2
- package/server/routes/skills.js +19 -8
- package/server/server.js +205 -34
- package/server/system-prompt-templates/presets/kimi-k3.md +1 -0
- package/ultraAgents/code-expert.json +1 -1
- package/dist/assets/App-BzV1ieDH.js +0 -2
- package/dist/assets/App-DU5jmGrx.css +0 -1
- package/dist/assets/Mobile-qBkAnXy3.js +0 -1
- package/dist/assets/index-CU0gt6m1.js +0 -2
- package/dist/assets/seqResourceLoaders-CxUFE36z.css +0 -41
- package/dist/assets/seqResourceLoaders-nGCs11zu.js +0 -2
package/server/lib/chat-queue.js
CHANGED
|
@@ -6,10 +6,19 @@
|
|
|
6
6
|
* (`queue-state`) so every client can render floating bubbles above the composer, and drains
|
|
7
7
|
* the queue into the PTY (bracketed paste + Enter) once the turn really ends.
|
|
8
8
|
*
|
|
9
|
+
* Queue status is server-authoritative: snapshot items are `queued` until the dispatcher takes
|
|
10
|
+
* one, then `dispatching` until the paste write completes. A single dispatch slot owns the
|
|
11
|
+
* item currently being interrupted/awaited/written, so a dispatching bubble stays visible and
|
|
12
|
+
* cannot be removed or re-sent by a second click; if dispatch fails it returns to its original
|
|
13
|
+
* FIFO position. Snapshot items also carry their sanitized anchor (project/sessionId/
|
|
14
|
+
* instanceKey) so views can filter per scope; every item-triggered broadcast repeats that
|
|
15
|
+
* scope on the message and passes the public item as the optional second `broadcastWs` arg
|
|
16
|
+
* (the server-side callback is upgraded separately).
|
|
17
|
+
*
|
|
9
18
|
* Per-bubble actions:
|
|
10
19
|
* - "Send now" (queue-send-now) interrupts the running turn (focus-in + ESC, same bytes as the
|
|
11
20
|
* web Stop button) and injects that message as soon as the TUI is observably idle.
|
|
12
|
-
* - "Remove" (queue-remove) drops it.
|
|
21
|
+
* - "Remove" (queue-remove) drops it — except while it is dispatching.
|
|
13
22
|
* Stop keeps the queue parked (product decision): `suppressNextDrain()` blocks the automatic
|
|
14
23
|
* turn-end drain until a NEW turn starts (streaming rising edge) or a message is sent now.
|
|
15
24
|
*
|
|
@@ -33,9 +42,14 @@ const INJECT_POLL_MS = 100; // idle poll cadence before injecting
|
|
|
33
42
|
const INJECT_POLL_MAX_MS = 2000; // give up polling after this; NEVER inject unconditionally
|
|
34
43
|
const RESCUE_AFTER_MS = 3000; // stale-busy rescue: frontend guessed busy while the turn already ended
|
|
35
44
|
|
|
45
|
+
// Anchor fields exposed on snapshots / broadcasts. strictAnchor (scheduled tasks) is an
|
|
46
|
+
// internal write-path flag and must never leak into client-visible state.
|
|
47
|
+
const SCOPE_KEYS = ['project', 'sessionId', 'instanceKey'];
|
|
48
|
+
|
|
36
49
|
// Injected deps (see initChatQueue). Null until wired; every public entry no-ops safely then.
|
|
37
50
|
let _deps = null;
|
|
38
|
-
let _items = []; // [{ id, text, ts }] FIFO
|
|
51
|
+
let _items = []; // [{ id, text, ts, anchor }] queued FIFO (dispatching item is NOT here)
|
|
52
|
+
let _dispatch = null; // { item, index } single dispatch slot: at most one item is dispatching
|
|
39
53
|
let _injecting = false; // single-flight guard around one bracket-paste inject sequence
|
|
40
54
|
let _rescueTimer = null; // stale-busy rescue timer
|
|
41
55
|
let _pollTimer = null; // active idle-poll interval (single slot — see _pollUntilSafe)
|
|
@@ -47,24 +61,111 @@ let _gen = 0; // generation counter: clear()/stop() invalidate in-
|
|
|
47
61
|
* @param {object} deps
|
|
48
62
|
* @param {function} deps.writeToPty — (data:string) => void
|
|
49
63
|
* @param {function} deps.writeToPtySequential — (chunks:string[], onComplete:(ok:boolean)=>void, opts?) => void
|
|
64
|
+
* @param {function} [deps.writeToPtySequentialFor] — anchor-routed variant (strict scheduled tasks)
|
|
50
65
|
* @param {function} deps.getPtyKind — () => 'claude' | 'shell' | null
|
|
51
66
|
* @param {function} deps.isStreaming — () => boolean (per-API-call streaming flag; flickers between tool calls)
|
|
52
67
|
* @param {function} deps.hasPendingApproval — () => boolean (web-visible ask/perm modal open)
|
|
53
68
|
* @param {function} [deps.hasExternalInjection] — () => boolean (IM bridge injection slot busy)
|
|
54
|
-
* @param {function} deps.
|
|
69
|
+
* @param {function} [deps.hasLivePtyFor] — (anchor) => boolean|undefined; false only when
|
|
70
|
+
* the exact target is provably dead. Unknown/unavailable means a transient write failure.
|
|
71
|
+
* @param {function} deps.broadcastWs — (msg:object, item?:object) => void
|
|
72
|
+
* `item` is the sanitized snapshot item that triggered the change (routing hint for the
|
|
73
|
+
* per-view broadcast upgrade); the message itself carries `scope` when one is known.
|
|
55
74
|
*/
|
|
56
75
|
export function initChatQueue(deps) {
|
|
57
76
|
_deps = deps;
|
|
58
77
|
}
|
|
59
78
|
|
|
60
|
-
|
|
61
|
-
|
|
79
|
+
function _normalizeScope(anchor) {
|
|
80
|
+
const scope = {};
|
|
81
|
+
if (!anchor || typeof anchor !== 'object') return scope;
|
|
82
|
+
for (const key of SCOPE_KEYS) {
|
|
83
|
+
if (typeof anchor[key] === 'string' && anchor[key]) scope[key] = anchor[key];
|
|
84
|
+
}
|
|
85
|
+
return scope;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function _hasScope(scope) {
|
|
89
|
+
return SCOPE_KEYS.some((key) => !!scope[key]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Public (sanitized) scope of an item; null when the item carries no usable anchor. */
|
|
93
|
+
function _scopeOf(item) {
|
|
94
|
+
const scope = _normalizeScope(item && item.anchor);
|
|
95
|
+
return _hasScope(scope) ? scope : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A scope filter matches when every supplied field matches (instanceKey narrows project). */
|
|
99
|
+
function _matchesScope(itemAnchor, filter) {
|
|
100
|
+
const scopeFilter = filter || {};
|
|
101
|
+
const itemScope = _normalizeScope(itemAnchor);
|
|
102
|
+
if (scopeFilter.project && itemScope.project !== scopeFilter.project) return false;
|
|
103
|
+
if (scopeFilter.sessionId && itemScope.sessionId !== scopeFilter.sessionId) return false;
|
|
104
|
+
if (!scopeFilter.instanceKey || !itemScope.instanceKey) return true;
|
|
105
|
+
return itemScope.instanceKey === scopeFilter.instanceKey;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Snapshot shape: public fields + derived status + sanitized anchor, never internal flags. */
|
|
109
|
+
function _publicItem(item, status) {
|
|
110
|
+
return {
|
|
111
|
+
id: item.id,
|
|
112
|
+
text: item.text,
|
|
113
|
+
ts: item.ts,
|
|
114
|
+
status,
|
|
115
|
+
anchor: _scopeOf(item),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Server-authoritative queue snapshot. `anchor` optionally filters to one view scope (all
|
|
121
|
+
* supplied fields must match; unscoped items never match a scoped filter). Default call keeps
|
|
122
|
+
* the historical "whole queue" behavior. The dispatching item stays in FIFO position with
|
|
123
|
+
* status 'dispatching'; the rest are 'queued'.
|
|
124
|
+
*/
|
|
125
|
+
export function getSnapshot(anchor) {
|
|
126
|
+
const filter = _normalizeScope(anchor);
|
|
127
|
+
const scoped = _hasScope(filter);
|
|
128
|
+
const include = (item) => !scoped || _matchesScope(item.anchor, filter);
|
|
129
|
+
const snapshot = _items.filter(include).map((item) => _publicItem(item, 'queued'));
|
|
130
|
+
if (!_dispatch || !include(_dispatch.item)) return snapshot;
|
|
131
|
+
const before = _items.slice(0, _dispatch.index).filter(include).length;
|
|
132
|
+
snapshot.splice(Math.max(0, Math.min(before, snapshot.length)), 0, _publicItem(_dispatch.item, 'dispatching'));
|
|
133
|
+
return snapshot;
|
|
62
134
|
}
|
|
63
135
|
|
|
64
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Broadcast a queue-state frame. `triggerItem` supplies the item scope for per-view routing:
|
|
138
|
+
* the scope is repeated on the message and the public item is passed as broadcastWs's optional
|
|
139
|
+
* second argument. A trigger without an id (synthetic `{ anchor }`) contributes only the scope.
|
|
140
|
+
*/
|
|
141
|
+
function _broadcast(triggerItem, triggerStatus) {
|
|
65
142
|
if (!_deps || !_deps.broadcastWs) return;
|
|
66
|
-
|
|
67
|
-
|
|
143
|
+
const msg = { type: 'queue-state', items: getSnapshot() };
|
|
144
|
+
const scope = _scopeOf(triggerItem);
|
|
145
|
+
if (scope) msg.scope = scope;
|
|
146
|
+
try {
|
|
147
|
+
if (triggerItem && triggerItem.id) {
|
|
148
|
+
_deps.broadcastWs(msg, _publicItem(triggerItem, triggerStatus || 'queued'));
|
|
149
|
+
} else {
|
|
150
|
+
_deps.broadcastWs(msg);
|
|
151
|
+
}
|
|
152
|
+
} catch (err) { reportSwallowed('chat-queue.broadcast', err); }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Negative ack (enqueue full / strict target gone), scoped to the affected item/anchor. */
|
|
156
|
+
function _broadcastRejected(reason, item, anchor) {
|
|
157
|
+
if (!_deps || !_deps.broadcastWs) return;
|
|
158
|
+
const scope = _normalizeScope(anchor || (item && item.anchor));
|
|
159
|
+
const msg = { type: 'queue-rejected', reason };
|
|
160
|
+
if (item && item.id) msg.id = item.id;
|
|
161
|
+
if (_hasScope(scope)) msg.scope = scope;
|
|
162
|
+
try {
|
|
163
|
+
if (item && item.id) {
|
|
164
|
+
_deps.broadcastWs(msg, _publicItem(item, 'queued'));
|
|
165
|
+
} else {
|
|
166
|
+
_deps.broadcastWs(msg);
|
|
167
|
+
}
|
|
168
|
+
} catch (err) { reportSwallowed('chat-queue.reject-broadcast', err); }
|
|
68
169
|
}
|
|
69
170
|
|
|
70
171
|
function _makeItem(text, anchor) {
|
|
@@ -72,7 +173,7 @@ function _makeItem(text, anchor) {
|
|
|
72
173
|
id: 'q_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8),
|
|
73
174
|
text,
|
|
74
175
|
ts: Date.now(),
|
|
75
|
-
anchor: anchor || null, // { project, sessionId
|
|
176
|
+
anchor: anchor || null, // { project, sessionId, instanceKey[, strictAnchor] } (2026-10-05)
|
|
76
177
|
};
|
|
77
178
|
}
|
|
78
179
|
|
|
@@ -121,54 +222,97 @@ function _safeToInject() {
|
|
|
121
222
|
}
|
|
122
223
|
|
|
123
224
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* TUI) and again if the write fails and the item returns to the head.
|
|
225
|
+
* Take an item out of the queued FIFO into the single dispatch slot and broadcast it as
|
|
226
|
+
* 'dispatching' BEFORE any timer/write starts. `index` is its original FIFO position so a
|
|
227
|
+
* failed dispatch can be restored there (never silently promoted to the head).
|
|
128
228
|
*/
|
|
129
|
-
function
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
229
|
+
function _startDispatch(item, index) {
|
|
230
|
+
_dispatch = { item, index };
|
|
231
|
+
_broadcast(item, 'dispatching');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Put the dispatching item back at its original FIFO position (poll timeout / write failure). */
|
|
235
|
+
function _restoreDispatch() {
|
|
236
|
+
if (!_dispatch) return false;
|
|
237
|
+
const { item, index } = _dispatch;
|
|
238
|
+
_dispatch = null;
|
|
239
|
+
const at = Math.max(0, Math.min(index, _items.length));
|
|
240
|
+
_items.splice(at, 0, item);
|
|
241
|
+
_broadcast(item, 'queued');
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function _targetGone(item) {
|
|
246
|
+
if (item.anchor && item.anchor.strictAnchor) return true;
|
|
247
|
+
if (!item.anchor || typeof _deps?.hasLivePtyFor !== 'function') return false;
|
|
248
|
+
try { return _deps.hasLivePtyFor(item.anchor) === false; }
|
|
249
|
+
catch (err) { reportSwallowed('chat-queue.liveness-probe', err); return false; }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Resolve a failed dispatch without resurrecting a superseded item or poisoning FIFO. */
|
|
253
|
+
function _failDispatch(item, gen, tag) {
|
|
254
|
+
if (gen !== _gen || !_dispatch || _dispatch.item.id !== item.id) return;
|
|
255
|
+
if (!_targetGone(item)) { _restoreDispatch(); return; }
|
|
256
|
+
_dispatch = null;
|
|
257
|
+
_broadcast(item, 'dispatching');
|
|
258
|
+
reportSwallowed('chat-queue.target-gone', new Error('queued prompt target is no longer live'));
|
|
259
|
+
_broadcastRejected('target-gone', item, item.anchor);
|
|
260
|
+
if (tag === 'chat-queue.inject' && gen === _gen && !_suppressDrain && !_dispatch && !_injecting && !_pollTimer && !_escTimer) {
|
|
261
|
+
_injectHead();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Write the dispatching item as bracket paste, then resolve the slot:
|
|
267
|
+
* - success: remove it (dispatch slot cleared, snapshot rebroadcast),
|
|
268
|
+
* - confirmed dead target: drop it and negatively ack instead of blocking other scopes,
|
|
269
|
+
* - any other failure/throw: restore it to its original FIFO position.
|
|
270
|
+
* A generation bump (clear/stop) invalidates the completion before any of the above.
|
|
271
|
+
*/
|
|
272
|
+
function _writeDispatch(item, gen, tag) {
|
|
273
|
+
// Route the inject by the enqueueing client's anchor when one was captured,
|
|
274
|
+
// so a message queued while viewing project A never lands in project B's PTY.
|
|
275
|
+
// strictAnchor (scheduled tasks) additionally forbids the drain path's
|
|
276
|
+
// project/active fallback: a dead explicit instance fails the write instead of
|
|
277
|
+
// landing in the wrong conversation (review P0-2).
|
|
278
|
+
const strict = !!(item.anchor && Object.keys(item.anchor).length > 0);
|
|
279
|
+
const write = _deps.writeToPtySequentialFor && item.anchor
|
|
280
|
+
? (chunks, done, opts) => _deps.writeToPtySequentialFor(chunks, done, { ...(opts || {}), strict }, item.anchor)
|
|
281
|
+
: _deps.writeToPtySequential;
|
|
134
282
|
_injecting = true;
|
|
135
|
-
const gen = _gen; // invalidate on clear()/stop(): a stale completion must not re-park
|
|
136
283
|
try {
|
|
137
|
-
|
|
138
|
-
// so a message queued while viewing project A never lands in project B's PTY.
|
|
139
|
-
// strictAnchor (scheduled tasks) additionally forbids the drain path's
|
|
140
|
-
// project/active fallback: a dead explicit instance fails the write instead of
|
|
141
|
-
// landing in the wrong conversation (review P0-2).
|
|
142
|
-
const strict = !!(item.anchor && item.anchor.strictAnchor);
|
|
143
|
-
const _write = _deps.writeToPtySequentialFor && item.anchor
|
|
144
|
-
? (chunks, done, opts) => _deps.writeToPtySequentialFor(chunks, done, { ...(opts || {}), strict }, item.anchor)
|
|
145
|
-
: _deps.writeToPtySequential;
|
|
146
|
-
_write(bracketPasteSubmit(item.text), (ok) => {
|
|
284
|
+
write(bracketPasteSubmit(item.text), (ok) => {
|
|
147
285
|
_injecting = false;
|
|
148
|
-
if (gen !== _gen) return;
|
|
149
|
-
if (!
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (strict) {
|
|
155
|
-
reportSwallowed('chat-queue.strict-drop', new Error('scheduled inject target gone'));
|
|
156
|
-
try { _deps.broadcastWs({ type: 'queue-rejected', reason: 'target-gone' }); } catch { /* best-effort */ }
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
// Dead PTY / write failure — never lose the message silently; put it back.
|
|
160
|
-
_items.unshift(item);
|
|
161
|
-
_broadcast();
|
|
286
|
+
if (gen !== _gen) return; // clear()/stop() mid-flight: never re-park into a fresh queue
|
|
287
|
+
if (!_dispatch || _dispatch.item.id !== item.id) return;
|
|
288
|
+
if (ok) {
|
|
289
|
+
_dispatch = null;
|
|
290
|
+
_broadcast(item, 'dispatching');
|
|
291
|
+
return;
|
|
162
292
|
}
|
|
293
|
+
_failDispatch(item, gen, tag);
|
|
163
294
|
}, { settleMs: 250 });
|
|
164
295
|
} catch (err) {
|
|
165
296
|
_injecting = false;
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
_broadcast();
|
|
169
|
-
}
|
|
170
|
-
reportSwallowed('chat-queue.inject', err);
|
|
297
|
+
_failDispatch(item, gen, tag);
|
|
298
|
+
reportSwallowed(tag, err);
|
|
171
299
|
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Inject the head item. Caller must have verified _safeToInject() first (except the internal
|
|
304
|
+
* re-checks here, which keep this self-guarding for direct calls). The item moves into the
|
|
305
|
+
* dispatch slot (and is broadcast as 'dispatching') before the PTY write starts; a failed
|
|
306
|
+
* write restores it at the head.
|
|
307
|
+
*/
|
|
308
|
+
function _injectHead() {
|
|
309
|
+
if (!_deps || _dispatch) return false;
|
|
310
|
+
if (!_safeToInject()) return false;
|
|
311
|
+
if (_items.length === 0) return false;
|
|
312
|
+
const item = _items.shift();
|
|
313
|
+
const gen = _gen;
|
|
314
|
+
_startDispatch(item, 0);
|
|
315
|
+
_writeDispatch(item, gen, 'chat-queue.inject');
|
|
172
316
|
return true;
|
|
173
317
|
}
|
|
174
318
|
|
|
@@ -205,17 +349,17 @@ export function enqueue(text, anchor) {
|
|
|
205
349
|
if (!_deps) return null;
|
|
206
350
|
const clean = sanitizeInbound(text == null ? '' : String(text)).trim();
|
|
207
351
|
if (!clean) return null;
|
|
208
|
-
|
|
352
|
+
// The dispatch slot is still an outstanding message: count it against the cap.
|
|
353
|
+
if (_items.length + (_dispatch ? 1 : 0) >= MAX_ITEMS) {
|
|
209
354
|
// Negative ack: the frontend cleared the composer optimistically — never fail silently.
|
|
210
355
|
console.warn('[chat-queue] queue full, rejecting message');
|
|
211
|
-
|
|
212
|
-
catch (err) { reportSwallowed('chat-queue.reject-broadcast', err); }
|
|
356
|
+
_broadcastRejected('full', null, anchor);
|
|
213
357
|
return null;
|
|
214
358
|
}
|
|
215
359
|
const item = _makeItem(clean, anchor);
|
|
216
360
|
_items.push(item);
|
|
217
361
|
_clearRescueTimer();
|
|
218
|
-
_broadcast();
|
|
362
|
+
_broadcast(item, 'queued');
|
|
219
363
|
let streaming = false;
|
|
220
364
|
try { streaming = !!(_deps.isStreaming && _deps.isStreaming()); } catch { streaming = false; }
|
|
221
365
|
if (!streaming) {
|
|
@@ -225,92 +369,177 @@ export function enqueue(text, anchor) {
|
|
|
225
369
|
// retries within the poll budget instead of stranding the item.
|
|
226
370
|
_rescueTimer = setTimeout(() => {
|
|
227
371
|
_rescueTimer = null;
|
|
228
|
-
|
|
372
|
+
// Parked, or a drain/send-now poll/dispatch already owns the next inject.
|
|
373
|
+
if (_suppressDrain || _pollTimer || _dispatch) return;
|
|
229
374
|
if (_safeToInject()) { _injectHead(); return; }
|
|
230
|
-
_pollUntilSafe(() => { if (!_suppressDrain) _injectHead(); }, () => { /* keep queued */ });
|
|
375
|
+
_pollUntilSafe(() => { if (!_suppressDrain && !_dispatch) _injectHead(); }, () => { /* keep queued */ });
|
|
231
376
|
}, RESCUE_AFTER_MS);
|
|
232
377
|
if (typeof _rescueTimer.unref === 'function') _rescueTimer.unref();
|
|
233
378
|
}
|
|
234
379
|
return item;
|
|
235
380
|
}
|
|
236
381
|
|
|
237
|
-
/**
|
|
238
|
-
|
|
382
|
+
/**
|
|
383
|
+
* Remove one queued item by id. Returns true when found.
|
|
384
|
+
* A dispatching item is owned by the dispatch slot: the removal is rejected and the current
|
|
385
|
+
* snapshot is rebroadcast so a client that optimistically hid the bubble restores it.
|
|
386
|
+
*/
|
|
387
|
+
export function remove(id, anchor) {
|
|
388
|
+
if (_dispatch && _dispatch.item.id === id) {
|
|
389
|
+
_broadcast(_dispatch.item, 'dispatching');
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
239
392
|
const idx = _items.findIndex((it) => it.id === id);
|
|
240
393
|
if (idx < 0) return false;
|
|
394
|
+
const item = _items[idx];
|
|
395
|
+
if (!_matchesScope(item.anchor, _normalizeScope(anchor))) {
|
|
396
|
+
_broadcast(item, 'queued');
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
241
399
|
_items.splice(idx, 1);
|
|
242
|
-
|
|
400
|
+
// Keep the dispatch slot's remembered FIFO position meaningful after a removal ahead of it.
|
|
401
|
+
if (_dispatch && idx < _dispatch.index) _dispatch.index -= 1;
|
|
402
|
+
_broadcast(item, 'queued');
|
|
243
403
|
return true;
|
|
244
404
|
}
|
|
245
405
|
|
|
246
|
-
/**
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
406
|
+
/**
|
|
407
|
+
* Drop queued items (session/workspace switch). With no anchor this keeps the historical
|
|
408
|
+
* full-clear behavior; with an anchor only items whose sanitized scope matches every supplied
|
|
409
|
+
* field are dropped. Also cancels pending timers and invalidates any in-flight inject whose
|
|
410
|
+
* dispatch slot was dropped (gen bump) so an old-session message can never land in a fresh PTY.
|
|
411
|
+
* Cannot cancel bytes already written to the PTY (best-effort, ~330ms window).
|
|
412
|
+
*/
|
|
413
|
+
export function clear(anchor) {
|
|
414
|
+
const filter = _normalizeScope(anchor);
|
|
415
|
+
if (!_hasScope(filter)) {
|
|
416
|
+
// Full clear (legacy callers and stop/start paths).
|
|
417
|
+
_gen++;
|
|
418
|
+
_clearRescueTimer();
|
|
419
|
+
_clearPollTimer();
|
|
420
|
+
_clearEscTimer();
|
|
421
|
+
const hadState = !!_dispatch || _items.length > 0;
|
|
422
|
+
_dispatch = null;
|
|
423
|
+
_items = [];
|
|
424
|
+
if (hadState) _broadcast();
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Scoped clear: keep other views' queued items AND their in-flight dispatch untouched.
|
|
429
|
+
let removedQueued = false;
|
|
430
|
+
const dispatchIndex = _dispatch ? _dispatch.index : 0;
|
|
431
|
+
let removedBeforeDispatch = 0;
|
|
432
|
+
const kept = [];
|
|
433
|
+
_items.forEach((item, idx) => {
|
|
434
|
+
if (_matchesScope(item.anchor, filter)) {
|
|
435
|
+
removedQueued = true;
|
|
436
|
+
if (_dispatch && idx < dispatchIndex) removedBeforeDispatch += 1;
|
|
437
|
+
} else {
|
|
438
|
+
kept.push(item);
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
_items = kept;
|
|
442
|
+
if (_dispatch) _dispatch.index = Math.max(0, dispatchIndex - removedBeforeDispatch);
|
|
443
|
+
|
|
444
|
+
const droppedItem = _dispatch && _matchesScope(_dispatch.item.anchor, filter) ? _dispatch.item : null;
|
|
445
|
+
if (droppedItem) {
|
|
446
|
+
// This dispatch belonged to the cleared scope: invalidate its completion so a late
|
|
447
|
+
// failure cannot requeue it after the switch.
|
|
448
|
+
_gen++;
|
|
449
|
+
_clearPollTimer();
|
|
450
|
+
_clearEscTimer();
|
|
451
|
+
_dispatch = null;
|
|
452
|
+
}
|
|
453
|
+
if (_items.length === 0 && !_dispatch) _clearRescueTimer();
|
|
454
|
+
if (removedQueued || droppedItem) {
|
|
455
|
+
// A dropped dispatch carries its own scope; a pure queued-item clear is scoped by the
|
|
456
|
+
// filter itself (synthetic trigger, no item second argument).
|
|
457
|
+
_broadcast(droppedItem || { anchor: filter }, droppedItem ? 'dispatching' : undefined);
|
|
458
|
+
}
|
|
257
459
|
}
|
|
258
460
|
|
|
259
461
|
/**
|
|
260
462
|
* Send-now: interrupt the running turn and inject this item immediately.
|
|
261
|
-
*
|
|
262
|
-
*
|
|
463
|
+
* The item moves into the dispatch slot BEFORE the focus/ESC timer or any write, so it stays
|
|
464
|
+
* visible as 'dispatching' throughout the interrupt/poll/write phases and cannot be acted on
|
|
465
|
+
* twice. On poll timeout / write failure / throw it returns to its original FIFO position.
|
|
466
|
+
*
|
|
467
|
+
* Guard order matters (review P0): refuse BEFORE touching the queue when an inject or another
|
|
468
|
+
* dispatch is in flight, so a double-action can never strand a removed-but-unsent message.
|
|
469
|
+
* Rejections that target a known item rebroadcast the snapshot (clients replay dispatching).
|
|
263
470
|
*/
|
|
264
|
-
export function sendNow(id) {
|
|
471
|
+
export function sendNow(id, anchor) {
|
|
265
472
|
if (!_deps) return false;
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
//
|
|
269
|
-
if (
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const
|
|
473
|
+
|
|
474
|
+
const filter = _normalizeScope(anchor);
|
|
475
|
+
// Double click on the item already dispatching: reject + replay its dispatching snapshot.
|
|
476
|
+
if (_dispatch && _dispatch.item.id === id) {
|
|
477
|
+
_broadcast(_dispatch.item, 'dispatching');
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
const queuedIdx = _items.findIndex((it) => it.id === id);
|
|
481
|
+
if (queuedIdx >= 0 && !_matchesScope(_items[queuedIdx].anchor, filter)) {
|
|
482
|
+
_broadcast(_items[queuedIdx], 'queued');
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
// `_dispatch` covers any send-now/drain dispatch in flight (including its ESC-delay window);
|
|
486
|
+
// `_injecting` covers an in-flight paste; `_pollTimer` also covers a resume/drain poll.
|
|
487
|
+
if (_dispatch || _injecting || _pollTimer) {
|
|
488
|
+
// Rejected operation on a known item: replay so a client-side optimistic action reverts.
|
|
489
|
+
if (queuedIdx >= 0) _broadcast(_dispatch ? _dispatch.item : _items[queuedIdx], _dispatch ? 'dispatching' : 'queued');
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
try {
|
|
493
|
+
if (_deps.hasExternalInjection && _deps.hasExternalInjection()) {
|
|
494
|
+
if (queuedIdx >= 0) _broadcast(_items[queuedIdx], 'queued');
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
} catch { return false; }
|
|
498
|
+
if (queuedIdx < 0) return false;
|
|
499
|
+
const [item] = _items.splice(queuedIdx, 1);
|
|
274
500
|
_clearRescueTimer();
|
|
275
|
-
_broadcast();
|
|
276
501
|
|
|
277
|
-
const gen = _gen; // a clear()/stop() mid-flight invalidates the completion
|
|
502
|
+
const gen = _gen; // a clear()/stop() mid-flight invalidates the completion handlers below
|
|
503
|
+
_startDispatch(item, queuedIdx);
|
|
504
|
+
|
|
278
505
|
const inject = () => {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
_injecting = false;
|
|
283
|
-
if (gen !== _gen) return;
|
|
284
|
-
if (!ok) { _items.unshift(item); _broadcast(); }
|
|
285
|
-
}, { settleMs: 250 });
|
|
286
|
-
} catch (err) {
|
|
287
|
-
_injecting = false;
|
|
288
|
-
if (gen === _gen) {
|
|
289
|
-
_items.unshift(item);
|
|
290
|
-
_broadcast();
|
|
291
|
-
}
|
|
292
|
-
reportSwallowed('chat-queue.send-now.inject', err);
|
|
293
|
-
}
|
|
506
|
+
if (gen !== _gen) return;
|
|
507
|
+
if (!_dispatch || _dispatch.item.id !== item.id) return;
|
|
508
|
+
_writeDispatch(item, gen, 'chat-queue.send-now.inject');
|
|
294
509
|
};
|
|
295
510
|
const restore = () => {
|
|
296
|
-
// Never blind-inject (see _pollUntilSafe). Park the item back at
|
|
511
|
+
// Never blind-inject (see _pollUntilSafe). Park the item back at its original FIFO slot.
|
|
297
512
|
if (gen !== _gen) return;
|
|
298
|
-
if (!
|
|
299
|
-
|
|
300
|
-
_broadcast();
|
|
301
|
-
}
|
|
513
|
+
if (!_dispatch || _dispatch.item.id !== item.id) return;
|
|
514
|
+
_restoreDispatch();
|
|
302
515
|
};
|
|
303
516
|
|
|
304
517
|
let streaming = false;
|
|
305
518
|
try { streaming = !!(_deps.isStreaming && _deps.isStreaming()); } catch { streaming = false; }
|
|
306
519
|
if (streaming) {
|
|
307
|
-
// Interrupt
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
520
|
+
// Interrupt the item's own session, not whichever PTY is globally active.
|
|
521
|
+
const writeFocus = (data, tag) => {
|
|
522
|
+
try {
|
|
523
|
+
if (item.anchor && typeof _deps.writeToPtyFor !== 'function') return false;
|
|
524
|
+
const sent = item.anchor
|
|
525
|
+
? _deps.writeToPtyFor(data, { ...item.anchor, strict: true }) !== false
|
|
526
|
+
: _deps.writeToPty(data) !== false;
|
|
527
|
+
if (sent === false) {
|
|
528
|
+
_failDispatch(item, gen, tag);
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
return true;
|
|
532
|
+
} catch (err) {
|
|
533
|
+
_failDispatch(item, gen, tag);
|
|
534
|
+
reportSwallowed(tag, err);
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
if (writeFocus('\x1b[I', 'chat-queue.send-now.focus') === false) return true;
|
|
311
539
|
_escTimer = setTimeout(() => {
|
|
312
540
|
_escTimer = null;
|
|
313
|
-
|
|
541
|
+
if (gen !== _gen || !_dispatch || _dispatch.item.id !== item.id) return;
|
|
542
|
+
if (writeFocus('\x1b', 'chat-queue.send-now.esc') === false) return;
|
|
314
543
|
_pollUntilSafe(inject, restore);
|
|
315
544
|
}, STOP_ESC_DELAY_MS);
|
|
316
545
|
if (typeof _escTimer.unref === 'function') _escTimer.unref();
|
|
@@ -329,13 +558,13 @@ export function sendNow(id) {
|
|
|
329
558
|
*/
|
|
330
559
|
export function onTurnEnd() {
|
|
331
560
|
if (!_deps || _items.length === 0 || _injecting) return;
|
|
332
|
-
// A send-now
|
|
333
|
-
// clear
|
|
334
|
-
if (_pollTimer) return;
|
|
561
|
+
// A dispatch (send-now or drain) or another pending poll already owns the next inject; let
|
|
562
|
+
// it finish rather than clear a timer out from under it (clearing would strand the item).
|
|
563
|
+
if (_dispatch || _pollTimer) return;
|
|
335
564
|
_clearRescueTimer();
|
|
336
565
|
if (_suppressDrain) return;
|
|
337
566
|
if (_safeToInject()) { _injectHead(); return; }
|
|
338
|
-
_pollUntilSafe(() => { if (!_suppressDrain) _injectHead(); }, () => { /* keep queued; next turn-end retries */ });
|
|
567
|
+
_pollUntilSafe(() => { if (!_suppressDrain && !_dispatch) _injectHead(); }, () => { /* keep queued; next turn-end retries */ });
|
|
339
568
|
}
|
|
340
569
|
|
|
341
570
|
/** Park the queue: the next turn-end(s) must not auto-drain (user pressed Stop). */
|
|
@@ -356,6 +585,7 @@ export function stop() {
|
|
|
356
585
|
_clearPollTimer();
|
|
357
586
|
_clearEscTimer();
|
|
358
587
|
_injecting = false;
|
|
588
|
+
_dispatch = null;
|
|
359
589
|
_items = [];
|
|
360
590
|
_suppressDrain = false;
|
|
361
591
|
_gen++;
|
|
@@ -371,26 +601,26 @@ const RESUME_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f
|
|
|
371
601
|
*
|
|
372
602
|
* Promise-returning so the route can map the outcome to HTTP. Resolves:
|
|
373
603
|
* { ok:true } — the paste was written
|
|
374
|
-
* { ok:false, reason:'busy' } — TUI streaming / approval open / another inject
|
|
375
|
-
* flight / timed out waiting for idle (never blind-injects)
|
|
604
|
+
* { ok:false, reason:'busy' } — TUI streaming / approval open / another inject, poll, or
|
|
605
|
+
* dispatch in flight / timed out waiting for idle (never blind-injects)
|
|
376
606
|
* { ok:false, reason:'bad-uuid' } — sessionUuid is not a UUID shape
|
|
377
607
|
* { ok:false, reason:'no-deps' } — initChatQueue never ran (SDK mode / not wired)
|
|
378
608
|
* { ok:false, reason:'write-failed' } — the PTY write failed or threw
|
|
379
609
|
*
|
|
380
|
-
* Shares the single `_injecting`/`_pollTimer` slots with the drain/sendNow paths,
|
|
381
|
-
* refuses up-front when
|
|
382
|
-
* pending
|
|
383
|
-
*
|
|
384
|
-
*
|
|
610
|
+
* Shares the single `_injecting`/`_pollTimer`/`_dispatch` slots with the drain/sendNow paths,
|
|
611
|
+
* so it refuses up-front when any is owned (mirrors sendNow's guard) instead of clobbering a
|
|
612
|
+
* pending dispatch and stranding a queued message. The completion handler resets `_injecting`
|
|
613
|
+
* unconditionally (both callback and catch) so a mid-inject clear()/stop() generation bump can
|
|
614
|
+
* never wedge the queue.
|
|
385
615
|
*/
|
|
386
616
|
export function injectResumeCommand(sessionUuid, anchor) {
|
|
387
617
|
return new Promise((resolve) => {
|
|
388
618
|
if (!_deps) { resolve({ ok: false, reason: 'no-deps' }); return; }
|
|
389
619
|
const uuid = sanitizeInbound(String(sessionUuid == null ? '' : sessionUuid)).trim();
|
|
390
620
|
if (!RESUME_UUID_RE.test(uuid)) { resolve({ ok: false, reason: 'bad-uuid' }); return; }
|
|
391
|
-
// Refuse BEFORE touching _pollUntilSafe: a
|
|
392
|
-
//
|
|
393
|
-
if (_injecting || _pollTimer) { resolve({ ok: false, reason: 'busy' }); return; }
|
|
621
|
+
// Refuse BEFORE touching _pollUntilSafe: a dispatch (even in its ESC-delay window) or a
|
|
622
|
+
// drain's poll owns the single timer slots — barging in would strand a queued message.
|
|
623
|
+
if (_dispatch || _injecting || _pollTimer) { resolve({ ok: false, reason: 'busy' }); return; }
|
|
394
624
|
let settled = false;
|
|
395
625
|
const done = (r) => { if (!settled) { settled = true; resolve(r); } };
|
|
396
626
|
const inject = () => {
|
|
@@ -233,8 +233,30 @@ export function isReadAllowed(absPath) {
|
|
|
233
233
|
if (SENSITIVE_CLAUDE_FILES.has(last)) {
|
|
234
234
|
return { ok: false, reason: 'sensitive-claude-config' };
|
|
235
235
|
}
|
|
236
|
-
//
|
|
237
|
-
|
|
236
|
+
// settings.json / settings.local.json at any depth, plus all backups/variants
|
|
237
|
+
// (settings.json.bk*, settings_*.json, etc.) — rolling backup files carry the
|
|
238
|
+
// same env/api-key content and must not slip past the exact-name match.
|
|
239
|
+
if (/^settings(\.local)?\.json($|\.)|^settings_.*\.json$/i.test(last)) {
|
|
240
|
+
return { ok: false, reason: 'sensitive-claude-config' };
|
|
241
|
+
}
|
|
242
|
+
// Prompt history, project transcripts, shell snapshots, file history, and
|
|
243
|
+
// paste cache contain user prompt content and (in the case of shell snapshots)
|
|
244
|
+
// live env vars. Block the whole subtree EXCEPT projects/<encoded>/memory/**,
|
|
245
|
+
// which is the documented per-project memory dir served by /api/project-memory.
|
|
246
|
+
const rel = norm(real.slice(norm(withSep(claudeReal)).length));
|
|
247
|
+
const firstSeg = rel.split(sep)[0];
|
|
248
|
+
if (firstSeg === 'todos' || firstSeg === 'shell-snapshots'
|
|
249
|
+
|| firstSeg === 'file-history' || firstSeg === 'paste-cache' || firstSeg === 'session-env') {
|
|
250
|
+
return { ok: false, reason: 'sensitive-claude-config' };
|
|
251
|
+
}
|
|
252
|
+
if (firstSeg === 'projects') {
|
|
253
|
+
const parts = rel.split(sep);
|
|
254
|
+
const isMemoryDir = parts.length >= 3 && parts[2] === 'memory';
|
|
255
|
+
if (!isMemoryDir) {
|
|
256
|
+
return { ok: false, reason: 'sensitive-claude-config' };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (last === 'history.jsonl') {
|
|
238
260
|
return { ok: false, reason: 'sensitive-claude-config' };
|
|
239
261
|
}
|
|
240
262
|
}
|