cc-viewer 1.9.14 → 1.9.15

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 (30) hide show
  1. package/dist/assets/App-BCDnm2Lh.js +2 -0
  2. package/dist/assets/App-CCnPkf2_.css +1 -0
  3. package/dist/assets/{MdxEditorPanel-DI3B4gsM.js → MdxEditorPanel-DRLFmdU5.js} +1 -1
  4. package/dist/assets/Mobile-C8VJqBwa.js +1 -0
  5. package/dist/assets/{ProxyStatsModal-B8K0AtAq.js → ProxyStatsModal-Ba1E1lu4.js} +1 -1
  6. package/dist/assets/{index-CU0gt6m1.js → index-Sp3JYYKo.js} +2 -2
  7. package/dist/assets/seqResourceLoaders-BUfjTaCV.css +41 -0
  8. package/dist/assets/seqResourceLoaders-CHmtPDFO.js +2 -0
  9. package/dist/index.html +1 -1
  10. package/node_modules/@ccv/core/src/contentFilter.js +3 -0
  11. package/node_modules/@ccv/core/src/queued-prompt.js +66 -0
  12. package/node_modules/@ccv/core/src/v2-transcript-normalizer.js +78 -14
  13. package/package.json +1 -1
  14. package/server/lib/chat-queue.js +350 -120
  15. package/server/lib/scheduled-task-executor.js +3 -1
  16. package/server/lib/sdk-manager.js +282 -68
  17. package/server/lib/v2/conversation-store.js +15 -11
  18. package/server/lib/v2/v2-writer.js +1 -1
  19. package/server/lib/view-root.js +4 -4
  20. package/server/pty-manager.js +8 -8
  21. package/server/routes/files-content.js +108 -14
  22. package/server/routes/files-fs.js +122 -36
  23. package/server/routes/resume.js +1 -1
  24. package/server/routes/sdk-context.js +13 -2
  25. package/server/server.js +128 -28
  26. package/dist/assets/App-BzV1ieDH.js +0 -2
  27. package/dist/assets/App-DU5jmGrx.css +0 -1
  28. package/dist/assets/Mobile-qBkAnXy3.js +0 -1
  29. package/dist/assets/seqResourceLoaders-CxUFE36z.css +0 -41
  30. package/dist/assets/seqResourceLoaders-nGCs11zu.js +0 -2
@@ -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.broadcastWs — (msg:object) => void
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
- export function getSnapshot() {
61
- return _items.map(({ id, text, ts }) => ({ id, text, ts }));
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
- function _broadcast() {
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
- try { _deps.broadcastWs({ type: 'queue-state', items: getSnapshot() }); }
67
- catch (err) { reportSwallowed('chat-queue.broadcast', err); }
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 } from the enqueueing client (2026-10-05)
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
- * Inject the head item. Caller must have verified _safeToInject() first (except the internal
125
- * re-checks here, which keep this self-guarding for direct calls).
126
- * Broadcasts on shift (so the bubble disappears the moment the message starts typing into the
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 _injectHead() {
130
- if (!_safeToInject()) return false;
131
- const item = _items.shift();
132
- if (!item) return false;
133
- _broadcast();
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
- // Route the inject by the enqueueing client's anchor when one was captured,
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 (!ok) {
150
- // A strict (scheduled-task) write that failed is TERMINAL: the target instance is
151
- // gone, and requeueing to the head of the shared cross-project FIFO would block
152
- // every later message forever (review: strict failure must not unshift). Drop it
153
- // and tell the client the scheduled prompt was not delivered.
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
- if (gen === _gen) {
167
- _items.unshift(item);
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
- if (_items.length >= MAX_ITEMS) {
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
- try { _deps.broadcastWs({ type: 'queue-rejected', reason: 'full' }); }
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
- if (_suppressDrain || _pollTimer) return; // parked, or a drain/send-now poll already owns it
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
- /** Remove one queued item by id. Returns true when found. */
238
- export function remove(id) {
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
- _broadcast();
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
- /** Drop all queued items (session switch). Also cancels pending timers and invalidates any
247
- * in-flight inject (gen bump) so an old-session message can never land in a fresh PTY.
248
- * Cannot cancel bytes already written to the PTY (best-effort, ~330ms window). */
249
- export function clear() {
250
- _gen++;
251
- _clearRescueTimer();
252
- _clearPollTimer();
253
- _clearEscTimer();
254
- if (_items.length === 0) return;
255
- _items = [];
256
- _broadcast();
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
- * Guard order matters (review P0): refuse BEFORE touching the queue when an inject is in
262
- * flight, so a double-action can never strand a removed-but-unsent message.
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
- // Guard order matters (review P0): refuse BEFORE touching the queue. `_injecting` covers an
267
- // in-flight paste; `_pollTimer` covers a prior send-now still in its ESC/idle-poll phase —
268
- // a second poll would clobber the first one's timer and strand its already-removed item.
269
- if (_injecting || _pollTimer) return false;
270
- try { if (_deps.hasExternalInjection && _deps.hasExternalInjection()) return false; } catch { return false; }
271
- const idx = _items.findIndex((it) => it.id === id);
272
- if (idx < 0) return false;
273
- const [item] = _items.splice(idx, 1);
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 handler below
502
+ const gen = _gen; // a clear()/stop() mid-flight invalidates the completion handlers below
503
+ _startDispatch(item, queuedIdx);
504
+
278
505
  const inject = () => {
279
- _injecting = true;
280
- try {
281
- _deps.writeToPtySequential(bracketPasteSubmit(item.text), (ok) => {
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 the head.
511
+ // Never blind-inject (see _pollUntilSafe). Park the item back at its original FIFO slot.
297
512
  if (gen !== _gen) return;
298
- if (!_items.some((it) => it.id === item.id)) {
299
- _items.unshift(item);
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 first: focus-in (a hidden xterm is unfocused; Ink ignores ESC then) then ESC —
308
- // byte-identical to the web Stop button (ChatView handleInputStop). The gap timer is
309
- // tracked so stop()/clear() can cancel it before it lands in a fresh PTY.
310
- try { _deps.writeToPty('\x1b[I'); } catch (err) { reportSwallowed('chat-queue.send-now.focus', err); }
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
- try { _deps.writeToPty('\x1b'); } catch (err) { reportSwallowed('chat-queue.send-now.esc', err); }
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 poll is already pending — it owns the next inject; let it finish rather than
333
- // clear its timer out from under it (clearing would strand the removed item).
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 or poll in
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, so it
381
- * refuses up-front when either is owned (mirrors sendNow's guard) instead of clobbering a
382
- * pending drain timer and stranding a queued message. The completion handler resets
383
- * `_injecting` unconditionally (both callback and catch) so a mid-inject clear()/stop()
384
- * generation bump can never wedge the queue.
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 drain's poll owns the single _pollTimer slot,
392
- // and an in-flight inject owns _injecting — barging in would strand a queued message.
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 = () => {
@@ -50,7 +50,9 @@ async function _injectSdk(task, io) {
50
50
  try {
51
51
  // Fire-and-forget: acceptance = dispatched. The turn's own outcome is presented by
52
52
  // the conversation itself (resident result carries no success/failure to us).
53
- await sdk.sendUserMessage(task.prompt, { cwd: task.cwd, project: task.project, instanceKey: target.instanceKey });
53
+ // The exact instance was already resolved above. Do not add a legacy project stamp:
54
+ // sdk-manager rejects a mismatched project+instance pair and would drop the injection.
55
+ await sdk.sendUserMessage(task.prompt, { instanceKey: target.instanceKey });
54
56
  return { status: 'dispatched' };
55
57
  } catch (err) {
56
58
  return { status: 'failed', reason: 'internal', error: String(err?.message || err) };