shadok-ai 0.15.6 → 0.15.7

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 (2) hide show
  1. package/package.json +1 -1
  2. package/public/index.html +90 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadok-ai",
3
- "version": "0.15.6",
3
+ "version": "0.15.7",
4
4
  "main": "dist/session.js",
5
5
  "scripts": {
6
6
  "test": "node --import tsx --test --test-timeout=120000 test/*.ts context/*/test/*.test.mjs",
package/public/index.html CHANGED
@@ -5546,6 +5546,16 @@
5546
5546
  function scheduleReconnect(t) {
5547
5547
  if (t.closing) return;
5548
5548
  if (t.reconnectTimer) clearTimeout(t.reconnectTimer);
5549
+ // The socket that owed us a `working` is gone, so the optimistic gap can
5550
+ // never close by itself: drop it and hand back anything queued behind it.
5551
+ // Without this a drop INSIDE that gap latched `awaitingTurn` for good — the
5552
+ // reattach only sends `working` when the session is still busy, so a turn
5553
+ // that finished while we were away leaves no event to clear it, and every
5554
+ // later Enter queued behind a turn that will never end: box cleared, draft
5555
+ // dropped, nothing on the wire, nothing on screen. Measured in a browser.
5556
+ if (drainQueueToBox(t)) {
5557
+ addTurn(t, "system", "", "Your unsent message is back in the box — the link dropped before it was sent.");
5558
+ }
5549
5559
  const n = t.reconnectAttempts++;
5550
5560
  const delay = Math.min(15000, 1000 * 2 ** n) + Math.floor(Math.random() * 500);
5551
5561
  setTabState(t, "dead", "link lost — reconnecting…");
@@ -6195,9 +6205,17 @@
6195
6205
  if (msg.lastTurnMs) showFrozenTime(t, msg.lastTurnMs);
6196
6206
  else t.timerEl.classList.add("done"); // freeze whatever's shown; don't keep ticking
6197
6207
  addTurn(t, "system", "", "Session established. The pilot has the controls.");
6208
+ // Backstop to the same rule: "ready" asserts the process is idle NOW,
6209
+ // so there is no turn in flight to wait on — whatever path got us here.
6210
+ if (drainQueueToBox(t)) {
6211
+ addTurn(t, "system", "", "Your unsent message is back in the box — the turn it was waiting for is over.");
6212
+ }
6198
6213
  if (t === active) promptInput.focus();
6199
6214
  break;
6200
6215
  case "working":
6216
+ // The turn is confirmed running: the optimistic gap is over, and any
6217
+ // further send now goes through the busy (takeover) path.
6218
+ t.awaitingTurn = false;
6201
6219
  // Another client may have resolved the pending dialog.
6202
6220
  retireChoices(t);
6203
6221
  // Covers turns started elsewhere (other client, dialog answer, cron) and
@@ -6326,11 +6344,13 @@
6326
6344
  persistChannels();
6327
6345
  }
6328
6346
  stopTurnTimer(t);
6329
- // A message was typed during this turn (a takeover): the turn has just
6330
- // settled, so send it now. Mirrors the Telegram bridge's preempt.
6331
- if (t.preempting) {
6332
- const queued = t.preempting;
6333
- t.preempting = null;
6347
+ t.awaitingTurn = false;
6348
+ // Messages queued during this turn — a takeover (preemptTurn) or a fast
6349
+ // double-send (queueForNextTurn) — settle now. Send the NEXT one, in
6350
+ // order; submitPrompt re-arms the gap, so the rest flush on the following
6351
+ // turn-done. Mirrors the Telegram bridge's preempt.
6352
+ if (t.sendQueue && t.sendQueue.length) {
6353
+ const queued = t.sendQueue.shift();
6334
6354
  clearTimeout(t.preemptTimer);
6335
6355
  setTabState(t, "ready", "ready");
6336
6356
  submitPrompt(t, queued);
@@ -6382,6 +6402,7 @@
6382
6402
  // to restore it and forget it so it doesn't resurrect every reload.
6383
6403
  addTurn(t, "system", "", msg.message);
6384
6404
  forgetChannel(msg.sessionId);
6405
+ drainQueueToBox(t);
6385
6406
  stopTurnTimer(t);
6386
6407
  setTabMood(t, null);
6387
6408
  setTabState(t, "dead", "directory gone");
@@ -6391,6 +6412,11 @@
6391
6412
  // the instance's. Surface the fix instead of a line in the transcript.
6392
6413
  if (msg.code === "logged-out") { openAuthCard(); }
6393
6414
  addTurn(t, "system", "", "Error: " + msg.message);
6415
+ // A turn died before it could flush what was queued behind it (a fast
6416
+ // double-send): hand those back to the box rather than strand them.
6417
+ if (drainQueueToBox(t)) {
6418
+ addTurn(t, "system", "", "Your next message is back in the box — the turn didn't complete.");
6419
+ }
6394
6420
  // Failed before ever going live (e.g. a bad restore): mark dead so it
6395
6421
  // doesn't hang on "connecting…".
6396
6422
  if (t.status === "connecting") setTabState(t, "dead", "failed to start");
@@ -6402,6 +6428,7 @@
6402
6428
  break;
6403
6429
  case "exited":
6404
6430
  addTurn(t, "system", "", "The claude process exited (code " + msg.code + ").");
6431
+ drainQueueToBox(t);
6405
6432
  stopTurnTimer(t);
6406
6433
  setTabMood(t, null);
6407
6434
  setTabState(t, "dead", "session ended");
@@ -6445,6 +6472,11 @@
6445
6472
  // pilot exits while the server awaits getUsage(), "exited" marks the tab
6446
6473
  // dead and this refusal would resurrect it as ready with a green LED,
6447
6474
  // with no later message to correct it.
6475
+ // The refused prompt never became a turn: close the optimistic gap. Its
6476
+ // own text is re-offered by renderPaceConfirm below; anything queued
6477
+ // BEHIND it (a fast follow-up) has no turn to ride on, so hand it back.
6478
+ t.awaitingTurn = false;
6479
+ drainQueueToBox(t);
6448
6480
  const rollback = t.status === "ready";
6449
6481
  if (rollback) {
6450
6482
  stopTurnTimer(t);
@@ -6585,21 +6617,54 @@
6585
6617
  function preemptTurn(t, text) {
6586
6618
  // Socket down: leave the text in the box, like submitPrompt's own guard.
6587
6619
  if (!sendTo(t, { type: "key", key: "escape" })) return;
6588
- t.preempting = text;
6589
- t.transcriptEl._stickBottom = true;
6590
- if (t === active) { promptInput.value = ""; promptInput.style.height = "auto"; }
6591
- dropDraft(t);
6592
- clearAttachments(t); // their paths are already folded into `text`
6620
+ queueForNextTurn(t, text);
6593
6621
  setTabState(t, "busy", "interrupting…");
6594
6622
  clearTimeout(t.preemptTimer);
6595
6623
  t.preemptTimer = setTimeout(() => {
6596
- if (t.preempting !== text) return; // already sent, or replaced by a newer one
6597
- t.preempting = null;
6598
- if (t === active && !promptInput.value) { promptInput.value = text; active.draft = text; }
6624
+ // Interrupt never landed (a wedged turn): hand the still-queued messages
6625
+ // back rather than lose them or loop. Only the interrupt path arms this —
6626
+ // a message merely QUEUED behind a healthy long turn must wait as long as
6627
+ // the turn takes, which can far exceed this failsafe.
6628
+ if (!t.sendQueue || !t.sendQueue.length) return;
6629
+ const stuck = t.sendQueue.join("\n");
6630
+ t.sendQueue = [];
6631
+ if (t === active && !promptInput.value) { promptInput.value = stuck; active.draft = stuck; }
6599
6632
  addTurn(t, "system", "", "Couldn't interrupt the running turn — your message is back in the box. Try again, or use ⎋.");
6600
6633
  }, PREEMPT_TIMEOUT_MS);
6601
6634
  }
6602
6635
 
6636
+ /**
6637
+ * Stash a message to send once the in-flight turn settles, and clear the
6638
+ * composer so the user sees it was accepted. Nothing is rendered now: the
6639
+ * message appears as an ordinary prompt when it is actually sent (on
6640
+ * `turn-done`), so the transcript stays in order. Shared by the takeover
6641
+ * (preemptTurn, which also interrupts) and the fast-double-send guard, where a
6642
+ * second Enter lands before the server's `working` has flipped the tab to
6643
+ * busy — without this it fired a SECOND raw prompt, which the server refused as
6644
+ * "a response is already in progress" while the box was already cleared, so the
6645
+ * message was lost. A FIFO queue, not a single slot: several quick messages all
6646
+ * survive, delivered in order.
6647
+ */
6648
+ function queueForNextTurn(t, text) {
6649
+ (t.sendQueue ||= []).push(text);
6650
+ t.transcriptEl._stickBottom = true;
6651
+ if (t === active) { promptInput.value = ""; promptInput.style.height = "auto"; }
6652
+ dropDraft(t);
6653
+ clearAttachments(t); // their paths are already folded into `text`
6654
+ }
6655
+
6656
+ /** Hand any queued-but-unsent messages back to the composer (a turn died
6657
+ * before they could be flushed), so a fast double-send is never swallowed by
6658
+ * an error/exit. Returns true if something was restored. */
6659
+ function drainQueueToBox(t) {
6660
+ t.awaitingTurn = false;
6661
+ if (!t.sendQueue || !t.sendQueue.length) return false;
6662
+ const stuck = t.sendQueue.join("\n");
6663
+ t.sendQueue = [];
6664
+ if (t === active && !promptInput.value) { promptInput.value = stuck; active.draft = stuck; autosizePrompt(); }
6665
+ return true;
6666
+ }
6667
+
6603
6668
  function submitPrompt(t, text, force) {
6604
6669
  // Send BEFORE touching the UI. Everything below is optimistic — it shows the
6605
6670
  // turn, marks the tab busy, clears the composer and drops the draft — so on
@@ -6609,6 +6674,12 @@
6609
6674
  addTurn(t, "system", "", "Not sent: this agent is not connected. Your text is still in the box.");
6610
6675
  return;
6611
6676
  }
6677
+ // The turn is in flight but the server's `working` has not come back yet, so
6678
+ // t.status is still "ready". Mark the gap so a second fast Enter QUEUES
6679
+ // (queueForNextTurn) instead of firing another raw prompt the server would
6680
+ // refuse as busy — the fast-double-send bug. Cleared on `working` (the busy
6681
+ // path takes over from there) and on every turn settle/terminal.
6682
+ t.awaitingTurn = true;
6612
6683
  // Any outstanding pace question is answered by the fact that something is
6613
6684
  // being sent — whether by clicking its own button or by a fresh submit.
6614
6685
  retirePaceConfirm(t);
@@ -6714,6 +6785,12 @@
6714
6785
  // Sending during a running turn is a takeover: interrupt it, then send the
6715
6786
  // message once the turn settles. Same behaviour as Telegram.
6716
6787
  if (t.status === "busy") return preemptTurn(t, text);
6788
+ // A prompt is already in flight but the server's `working` has not come back
6789
+ // yet (t.status still "ready"): a fast second Enter. QUEUE it instead of
6790
+ // firing a second raw prompt the server refuses as busy — the message would
6791
+ // otherwise be lost with the box already cleared. No interrupt: the turn is
6792
+ // only just starting, so let it finish and send this next.
6793
+ if (t.awaitingTurn) return queueForNextTurn(t, text);
6717
6794
  if (t.status !== "ready") return;
6718
6795
  // Checked here so nothing is optimistically rendered before the server
6719
6796
  // refuses. The server re-checks anyway — it is the authority.