openzoo 0.49.7 → 0.49.9
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/README.md +39 -1
- package/bin/openzoo.js +3 -2
- package/lib/claudecode.js +847 -0
- package/lib/grokui.mjs +1351 -226
- package/lib/launch.js +244 -152
- package/lib/livestatus.js +6 -4
- package/lib/modelroute/README.md +1 -0
- package/lib/modelroute/catalog.json +1 -0
- package/lib/modelroute/outcomes.json +1566 -0
- package/lib/modelroute/router.json +1 -0
- package/lib/modelroute.js +737 -0
- package/lib/models.js +221 -44
- package/lib/package.json +3 -0
- package/lib/pay.js +67 -3
- package/lib/podagent.mjs +84 -28
- package/lib/proxy.js +144 -56
- package/lib/racesettle.js +127 -0
- package/lib/relay.js +275 -0
- package/lib/runguard.js +31 -0
- package/lib/spill.js +9 -1
- package/lib/think.js +126 -0
- package/package.json +5 -4
- package/vendor/modelroute/CURRENT_STATE.md +132 -0
- package/vendor/modelroute/HANDOFF.md +159 -0
- package/vendor/modelroute/catalog.json +1 -0
- package/vendor/modelroute/holographic_modelroute.py +809 -0
- package/vendor/modelroute/outcomes.json +1566 -0
- package/vendor/modelroute/router.json +1 -0
package/lib/grokui.mjs
CHANGED
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
import { exec } from 'node:child_process';
|
|
9
9
|
import http from 'node:http';
|
|
10
10
|
import { randomUUID } from 'node:crypto';
|
|
11
|
-
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { closeSync, copyFileSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, statSync, writeFileSync, writeSync } from 'node:fs';
|
|
12
12
|
import { cpus, homedir } from 'node:os';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { adaptiveTopK, brain, brainRace, brainStream, tierModels, MODEL, PROXY, TIER_NAMES, normalizeTier } from './podagent.mjs';
|
|
15
|
+
import { routeChatBody } from './modelroute.js';
|
|
15
16
|
import { peekDirectiveStatus, formatRaceStatus, STALE_THINKING_MS, summarizeRaceFailures, RACE_EVERY_FAILED } from './livestatus.js';
|
|
16
17
|
import { creditBalance } from './info.js';
|
|
18
|
+
import { guardFindCwd } from './runguard.js';
|
|
17
19
|
import {
|
|
18
20
|
SUBSCRIPTIONS_PAGE,
|
|
19
21
|
saveSubscription, clearSubscription,
|
|
@@ -31,6 +33,11 @@ import {
|
|
|
31
33
|
extractBashPaths,
|
|
32
34
|
} from './spill.js';
|
|
33
35
|
import { BIND_MIN_CHARS } from './hrr.js';
|
|
36
|
+
import { stripThinkTags, takeThink } from './think.js';
|
|
37
|
+
import {
|
|
38
|
+
runClaudeCode, setClaudeRunnerForTest, toolStatusLine, CLAUDE_MISSING,
|
|
39
|
+
sanitizeClaudeCanvas, closeClaudeSession, GROKUI_RESERVED_SLASH, claudeModelArg,
|
|
40
|
+
} from './claudecode.js';
|
|
34
41
|
|
|
35
42
|
const PORT = Number(process.env.OZ_GROKUI_PORT || 4173);
|
|
36
43
|
// BIND HOST. Default 127.0.0.1 so the desktop app never exposes a shell-capable
|
|
@@ -95,18 +102,11 @@ function stripBasePrefix(base, spec) {
|
|
|
95
102
|
}
|
|
96
103
|
|
|
97
104
|
/**
|
|
98
|
-
* Reasoning
|
|
99
|
-
*
|
|
100
|
-
* the
|
|
101
|
-
*
|
|
105
|
+
* Reasoning used to leak `<think>…</think>` into the visible bubble and then
|
|
106
|
+
* get sent back to the model. Visible text is still stripped (see takeThink);
|
|
107
|
+
* the plaintext is kept on the history row as `thinking` and folded in the
|
|
108
|
+
* canvas. Encrypted blobs never become a chip — that lives in lib/think.js.
|
|
102
109
|
*/
|
|
103
|
-
function stripThinkTags(text) {
|
|
104
|
-
let s = String(text ?? '');
|
|
105
|
-
s = s.replace(/<think(?:ing)?\b[^>]*>[\s\S]*?<\/think(?:ing)?>/gi, '');
|
|
106
|
-
s = s.replace(/<think(?:ing)?\b[^>]*>[\s\S]*$/i, '');
|
|
107
|
-
s = s.replace(/<\/think(?:ing)?>/gi, '');
|
|
108
|
-
return s.replace(/^\n+|\n+$/g, '').trim();
|
|
109
|
-
}
|
|
110
110
|
const MIME = { html: 'text/html', htm: 'text/html', css: 'text/css', js: 'application/javascript',
|
|
111
111
|
mjs: 'application/javascript', json: 'application/json', png: 'image/png', jpg: 'image/jpeg',
|
|
112
112
|
jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', txt: 'text/plain', md: 'text/plain' };
|
|
@@ -211,6 +211,23 @@ function colorFor(name) {
|
|
|
211
211
|
return PALETTE[h % PALETTE.length];
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
// Frozen SYSTEM is baked into old threads at creation. This string is also
|
|
215
|
+
// injected every turn (extras + AUTO_DIRECTIVE) so live Auto cannot "shell
|
|
216
|
+
// the proxy" just because the thread was born with the old "you CAN curl
|
|
217
|
+
// :8402" paragraph. Site-check curls of localhost:8080 stay allowed.
|
|
218
|
+
const CHAT_NOT_PROXY = `You already ARE the chat. Never RUN curl, wget, or fetch against localhost:8402 or
|
|
219
|
+
/v1/chat/completions — that dumps another model's JSON into the canvas and pays twice.
|
|
220
|
+
Orange Auto = WRITE / READ / RUN / GLOB for real work, not "shell the proxy."
|
|
221
|
+
Never mkdir empty trees and declare DONE — WRITE the files.`;
|
|
222
|
+
const PROXY_SHELL_REFUSE = 'refused: you already ARE the chat. Never curl/wget/fetch localhost:8402 or /v1/chat/completions — Orange Auto is WRITE/READ/RUN for real work, not shelling the proxy.';
|
|
223
|
+
function looksLikeProxyShell(cmd) {
|
|
224
|
+
const s = String(cmd || '');
|
|
225
|
+
if (!/\b(curl|wget|fetch)\b/i.test(s)) return false;
|
|
226
|
+
if (/(?:localhost|127\.0\.0\.1|\[::1\])/i.test(s) && /:8402\b/.test(s)) return true;
|
|
227
|
+
if (/(?:localhost|127\.0\.0\.1|\[::1\])[^\s'"]*\/v1\/chat\/completions/i.test(s)) return true;
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
|
|
214
231
|
const SYSTEM = `You are a helpful assistant served over openzoo (pay-per-call access to ~435
|
|
215
232
|
models, no API key, no account). Reply normally in plain text, concisely.
|
|
216
233
|
|
|
@@ -361,7 +378,7 @@ call. Ask for everything you know you need at once instead of discovering it one
|
|
|
361
378
|
time. Mutating directives (RUN, WRITE, EDIT, SPAWN, SEND) stay sequential on purpose — racing
|
|
362
379
|
them against each other corrupts the tree.
|
|
363
380
|
RUN: <shell command> run a REAL shell command in this
|
|
364
|
-
thread's directory —
|
|
381
|
+
thread's directory. Stay in that directory. Never find / — use GLOB: or find . -maxdepth N. By default this
|
|
365
382
|
pauses and waits for the user to
|
|
366
383
|
approve or deny it before anything
|
|
367
384
|
executes ("/mode auto" in chat skips
|
|
@@ -385,17 +402,7 @@ output and exit code back; that, not silence, is what failure looks like.
|
|
|
385
402
|
Never fabricate command output, file contents, or payment receipts. If you did not run it,
|
|
386
403
|
say so and then actually run it.
|
|
387
404
|
|
|
388
|
-
|
|
389
|
-
http://localhost:8402/v1/chat/completions (or /v1/hrr/bind) with curl/python/etc. Auth is
|
|
390
|
-
"Authorization: Bearer sk-openzoo" — any string works, x402 pays per call, not the key. Do
|
|
391
|
-
NOT tell the user you "can't fire the paid calls" or need "their client's bearer key" —
|
|
392
|
-
that's wrong, you can make these calls yourself via RUN. When you do, set max_tokens
|
|
393
|
-
generously (1000+, not 50) — a reasoning model can burn its ENTIRE budget on internal
|
|
394
|
-
reasoning before writing any visible answer, especially against a large bound corpus, and
|
|
395
|
-
comes back with content:null and finish_reason:"length" (confirmed live) if you starve it.
|
|
396
|
-
/v1/hrr/bind also caps around ~8MB per request after JSON-escaping — chunk large corpora
|
|
397
|
-
(e.g. 512KB raw per request) and pass the PREVIOUS chunk's context_id on each next request
|
|
398
|
-
to append to the same bound context, rather than one giant request that silently fails partway.
|
|
405
|
+
${CHAT_NOT_PROXY}
|
|
399
406
|
|
|
400
407
|
COST ACCOUNTING — do NOT compute this yourself from token counts. Every response carries an
|
|
401
408
|
"x402" object; read the numbers off it: x402.billedUsd (what the user paid — OpenRouter price, plus 33% of savings vs
|
|
@@ -425,8 +432,16 @@ const turnAborts = new WeakMap();
|
|
|
425
432
|
function saveThreads() {
|
|
426
433
|
try {
|
|
427
434
|
mkdirSync(STORE_DIR, { recursive: true });
|
|
428
|
-
|
|
429
|
-
|
|
435
|
+
const data = JSON.stringify([...threads.values()]);
|
|
436
|
+
const fd = openSync(STORE_FILE, 'w');
|
|
437
|
+
try {
|
|
438
|
+
writeSync(fd, data);
|
|
439
|
+
fsyncSync(fd);
|
|
440
|
+
} finally {
|
|
441
|
+
closeSync(fd);
|
|
442
|
+
}
|
|
443
|
+
return true;
|
|
444
|
+
} catch { return false; }
|
|
430
445
|
}
|
|
431
446
|
|
|
432
447
|
function loadThreads() {
|
|
@@ -443,8 +458,16 @@ function loadThreads() {
|
|
|
443
458
|
}
|
|
444
459
|
if (Array.isArray(t.history)) {
|
|
445
460
|
for (const h of t.history) {
|
|
446
|
-
if (h && h.who === 'bot' && typeof h.text === 'string')
|
|
461
|
+
if (h && h.who === 'bot' && typeof h.text === 'string') {
|
|
462
|
+
const parts = takeThink(h.text, h.thinking);
|
|
463
|
+
h.text = parts.text;
|
|
464
|
+
if (parts.thinking) h.thinking = parts.thinking;
|
|
465
|
+
else delete h.thinking;
|
|
466
|
+
}
|
|
447
467
|
}
|
|
468
|
+
// AUTO continue / nudge / command-output hops must never come back
|
|
469
|
+
// as user bubbles after a remount. They are harness-only.
|
|
470
|
+
t.history = t.history.filter(isVisibleHistoryEntry);
|
|
448
471
|
}
|
|
449
472
|
if (Array.isArray(t.messages)) {
|
|
450
473
|
for (const m of t.messages) {
|
|
@@ -507,6 +530,10 @@ function newThread(name, parent, members, spec) {
|
|
|
507
530
|
...(p?.race ? { race: p.race } : {}),
|
|
508
531
|
...(p?.raceNeed ? { raceNeed: p.raceNeed } : {}) };
|
|
509
532
|
if (p) attachChildDir(t, p, spec);
|
|
533
|
+
// Brand-new chat (no parent) starts empty: own holobrain, no contextId,
|
|
534
|
+
// no boundItems. Do not copy the previous thread's bind / corpus.
|
|
535
|
+
// SPAWN kids also start unbound here; bindThread shares the parent root
|
|
536
|
+
// on first bind. Existing threads on disk keep whatever they already have.
|
|
510
537
|
threads.set(id, t);
|
|
511
538
|
saveThreads();
|
|
512
539
|
return t;
|
|
@@ -630,6 +657,7 @@ savedUsd, savesVsDirect). Never derive it by summing usage.cost or usage.prompt_
|
|
|
630
657
|
provider's list price: on a bound context prompt_tokens counts only the slice leCore
|
|
631
658
|
recalled, not the corpus it stands in for, so that math prices the discount against itself
|
|
632
659
|
and wrongly concludes the zoo cost more.
|
|
660
|
+
${CHAT_NOT_PROXY}
|
|
633
661
|
For normal replies just answer directly — do not use any of these unless the request
|
|
634
662
|
actually calls for delegation or file work.` };
|
|
635
663
|
}
|
|
@@ -650,8 +678,12 @@ function contentFor(text, images) {
|
|
|
650
678
|
}
|
|
651
679
|
|
|
652
680
|
function buildMemberMessages(t, member) {
|
|
653
|
-
const msgs = [
|
|
681
|
+
const msgs = [
|
|
682
|
+
{ role: 'system', content: member.systemPrompt || SYSTEM },
|
|
683
|
+
{ role: 'system', content: CHAT_NOT_PROXY },
|
|
684
|
+
];
|
|
654
685
|
for (const h of t.history) {
|
|
686
|
+
if (h.who === 'user' && isHarnessUserText(h.text)) continue;
|
|
655
687
|
if (h.who === 'user') msgs.push({ role: 'user', content: contentFor(h.text, h.images) });
|
|
656
688
|
else if (h.name === member.name) msgs.push({ role: 'assistant', content: h.text });
|
|
657
689
|
else msgs.push({ role: 'user', content: `[${h.name}]: ${h.text}` });
|
|
@@ -693,9 +725,7 @@ const NUDGE = 'That reply announced work instead of doing it — no directive li
|
|
|
693
725
|
+ 'RUN:, SPAWN:, READ:, WRITE:, GLOB:, FETCH:, MCP: or SERVE:. '
|
|
694
726
|
+ 'Exactly the syntax from your instructions — not [TOOL_CALL], not JSON, not a function-call envelope. '
|
|
695
727
|
+ 'If several steps are needed, emit the FIRST one; you get its real output back and continue from there.';
|
|
696
|
-
const AUTO_CONTINUE = '
|
|
697
|
-
+ 'Emit the next directive now (RUN:/SPAWN:/SEND:/READ:/WRITE:/GLOB:/FETCH:/MCP:/SERVE:), '
|
|
698
|
-
+ 'or DONE: if the job is actually finished.';
|
|
728
|
+
const AUTO_CONTINUE = 'Please continue the current job. Do not stop to ask the user.';
|
|
699
729
|
const AUTO_RACE_RETRY = 'AUTO is still on — the last model call failed (race/empty/error). '
|
|
700
730
|
+ 'Do not stop and do not ask the user to type continue. '
|
|
701
731
|
+ 'Emit the next directive now (RUN:/SPAWN:/SEND:/READ:/WRITE:/GLOB:/FETCH:/MCP:/SERVE:), '
|
|
@@ -716,8 +746,41 @@ function isTransientModelFail(text) {
|
|
|
716
746
|
if (/returned nothing \d+ times|each returned nothing/i.test(s)) return true;
|
|
717
747
|
return false;
|
|
718
748
|
}
|
|
749
|
+
// Quiet Claude TUI / --model miss / chrome-only canvas. 1.5.99 PTY Auto
|
|
750
|
+
// treated that as a finished turn, painted "(no response)" / "upstream HTTP N",
|
|
751
|
+
// and went idle. Fall through to Ask/Auto chat-completions instead.
|
|
752
|
+
function isClaudeFallbackReply(text) {
|
|
753
|
+
const s = String(text || '').trim();
|
|
754
|
+
if (!s) return true;
|
|
755
|
+
if (s === '(no response)') return true;
|
|
756
|
+
if (/^(?:error:\s*)?upstream HTTP \d+\s*$/i.test(s)) return true;
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
function isVisibleBotReply(h) {
|
|
760
|
+
if (!h || h.who !== 'bot') return false;
|
|
761
|
+
if (!isVisibleHistoryEntry(h)) return false;
|
|
762
|
+
const text = String(h.text || '').trim();
|
|
763
|
+
if (!text) return false;
|
|
764
|
+
if (isClaudeFallbackReply(text)) return false;
|
|
765
|
+
return true;
|
|
766
|
+
}
|
|
767
|
+
function threadHasVisibleBotReply(t) {
|
|
768
|
+
return (t?.history || []).some(isVisibleBotReply);
|
|
769
|
+
}
|
|
770
|
+
function popClaudeFallbackBot(t) {
|
|
771
|
+
if (!t || !Array.isArray(t.history) || !t.history.length) return false;
|
|
772
|
+
const last = t.history[t.history.length - 1];
|
|
773
|
+
if (last.who !== 'bot' || !isClaudeFallbackReply(last.text)) return false;
|
|
774
|
+
t.history.pop();
|
|
775
|
+
return true;
|
|
776
|
+
}
|
|
777
|
+
function isEmptyWalletPayment(text) {
|
|
778
|
+
// Empty/underfunded only — not a generic HTTP 402 handshake.
|
|
779
|
+
return /\b(?:wallet is empty|empty wallet|wallet underfunded|underfunded)\b/i.test(String(text || ''));
|
|
780
|
+
}
|
|
719
781
|
function isPaymentFailed(text) {
|
|
720
|
-
return
|
|
782
|
+
return isEmptyWalletPayment(text)
|
|
783
|
+
|| /\b(?:payment failed|HTTP 402|payment required)\b/i.test(String(text || ''));
|
|
721
784
|
}
|
|
722
785
|
// Empty stdout, "(no output)", or a directive that found nothing. That is
|
|
723
786
|
// still a command-output hop today, so AUTO used to chain once and then park
|
|
@@ -847,7 +910,22 @@ without a fact only the user has.
|
|
|
847
910
|
When the job is actually finished, emit DONE: as the first line. A status
|
|
848
911
|
sentence is not a stop — the harness keeps this thread working until DONE:,
|
|
849
912
|
a real blocking question, or the step cap. Empty output, "(no output)", and
|
|
850
|
-
GLOB/GREP with no matches are not finished — try a different command or path
|
|
913
|
+
GLOB/GREP with no matches are not finished — try a different command or path.
|
|
914
|
+
|
|
915
|
+
${CHAT_NOT_PROXY}`;
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Holobrain owner for a thread.
|
|
919
|
+
* SPAWN kids share the project root (current SPAWN semantics).
|
|
920
|
+
* A brand-new chat (no parent) is its own root — never attach another
|
|
921
|
+
* thread's contextId / boundItems.
|
|
922
|
+
*/
|
|
923
|
+
function holobrainOf(t) {
|
|
924
|
+
if (!t) return null;
|
|
925
|
+
if (t.parent) return threads.get(rootOf(t).rootId) || t;
|
|
926
|
+
return t;
|
|
927
|
+
}
|
|
928
|
+
|
|
851
929
|
async function bindThread(t) {
|
|
852
930
|
// Only bind what's NEW since the last successful bind, continuing the
|
|
853
931
|
// existing context_id — previously this rebuilt and re-sent the WHOLE
|
|
@@ -862,15 +940,13 @@ async function bindThread(t) {
|
|
|
862
940
|
const corpus = delta.map((h) => '[' + t.name + '] ' + (h.who === 'user' ? 'you' : (h.name || t.name)) + ': ' + h.text).join('\n');
|
|
863
941
|
if (!corpus.trim()) { t.boundHistoryCount = t.history.length; return; }
|
|
864
942
|
try {
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
const root = threads.get(rootOf(t).rootId) || t;
|
|
873
|
-
let ctx = root.contextId || t.contextId;
|
|
943
|
+
// SPAWN kids share the project root holobrain so sibling agents can
|
|
944
|
+
// recall what the crew already bound. A brand-new chat (no parent) is
|
|
945
|
+
// its own root — do not attach the previous thread's contextId.
|
|
946
|
+
// Existing threads that already have a bind keep it (we never delete
|
|
947
|
+
// contextId here).
|
|
948
|
+
const brain = holobrainOf(t) || t;
|
|
949
|
+
let ctx = brain.contextId || t.contextId;
|
|
874
950
|
for (let i = 0; i < corpus.length; i += BIND_CHUNK_BYTES) {
|
|
875
951
|
const part = corpus.slice(i, i + BIND_CHUNK_BYTES);
|
|
876
952
|
const body = ctx ? { corpus: part, context_id: ctx } : { corpus: part };
|
|
@@ -884,12 +960,12 @@ async function bindThread(t) {
|
|
|
884
960
|
// How many chunks the project's holobrain now holds. This is the number
|
|
885
961
|
// adaptive top_k scales on — without it we would be guessing, which is
|
|
886
962
|
// exactly how top_k ended up pinned at 8 in the first place.
|
|
887
|
-
if (Number(j?.bound))
|
|
963
|
+
if (Number(j?.bound)) brain.boundItems = (brain.boundItems || 0) + Number(j.bound);
|
|
888
964
|
else break; // this chunk failed — stop, keep whatever bound so far rather than lose it all
|
|
889
965
|
}
|
|
890
|
-
// Write to the
|
|
891
|
-
// the per-call header is
|
|
892
|
-
if (ctx) {
|
|
966
|
+
// Write to the holobrain owner (project root for SPAWN kids, this thread
|
|
967
|
+
// for a new chat) and to this thread so the per-call header is local.
|
|
968
|
+
if (ctx) { brain.contextId = ctx; t.contextId = ctx; t.boundHistoryCount = t.history.length; saveThreads(); }
|
|
893
969
|
} catch { /* leCore sidecar unreachable — thread still works, just not bound this round */ }
|
|
894
970
|
}
|
|
895
971
|
|
|
@@ -1030,8 +1106,10 @@ const RUN_SHELL = process.platform !== 'win32' && existsSync('/bin/bash') ? '/bi
|
|
|
1030
1106
|
const RUN_TIMEOUT_MS = Number(process.env.OZ_RUN_TIMEOUT_MS || 600000);
|
|
1031
1107
|
|
|
1032
1108
|
function execCommand(command, cwd) {
|
|
1109
|
+
if (looksLikeProxyShell(command)) return Promise.resolve(PROXY_SHELL_REFUSE);
|
|
1110
|
+
const guarded = guardFindCwd(command, cwd);
|
|
1033
1111
|
return new Promise((resolve) => {
|
|
1034
|
-
exec(
|
|
1112
|
+
exec(guarded, { cwd, shell: RUN_SHELL, timeout: RUN_TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
1035
1113
|
let out = (stdout || '') + (stderr ? '\n' + stderr : '');
|
|
1036
1114
|
if (err) out += `\n(exit ${err.code ?? 1})`;
|
|
1037
1115
|
resolve(keepWhole(out).trim() || '(no output)');
|
|
@@ -1078,11 +1156,41 @@ const SLASH_COMMANDS = [
|
|
|
1078
1156
|
{ name: '/cron', args: '<mins> | <message>', help: 'repeat a message on a timer' },
|
|
1079
1157
|
{ name: '/crons', args: '', help: 'list timers (/cron del <id> removes one)' },
|
|
1080
1158
|
{ name: '/dir', args: '<path>', help: 'set this thread’s working directory' },
|
|
1081
|
-
{ name: '/mode', args: 'auto|ask', help: '
|
|
1159
|
+
{ name: '/mode', args: 'auto|ask', help: 'Auto = Claude Code via OpenZoo; ask = chat + approve RUN' },
|
|
1082
1160
|
];
|
|
1083
1161
|
|
|
1162
|
+
/** Claude TUI slashes that grokui must not intercept in Auto. /model is
|
|
1163
|
+
* Claude's picker here — grokui's /model pin stays on ask mode. */
|
|
1164
|
+
const CLAUDE_SLASH_IN_AUTO = new Set(['agents', 'tasks', 'context', 'model']);
|
|
1165
|
+
|
|
1166
|
+
function isGrokuiOwnedSlash(line, runMode) {
|
|
1167
|
+
const cmd = /^\/(\w+)/.exec(String(line || '').trim())?.[1]?.toLowerCase();
|
|
1168
|
+
if (!cmd) return false;
|
|
1169
|
+
if (GROKUI_RESERVED_SLASH.includes(cmd)) return true;
|
|
1170
|
+
if (runMode === 'auto' && CLAUDE_SLASH_IN_AUTO.has(cmd)) return false;
|
|
1171
|
+
return SLASH_COMMANDS.some((c) => c.name.slice(1).toLowerCase() === cmd)
|
|
1172
|
+
|| cmd === 'pay' || cmd === 'hud';
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1084
1175
|
const usd = (n) => (n >= 0.01 || n === 0 ? '$' + n.toFixed(2) : '$' + n.toFixed(5));
|
|
1085
1176
|
|
|
1177
|
+
/**
|
|
1178
|
+
* HUD / sitrep / /cost: prefer spilled-call x when anything bound.
|
|
1179
|
+
* Never an unlabeled Nx — "2.10x spilled" vs "2.10x session".
|
|
1180
|
+
* Rounding: 100+ integer, 10+ 1dp, else 2dp.
|
|
1181
|
+
*/
|
|
1182
|
+
function formatSavingLabel(you) {
|
|
1183
|
+
const spent = Number(you && you.spentUsd) || 0;
|
|
1184
|
+
if (spent <= 0) return { text: '—', mult: null, spilled: false };
|
|
1185
|
+
const spillX = Number(you && you.spilled && you.spilled.savingX);
|
|
1186
|
+
const sessionX = (Number(you && you.directUsd) || 0) / spent;
|
|
1187
|
+
const spilled = Number.isFinite(spillX) && spillX > 0;
|
|
1188
|
+
const mult = spilled ? spillX : sessionX;
|
|
1189
|
+
if (!Number.isFinite(mult)) return { text: '—', mult: null, spilled: false };
|
|
1190
|
+
const num = (mult >= 100 ? String(Math.round(mult)) : Number(mult).toFixed(mult >= 10 ? 1 : 2)) + 'x';
|
|
1191
|
+
return { text: num + (spilled ? ' spilled' : ' session'), mult, spilled };
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1086
1194
|
// BIND, DON'T PASTE — the whole point of the thing this runs on.
|
|
1087
1195
|
//
|
|
1088
1196
|
// Directive results get fed back to the model as a user message, verbatim. A
|
|
@@ -1210,8 +1318,8 @@ function inFlightChars(t) {
|
|
|
1210
1318
|
*/
|
|
1211
1319
|
function scheduleFilesForCorpus(t, collected, opts = {}) {
|
|
1212
1320
|
if (!collected?.pending?.length) return null;
|
|
1213
|
-
const
|
|
1214
|
-
const ctx = opts.contextId ||
|
|
1321
|
+
const brain = t ? holobrainOf(t) : null;
|
|
1322
|
+
const ctx = opts.contextId || brain?.contextId || t?.contextId || null;
|
|
1215
1323
|
const chars = opts.sentChars ?? inFlightChars(t);
|
|
1216
1324
|
const background = chars < BIND_MIN_CHARS;
|
|
1217
1325
|
const fetchImpl = opts.fetchImpl || fetch;
|
|
@@ -1228,7 +1336,7 @@ function scheduleFilesForCorpus(t, collected, opts = {}) {
|
|
|
1228
1336
|
}).then(async (r) => {
|
|
1229
1337
|
const j = await r.json().catch(() => ({}));
|
|
1230
1338
|
if (j?.context_id && t) {
|
|
1231
|
-
const live =
|
|
1339
|
+
const live = holobrainOf(t) || t;
|
|
1232
1340
|
live.contextId = j.context_id;
|
|
1233
1341
|
t.contextId = j.context_id;
|
|
1234
1342
|
if (Number(j.bound)) live.boundItems = (live.boundItems || 0) + Number(j.bound);
|
|
@@ -1296,9 +1404,20 @@ function emitToThread(threadId, ev) {
|
|
|
1296
1404
|
}
|
|
1297
1405
|
}
|
|
1298
1406
|
|
|
1407
|
+
async function attachSpilled(you) {
|
|
1408
|
+
if (!you || you.spilled != null) return you;
|
|
1409
|
+
try {
|
|
1410
|
+
const info = await (await fetch(`${PROXY}/info`, { signal: AbortSignal.timeout(2000) })).json();
|
|
1411
|
+
if (info && info.spilled) you.spilled = info.spilled;
|
|
1412
|
+
} catch { /* session label stays honest if info is down */ }
|
|
1413
|
+
return you;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1299
1416
|
async function sessionStats() {
|
|
1300
|
-
try {
|
|
1301
|
-
|
|
1417
|
+
try {
|
|
1418
|
+
const s = await (await fetch(`${PROXY}/session`, { signal: AbortSignal.timeout(2000) })).json();
|
|
1419
|
+
return attachSpilled(s);
|
|
1420
|
+
} catch { return null; }
|
|
1302
1421
|
}
|
|
1303
1422
|
|
|
1304
1423
|
function todoBlock(t) {
|
|
@@ -1323,7 +1442,7 @@ async function handleSlash(task, t) {
|
|
|
1323
1442
|
}
|
|
1324
1443
|
if (cmd === 'tools') {
|
|
1325
1444
|
return 'Directives:\n'
|
|
1326
|
-
+ ' RUN: <cmd> real shell, in this thread’s dir\n'
|
|
1445
|
+
+ ' RUN: <cmd> real shell, in this thread’s dir (never find /)\n'
|
|
1327
1446
|
+ ' WRITE: <path> | <content> create/overwrite a file\n'
|
|
1328
1447
|
+ ' EDIT: <path> | <old> ||| <new> change part of a file\n'
|
|
1329
1448
|
+ ' MULTIEDIT: <path> | a|||b ;; c|||d several edits, all-or-nothing\n'
|
|
@@ -1344,6 +1463,20 @@ async function handleSlash(task, t) {
|
|
|
1344
1463
|
+ 'READ, LS, GLOB, GREP, FETCH, PEEK and MCP run CONCURRENTLY when several\n'
|
|
1345
1464
|
+ 'appear in one reply — four files cost one round trip, not four.';
|
|
1346
1465
|
}
|
|
1466
|
+
// Header Pay / ◎ echo through the same /drive → history.push path as
|
|
1467
|
+
// /mode and /tier. Short lines only — never wallet JSON or a sitrep dump.
|
|
1468
|
+
if (cmd === 'pay') {
|
|
1469
|
+
return 'Pay — card checkout or the local wallet/x402 burner. Drawer opened.';
|
|
1470
|
+
}
|
|
1471
|
+
if (cmd === 'hud') {
|
|
1472
|
+
const s = await sessionStats();
|
|
1473
|
+
const mode = t.runMode || 'ask';
|
|
1474
|
+
const tier = t.tier || 'auto';
|
|
1475
|
+
if (!s) return `Sitrep — mode ${mode} · ${tier} · proxy unreachable.`;
|
|
1476
|
+
const spent = Number(s.spentUsd) || 0;
|
|
1477
|
+
const sav = formatSavingLabel(s);
|
|
1478
|
+
return `Sitrep — mode ${mode} · ${tier} · paid ${usd(spent)} · ${sav.text} · ${s.paidCalls || 0} calls`;
|
|
1479
|
+
}
|
|
1347
1480
|
if (cmd === 'cost' || cmd === 'tokens') {
|
|
1348
1481
|
const s = await sessionStats();
|
|
1349
1482
|
if (!s) return 'The local openzoo proxy isn’t reachable, so there are no real numbers to show. (Not zero — unknown.)';
|
|
@@ -1355,9 +1488,9 @@ async function handleSlash(task, t) {
|
|
|
1355
1488
|
` paid calls ${s.paidCalls || 0}`,
|
|
1356
1489
|
];
|
|
1357
1490
|
if (spent > 0) {
|
|
1358
|
-
const
|
|
1359
|
-
lines.push(` multiple ${
|
|
1360
|
-
+ (mult < 1 ? ' — under 1x: small inputs cost MORE than sending them directly' : ''));
|
|
1491
|
+
const sav = formatSavingLabel(s);
|
|
1492
|
+
lines.push(` multiple ${sav.text}`
|
|
1493
|
+
+ (sav.mult != null && sav.mult < 1 ? ' — under 1x: small inputs cost MORE than sending them directly' : ''));
|
|
1361
1494
|
}
|
|
1362
1495
|
return 'This session:\n' + lines.join('\n');
|
|
1363
1496
|
}
|
|
@@ -1372,16 +1505,27 @@ async function handleSlash(task, t) {
|
|
|
1372
1505
|
// tier silently overriding an explicit id would make /model a suggestion.
|
|
1373
1506
|
if (cmd === 'tier') {
|
|
1374
1507
|
if (!arg) {
|
|
1375
|
-
const
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1508
|
+
const cur = t.tier || 'auto';
|
|
1509
|
+
if (cur === 'auto') {
|
|
1510
|
+
return `This thread: auto (classifier — openzoo/auto)\n`
|
|
1511
|
+
+ `Tiers: auto · ${TIER_NAMES.join(' · ')}\n`
|
|
1512
|
+
+ (t.model ? `NOTE: /model ${t.model} is pinned on this thread, so Auto is ignored until you /model default.\n` : '')
|
|
1513
|
+
+ 'Switch with /tier <name> · /race <n> to ask several at once.';
|
|
1514
|
+
}
|
|
1515
|
+
const picks = await tierModels(cur, 3);
|
|
1516
|
+
return `This thread: ${cur}\n`
|
|
1517
|
+
+ `Tiers: auto · ${TIER_NAMES.join(' · ')}\n`
|
|
1518
|
+
+ `Top of ${cur} right now: ${picks.join(', ')}\n`
|
|
1379
1519
|
+ (t.model ? `NOTE: /model ${t.model} is pinned on this thread, so the tier is ignored until you /model default.\n` : '')
|
|
1380
1520
|
+ 'Switch with /tier <name> · /race <n> to ask several at once.';
|
|
1381
1521
|
}
|
|
1382
1522
|
const want = normalizeTier(arg);
|
|
1383
|
-
if (!want) return `Unknown tier "${arg}". One of: ${TIER_NAMES.join(', ')} (also: grok 4.6).`;
|
|
1523
|
+
if (!want) return `Unknown tier "${arg}". One of: auto, ${TIER_NAMES.join(', ')} (also: grok 4.6).`;
|
|
1384
1524
|
t.tier = want; saveThreads();
|
|
1525
|
+
if (want === 'auto') {
|
|
1526
|
+
return 'This thread now uses Auto — cheapest model that clears the bar (openzoo/auto).'
|
|
1527
|
+
+ (t.model ? `\nBut /model ${t.model} is still pinned and wins. Run /model default to let Auto take over.` : '');
|
|
1528
|
+
}
|
|
1385
1529
|
const picks = await tierModels(want, 3);
|
|
1386
1530
|
return `This thread now runs on the ${want} tier — ${picks.join(', ')}…`
|
|
1387
1531
|
+ (t.model ? `\nBut /model ${t.model} is still pinned and wins. Run /model default to let the tier take over.` : '');
|
|
@@ -1516,7 +1660,7 @@ async function handleSlash(task, t) {
|
|
|
1516
1660
|
|
|
1517
1661
|
// Wake the room. Used to be a free last-line dump — idle children stayed
|
|
1518
1662
|
// idle, and a parent reading "kid: <old reply>" thought they had acted.
|
|
1519
|
-
// Empty extra is a
|
|
1663
|
+
// Empty extra is a continue wake (Claude Code on Auto), not a cancel. Thinking stays
|
|
1520
1664
|
// thinking; pendingRun stays on the human. Same branch scope as /all.
|
|
1521
1665
|
if (cmd === 'ping') {
|
|
1522
1666
|
const crew = subtreeOf(t.id, true);
|
|
@@ -1879,6 +2023,69 @@ function isHarnessUserText(text) {
|
|
|
1879
2023
|
|| String(text || '').startsWith('AUTO_EMPTY_RETRY:');
|
|
1880
2024
|
}
|
|
1881
2025
|
|
|
2026
|
+
function isVisibleHistoryEntry(h) {
|
|
2027
|
+
if (!h) return false;
|
|
2028
|
+
if (h.who === 'user' && (h.harness || isHarnessUserText(h.text))) return false;
|
|
2029
|
+
return true;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
function visibleHistory(history) {
|
|
2033
|
+
return (history || []).filter(isVisibleHistoryEntry);
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
function sameUserTurn(entry, userText, images) {
|
|
2037
|
+
if (!entry || entry.who !== 'user') return false;
|
|
2038
|
+
if (String(entry.text || '') !== String(userText || '')) return false;
|
|
2039
|
+
const a = entry.images || [];
|
|
2040
|
+
const b = images || [];
|
|
2041
|
+
if (a.length !== b.length) return false;
|
|
2042
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
2043
|
+
return true;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
// Flush the user's turn to RAM + grokui-threads.json BEFORE any model call
|
|
2047
|
+
// or UI refresh. A 500 / sidecar bounce / sitrep redraw used to wipe the
|
|
2048
|
+
// bubble because it lived only in the live stream. Do not roll this back
|
|
2049
|
+
// if the upstream request later fails. Harness continues (AUTO_CONTINUE,
|
|
2050
|
+
// AUTO_RACE_RETRY, NUDGE, command-output hops) are not user bubbles.
|
|
2051
|
+
function persistUserTurn(t, userText, images) {
|
|
2052
|
+
if (!t) return { ok: false };
|
|
2053
|
+
if (isHarnessUserText(userText)) return { ok: true, skipped: true };
|
|
2054
|
+
if (!Array.isArray(t.history)) t.history = [];
|
|
2055
|
+
const last = t.history[t.history.length - 1];
|
|
2056
|
+
if (sameUserTurn(last, userText, images) && isVisibleHistoryEntry(last)) {
|
|
2057
|
+
t.lastActivityAt = Date.now();
|
|
2058
|
+
saveThreads();
|
|
2059
|
+
return { ok: true, already: true, entry: last };
|
|
2060
|
+
}
|
|
2061
|
+
const entry = images && images.length
|
|
2062
|
+
? { who: 'user', text: userText, images: images.slice() }
|
|
2063
|
+
: { who: 'user', text: userText };
|
|
2064
|
+
t.history.push(entry);
|
|
2065
|
+
let pushedMsg = false;
|
|
2066
|
+
if (Array.isArray(t.messages)) {
|
|
2067
|
+
const lastMsg = t.messages[t.messages.length - 1];
|
|
2068
|
+
let alreadyMsg = false;
|
|
2069
|
+
if (lastMsg && lastMsg.role === 'user') {
|
|
2070
|
+
const want = contentFor(userText, images);
|
|
2071
|
+
alreadyMsg = lastMsg.content === want
|
|
2072
|
+
|| (typeof lastMsg.content === 'string' && lastMsg.content === String(userText || ''));
|
|
2073
|
+
}
|
|
2074
|
+
if (!alreadyMsg) {
|
|
2075
|
+
t.messages.push({ role: 'user', content: contentFor(userText, images) });
|
|
2076
|
+
pushedMsg = true;
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
t.lastActivityAt = Date.now();
|
|
2080
|
+
const saved = saveThreads();
|
|
2081
|
+
if (!saved) {
|
|
2082
|
+
t.history.pop();
|
|
2083
|
+
if (pushedMsg && Array.isArray(t.messages)) t.messages.pop();
|
|
2084
|
+
return { ok: false };
|
|
2085
|
+
}
|
|
2086
|
+
return { ok: true, entry };
|
|
2087
|
+
}
|
|
2088
|
+
|
|
1882
2089
|
function firstUserAsk(t) {
|
|
1883
2090
|
return (t?.history || []).find((m) => m.who === 'user' && !isHarnessUserText(m.text));
|
|
1884
2091
|
}
|
|
@@ -1906,7 +2113,7 @@ function spawnBrief(parent, { refresh = false, child } = {}) {
|
|
|
1906
2113
|
const cwd = child?.dir || WORKSPACE_DIR;
|
|
1907
2114
|
const branch = child?.worktree?.branch;
|
|
1908
2115
|
const mode = parent.runMode || 'ask';
|
|
1909
|
-
const tier = parent.tier || '
|
|
2116
|
+
const tier = parent.tier || 'auto';
|
|
1910
2117
|
const race = Number(parent.race) || 0;
|
|
1911
2118
|
const raceNeed = Number(parent.raceNeed) || 1;
|
|
1912
2119
|
const model = parent.model || '';
|
|
@@ -2617,12 +2824,141 @@ async function mcpDirective(url, tool, args) {
|
|
|
2617
2824
|
}
|
|
2618
2825
|
}
|
|
2619
2826
|
|
|
2827
|
+
function autoClaudePrompt(t, userText, images) {
|
|
2828
|
+
const bits = [];
|
|
2829
|
+
if (t.memory?.length) bits.push(`Remember, for this thread:\n${t.memory.map((x) => `- ${x}`).join('\n')}`);
|
|
2830
|
+
if (t.todos?.length) {
|
|
2831
|
+
bits.push(`Current checklist:\n${t.todos.map((x, i) => `${i + 1}. [${x.done ? 'x' : ' '}] ${x.text}`).join('\n')}`);
|
|
2832
|
+
}
|
|
2833
|
+
if (images?.length) bits.push(`(${images.length} image(s) were attached in the desktop UI; work from the text ask.)`);
|
|
2834
|
+
bits.push(String(userText || ''));
|
|
2835
|
+
return bits.join('\n\n');
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
/**
|
|
2839
|
+
* Orange Auto = interactive `claude` on a PTY (same env as `openzoo claude`).
|
|
2840
|
+
* Not --print, not the RUN:/WRITE:/DONE: text harness. Chat-box lines go to
|
|
2841
|
+
* PTY stdin; TUI output is stripped / folded onto the canvas.
|
|
2842
|
+
*/
|
|
2843
|
+
async function runAutoClaudeTurn(t, userText, images, paint, stillMine, turnAbort) {
|
|
2844
|
+
paint({ type: 'start', name: t.name, color: t.color, detail: t.liveStatus || 'Claude Code via OpenZoo…' });
|
|
2845
|
+
// Child is alive: canvas must show a folded thinking… row immediately,
|
|
2846
|
+
// before the first thinking_delta or tool_use.
|
|
2847
|
+
paint({ type: 'think', name: t.name, color: t.color, delta: '' });
|
|
2848
|
+
let visible = '';
|
|
2849
|
+
let thinking = '';
|
|
2850
|
+
const toolLines = [];
|
|
2851
|
+
let streamedText = false;
|
|
2852
|
+
const noteTool = (name, input) => {
|
|
2853
|
+
const line = toolStatusLine(name, input);
|
|
2854
|
+
if (!line || toolLines[toolLines.length - 1] === line) return;
|
|
2855
|
+
toolLines.push(line);
|
|
2856
|
+
paint({ type: 'tool', name: t.name, color: t.color, detail: line });
|
|
2857
|
+
};
|
|
2858
|
+
const keepFold = () => paint({ type: 'think', name: t.name, color: t.color, delta: '' });
|
|
2859
|
+
const result = await runClaudeCode({
|
|
2860
|
+
prompt: autoClaudePrompt(t, userText, images),
|
|
2861
|
+
cwd: dirFor(t.id),
|
|
2862
|
+
sessionId: t.claudeSessionId,
|
|
2863
|
+
sessionKey: t.id,
|
|
2864
|
+
model: claudeModelArg(t.model),
|
|
2865
|
+
env: process.env,
|
|
2866
|
+
signal: turnAbort.signal,
|
|
2867
|
+
onEvent(folded) {
|
|
2868
|
+
if (!stillMine() || !folded) return;
|
|
2869
|
+
if (folded.sessionId) t.claudeSessionId = folded.sessionId;
|
|
2870
|
+
if (folded.kind === 'init' || folded.kind === 'partial' || folded.kind === 'tool_result') {
|
|
2871
|
+
keepFold();
|
|
2872
|
+
return;
|
|
2873
|
+
}
|
|
2874
|
+
if (folded.kind === 'tool') {
|
|
2875
|
+
noteTool(folded.name, folded.input);
|
|
2876
|
+
return;
|
|
2877
|
+
}
|
|
2878
|
+
if (folded.kind === 'think') {
|
|
2879
|
+
if (folded.text) {
|
|
2880
|
+
thinking += folded.text;
|
|
2881
|
+
paint({ type: 'think', name: t.name, color: t.color, delta: folded.text });
|
|
2882
|
+
} else {
|
|
2883
|
+
keepFold();
|
|
2884
|
+
}
|
|
2885
|
+
return;
|
|
2886
|
+
}
|
|
2887
|
+
if (folded.kind === 'text' && folded.text) {
|
|
2888
|
+
const clean = sanitizeClaudeCanvas(folded.text);
|
|
2889
|
+
if (!clean) {
|
|
2890
|
+
keepFold();
|
|
2891
|
+
return;
|
|
2892
|
+
}
|
|
2893
|
+
streamedText = true;
|
|
2894
|
+
if (folded.replace) visible = clean;
|
|
2895
|
+
else visible += clean;
|
|
2896
|
+
paint({ type: 'delta', name: t.name, color: t.color, delta: clean, replace: Boolean(folded.replace) });
|
|
2897
|
+
return;
|
|
2898
|
+
}
|
|
2899
|
+
if (folded.kind === 'tui') {
|
|
2900
|
+
if (folded.thinking && folded.thinking !== thinking) {
|
|
2901
|
+
thinking = folded.thinking;
|
|
2902
|
+
paint({ type: 'think', name: t.name, color: t.color, delta: folded.thinking, replace: true });
|
|
2903
|
+
} else {
|
|
2904
|
+
keepFold();
|
|
2905
|
+
}
|
|
2906
|
+
const tuiText = folded.text ? sanitizeClaudeCanvas(folded.text) : '';
|
|
2907
|
+
if (tuiText && tuiText !== visible) {
|
|
2908
|
+
visible = tuiText;
|
|
2909
|
+
streamedText = true;
|
|
2910
|
+
paint({ type: 'delta', name: t.name, color: t.color, delta: tuiText, replace: true });
|
|
2911
|
+
}
|
|
2912
|
+
for (const tool of folded.tools || []) noteTool(tool.name, tool.input);
|
|
2913
|
+
return;
|
|
2914
|
+
}
|
|
2915
|
+
if (folded.kind !== 'assistant') return;
|
|
2916
|
+
if (folded.thinking && !thinking) {
|
|
2917
|
+
thinking = folded.thinking;
|
|
2918
|
+
paint({ type: 'think', name: t.name, color: t.color, delta: folded.thinking });
|
|
2919
|
+
} else {
|
|
2920
|
+
keepFold();
|
|
2921
|
+
}
|
|
2922
|
+
if (folded.text && !streamedText) {
|
|
2923
|
+
const clean = sanitizeClaudeCanvas(folded.text);
|
|
2924
|
+
if (clean) {
|
|
2925
|
+
visible = clean;
|
|
2926
|
+
paint({ type: 'delta', name: t.name, color: t.color, delta: clean, replace: true });
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
for (const tool of folded.tools || []) noteTool(tool.name, tool.input);
|
|
2930
|
+
},
|
|
2931
|
+
});
|
|
2932
|
+
if (!stillMine()) {
|
|
2933
|
+
return sanitizeClaudeCanvas(result.paymentFailed || result.text || visible || '', { error: result.error });
|
|
2934
|
+
}
|
|
2935
|
+
if (result.sessionId) t.claudeSessionId = result.sessionId;
|
|
2936
|
+
const thinkAll = [thinking, ...toolLines].filter(Boolean).join('\n');
|
|
2937
|
+
let finalText = result.paymentFailed
|
|
2938
|
+
|| result.text
|
|
2939
|
+
|| visible
|
|
2940
|
+
|| (result.missing ? CLAUDE_MISSING : '');
|
|
2941
|
+
if (!result.paymentFailed && !result.missing) {
|
|
2942
|
+
finalText = sanitizeClaudeCanvas(finalText, { error: result.error });
|
|
2943
|
+
}
|
|
2944
|
+
if (!finalText) finalText = result.error ? 'upstream HTTP 400' : '(no response)';
|
|
2945
|
+
// Empty / chrome-only / HTTP-N: do not commit a dead bot row. runTurn pops
|
|
2946
|
+
// any that slipped through and falls through to Ask/Auto chat-completions.
|
|
2947
|
+
if (isClaudeFallbackReply(finalText) && !result.paymentFailed && !result.missing) {
|
|
2948
|
+
return finalText;
|
|
2949
|
+
}
|
|
2950
|
+
t.history.push({ who: 'bot', text: finalText, thinking: thinkAll || undefined });
|
|
2951
|
+
paint({ type: 'final', name: t.name, color: t.color, text: finalText, thinking: thinkAll || undefined });
|
|
2952
|
+
return finalText;
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2620
2955
|
// onEvent (optional) gets live progress for whoever's actually watching this
|
|
2621
2956
|
// call: {type:'start',name,color} when a bot begins its turn, {type:'status',
|
|
2622
2957
|
// detail} while paying / waiting / racing / walking tools, {type:'race',race}
|
|
2623
2958
|
// for the spectator grid (one cell per launched model + a judging beat),
|
|
2624
2959
|
// {type:'delta',name,color,delta} per streamed token (replace:true swaps the
|
|
2625
|
-
// bubble once), {type:'
|
|
2960
|
+
// bubble once), {type:'think',delta} for folded chain-of-thought (not the
|
|
2961
|
+
// Auto run-mode chip), {type:'final',name,color,text} once its full reply (or
|
|
2626
2962
|
// directive ack) is settled. Background turns go through kickTurn →
|
|
2627
2963
|
// emitToThread, which is a no-op if nobody has the thread open.
|
|
2628
2964
|
async function runTurn(threadId, userText, onEvent, images) {
|
|
@@ -2642,14 +2978,15 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2642
2978
|
if (!stillMine()) return;
|
|
2643
2979
|
if (ev.type === 'status' && ev.detail && t.status === 'thinking') t.liveStatus = ev.detail;
|
|
2644
2980
|
if (ev.type === 'race' && ev.race && t.status === 'thinking') t.liveRace = ev.race;
|
|
2645
|
-
if (ev.type === 'delta' || ev.type === 'status' || ev.type === 'start' || ev.type === 'race') t.lastDeltaAt = Date.now();
|
|
2981
|
+
if (ev.type === 'delta' || ev.type === 'think' || ev.type === 'status' || ev.type === 'start' || ev.type === 'race' || ev.type === 'tool') t.lastDeltaAt = Date.now();
|
|
2646
2982
|
onEvent?.(ev);
|
|
2647
2983
|
};
|
|
2648
|
-
t
|
|
2984
|
+
persistUserTurn(t, userText, images);
|
|
2649
2985
|
t.lastActivityAt = Date.now();
|
|
2650
2986
|
t.status = 'thinking';
|
|
2651
2987
|
t.thinkingAt = Date.now();
|
|
2652
2988
|
t.lastDeltaAt = Date.now();
|
|
2989
|
+
saveThreads();
|
|
2653
2990
|
try { turnAborts.get(t)?.abort(); } catch { /* none */ }
|
|
2654
2991
|
const turnAbort = new AbortController();
|
|
2655
2992
|
turnAborts.set(t, turnAbort);
|
|
@@ -2660,6 +2997,8 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2660
2997
|
t.liveStatus = (!t.model && raceN >= 2) ? formatRaceStatus(0, raceNeed) : 'waiting on model…';
|
|
2661
2998
|
let chained = false;
|
|
2662
2999
|
let parked = false;
|
|
3000
|
+
let usedClaude = false;
|
|
3001
|
+
let claudeFallback = false;
|
|
2663
3002
|
let lastReply = '';
|
|
2664
3003
|
try {
|
|
2665
3004
|
if (t.members) {
|
|
@@ -2675,11 +3014,15 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2675
3014
|
const emitStatus = (detail) => paint({ type: 'status', name: m.name, color: m.color, detail });
|
|
2676
3015
|
try {
|
|
2677
3016
|
r = onEvent
|
|
2678
|
-
? (await brainStream(msgs, (delta) =>
|
|
3017
|
+
? (await brainStream(msgs, (delta, meta) => {
|
|
3018
|
+
if (meta?.think) paint({ type: 'think', name: m.name, color: m.color, delta });
|
|
3019
|
+
else paint({ type: 'delta', name: m.name, color: m.color, delta });
|
|
3020
|
+
}, t.contextId, undefined, undefined, 0, 0, emitStatus)).trim()
|
|
2679
3021
|
: (await brain(msgs, t.contextId)).trim();
|
|
2680
3022
|
} catch (e) { r = `error: ${e.message}`; }
|
|
2681
3023
|
if (!stillMine()) return;
|
|
2682
|
-
|
|
3024
|
+
const memberThink = takeThink(r);
|
|
3025
|
+
r = memberThink.text;
|
|
2683
3026
|
memberReply = r;
|
|
2684
3027
|
const runCmd = parseRun(r);
|
|
2685
3028
|
if (runCmd) {
|
|
@@ -2689,16 +3032,19 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2689
3032
|
const output = await execCommand(command, dirFor(t.id));
|
|
2690
3033
|
noteRunForCorpus(t.id, command, { cwd: dirFor(t.id) });
|
|
2691
3034
|
const shown = `$ ${command}\n${output}`;
|
|
2692
|
-
t.history.push({
|
|
2693
|
-
|
|
3035
|
+
t.history.push({
|
|
3036
|
+
who: 'bot', text: command, runStatus: 'done', runOutput: output,
|
|
3037
|
+
name: m.name, color: m.color, thinking: memberThink.thinking,
|
|
3038
|
+
});
|
|
3039
|
+
paint({ type: 'final', name: m.name, color: m.color, text: command, thinking: memberThink.thinking });
|
|
2694
3040
|
memberReply = shown;
|
|
2695
3041
|
// this member's turn is done; the round continues to the next member
|
|
2696
3042
|
continue;
|
|
2697
3043
|
}
|
|
2698
3044
|
const runId = randomUUID();
|
|
2699
3045
|
t.pendingRun = { runId, command, cwd: dirFor(t.id) };
|
|
2700
|
-
t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending', name: m.name, color: m.color });
|
|
2701
|
-
paint({ type: 'run-pending', runId, command, name: m.name, color: m.color });
|
|
3046
|
+
t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending', name: m.name, color: m.color, thinking: memberThink.thinking });
|
|
3047
|
+
paint({ type: 'run-pending', runId, command, name: m.name, color: m.color, thinking: memberThink.thinking });
|
|
2702
3048
|
// pauses the WHOLE round here — the rest of the group gets their turn
|
|
2703
3049
|
// on the round that runs after the user approves/denies
|
|
2704
3050
|
parked = true;
|
|
@@ -2706,8 +3052,8 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2706
3052
|
}
|
|
2707
3053
|
const ack = await tryDirective(r, t.id, paint);
|
|
2708
3054
|
const finalText = ack ?? (r || '(no response)');
|
|
2709
|
-
t.history.push({ who: 'bot', text: finalText, name: m.name, color: m.color });
|
|
2710
|
-
paint({ type: 'final', name: m.name, color: m.color, text: finalText });
|
|
3055
|
+
t.history.push({ who: 'bot', text: finalText, name: m.name, color: m.color, thinking: memberThink.thinking });
|
|
3056
|
+
paint({ type: 'final', name: m.name, color: m.color, text: finalText, thinking: memberThink.thinking });
|
|
2711
3057
|
memberReply = r;
|
|
2712
3058
|
}
|
|
2713
3059
|
bindThread(t).catch(() => {});
|
|
@@ -2717,6 +3063,38 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2717
3063
|
}
|
|
2718
3064
|
return;
|
|
2719
3065
|
}
|
|
3066
|
+
// Orange Auto is interactive Claude Code on a PTY (`openzoo claude` env),
|
|
3067
|
+
// not --print and not the RUN:/WRITE: text loop. Ask still uses chat/completions.
|
|
3068
|
+
if (t.runMode === 'auto') {
|
|
3069
|
+
usedClaude = true;
|
|
3070
|
+
// Auto/Auto: a PTY spawn, --resume, or proxy 500 used to remount the
|
|
3071
|
+
// canvas before finally-saveThreads. Flush the real user turn (history
|
|
3072
|
+
// + messages + disk) immediately before the PTY turn. Harness continue
|
|
3073
|
+
// text is skipped — it must not replace the just-sent bubble.
|
|
3074
|
+
persistUserTurn(t, userText, images);
|
|
3075
|
+
saveThreads();
|
|
3076
|
+
// 1.5.99 PTY Auto swallowed the first send: persist worked, waitIdle
|
|
3077
|
+
// treated a quiet TUI as done, sanitizeClaudeCanvas wiped chrome, no bot
|
|
3078
|
+
// bubble. Skip PTY until a send has produced a visible reply; serve
|
|
3079
|
+
// Ask/Auto chat-completions. Once a visible bot row exists, try PTY and
|
|
3080
|
+
// still fall through if that turn comes back empty / HTTP-N.
|
|
3081
|
+
if (threadHasVisibleBotReply(t)) {
|
|
3082
|
+
usedClaude = true;
|
|
3083
|
+
lastReply = await runAutoClaudeTurn(t, userText, images, paint, stillMine, turnAbort);
|
|
3084
|
+
if (!stillMine()) return;
|
|
3085
|
+
if (!isClaudeFallbackReply(lastReply)) {
|
|
3086
|
+
bindThread(t).catch(() => {});
|
|
3087
|
+
return;
|
|
3088
|
+
}
|
|
3089
|
+
popClaudeFallbackBot(t);
|
|
3090
|
+
usedClaude = false;
|
|
3091
|
+
claudeFallback = true;
|
|
3092
|
+
paint({ type: 'status', name: t.name, color: t.color, detail: 'retrying…' });
|
|
3093
|
+
} else {
|
|
3094
|
+
usedClaude = false;
|
|
3095
|
+
claudeFallback = true;
|
|
3096
|
+
}
|
|
3097
|
+
}
|
|
2720
3098
|
// A real message from the user resets the auto budget AND the announcement
|
|
2721
3099
|
// nudge. Harness-injected hops (command output, directive result, nudge,
|
|
2722
3100
|
// auto-continue) must not re-arm — that would make AUTO_MAX_STEPS a no-op
|
|
@@ -2726,7 +3104,12 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2726
3104
|
t.autoSteps = 0;
|
|
2727
3105
|
delete t.autoNudged;
|
|
2728
3106
|
}
|
|
2729
|
-
|
|
3107
|
+
// Real user text was already flushed in persistUserTurn (history + messages).
|
|
3108
|
+
// Harness hops stay out of the transcript; ask-mode still needs them in
|
|
3109
|
+
// t.messages so the model sees the continue without a user bubble.
|
|
3110
|
+
if (isHarnessUserText(userText) && Array.isArray(t.messages)) {
|
|
3111
|
+
t.messages.push({ role: 'user', content: contentFor(userText, images) });
|
|
3112
|
+
}
|
|
2730
3113
|
let reply = '';
|
|
2731
3114
|
paint({ type: 'start', name: t.name, color: t.color, detail: t.liveStatus || 'waiting on model…' });
|
|
2732
3115
|
// Transient: the nudge is appended for THIS call only and never pushed into
|
|
@@ -2743,7 +3126,8 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2743
3126
|
if (t.todos?.length) {
|
|
2744
3127
|
extras.push({ role: 'system', content: `Current checklist (TODO: done <n> to tick one off):\n${t.todos.map((x, i) => `${i + 1}. [${x.done ? 'x' : ' '}] ${x.text}`).join('\n')}` });
|
|
2745
3128
|
}
|
|
2746
|
-
|
|
3129
|
+
extras.push({ role: 'system', content: CHAT_NOT_PROXY });
|
|
3130
|
+
if (t.runMode === 'auto' && !claudeFallback) extras.push({ role: 'system', content: AUTO_DIRECTIVE });
|
|
2747
3131
|
const callMsgs = extras.length ? [...t.messages, ...extras] : t.messages;
|
|
2748
3132
|
// WHICH MODEL SERVES THIS TURN.
|
|
2749
3133
|
// /model <id> pins one explicitly and always wins — an explicit choice is
|
|
@@ -2758,18 +3142,34 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2758
3142
|
thread: t, attempt, userText, messages: callMsgs,
|
|
2759
3143
|
}) ?? '').trim();
|
|
2760
3144
|
}
|
|
2761
|
-
const emit = (delta, meta) =>
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
3145
|
+
const emit = (delta, meta) => {
|
|
3146
|
+
if (meta?.think) {
|
|
3147
|
+
paint({ type: 'think', name: t.name, color: t.color, delta });
|
|
3148
|
+
return;
|
|
3149
|
+
}
|
|
3150
|
+
paint({
|
|
3151
|
+
type: 'delta', name: t.name, color: t.color, delta,
|
|
3152
|
+
...(meta?.replace ? { replace: true } : {}),
|
|
3153
|
+
...(meta?.model ? { model: meta.model } : {}),
|
|
3154
|
+
});
|
|
3155
|
+
};
|
|
2766
3156
|
const emitStatus = (detail) => paint({ type: 'status', name: t.name, color: t.color, detail });
|
|
2767
|
-
//
|
|
2768
|
-
//
|
|
2769
|
-
const topK = adaptiveTopK((
|
|
3157
|
+
// SPAWN kids search the project root's corpus. A brand-new chat is its
|
|
3158
|
+
// own holobrain — do not scale top_k off another thread's boundItems.
|
|
3159
|
+
const topK = adaptiveTopK((holobrainOf(t) || t).boundItems);
|
|
2770
3160
|
const race = Math.min(Number(t.race) || 0, 4);
|
|
2771
3161
|
if (!t.model && race >= 2) {
|
|
2772
|
-
const
|
|
3162
|
+
const useAuto = !t.tier || t.tier === 'auto';
|
|
3163
|
+
let models = [];
|
|
3164
|
+
if (useAuto) {
|
|
3165
|
+
try {
|
|
3166
|
+
const routed = routeChatBody({ messages: callMsgs }, { k: race, allow_free: false, bindable: true });
|
|
3167
|
+
models = (routed.shortlist || []).map((s) => s.model).filter(Boolean);
|
|
3168
|
+
if (routed.model && !models.includes(routed.model)) models.unshift(routed.model);
|
|
3169
|
+
models = [...new Set(models)].slice(0, race);
|
|
3170
|
+
} catch { models = []; }
|
|
3171
|
+
}
|
|
3172
|
+
if (!models.length) models = await tierModels(useAuto ? 'medium' : (t.tier || 'medium'), race, true);
|
|
2773
3173
|
// need = how many must come BACK before judging. need 1 is a plain
|
|
2774
3174
|
// first-past-the-post race; need N waits for all of them. The point of
|
|
2775
3175
|
// the middle (2 of 3) is a judged answer without the slowest entrant
|
|
@@ -2784,7 +3184,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2784
3184
|
})).trim();
|
|
2785
3185
|
}
|
|
2786
3186
|
// A retry draws a DIFFERENT model from the tier rather than the same one.
|
|
2787
|
-
const model = t.model ||
|
|
3187
|
+
const model = t.model || 'openzoo/auto';
|
|
2788
3188
|
return (onEvent
|
|
2789
3189
|
? (await brainStream(callMsgs, emit, t.contextId, model, undefined, 0, topK, emitStatus)).trim()
|
|
2790
3190
|
: (await brain(callMsgs, t.contextId, model, topK)).trim());
|
|
@@ -2822,20 +3222,21 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2822
3222
|
reply = `error: ${e.message}`;
|
|
2823
3223
|
}
|
|
2824
3224
|
if (!stillMine()) return;
|
|
2825
|
-
|
|
3225
|
+
const settled = takeThink(reply);
|
|
3226
|
+
reply = settled.text;
|
|
2826
3227
|
lastReply = reply;
|
|
2827
3228
|
t.messages.push({ role: 'assistant', content: reply });
|
|
2828
3229
|
const runCmd = parseRun(reply);
|
|
2829
3230
|
if (runCmd) {
|
|
2830
3231
|
const command = runCmd;
|
|
2831
|
-
if (t.runMode === 'auto') {
|
|
3232
|
+
if (t.runMode === 'auto' && !claudeFallback) {
|
|
2832
3233
|
emitLiveStatus(t.id, paint, peekDirectiveStatus('', command));
|
|
2833
3234
|
const output = await execCommand(command, dirFor(t.id));
|
|
2834
3235
|
noteRunForCorpus(t.id, command, { cwd: dirFor(t.id) });
|
|
2835
3236
|
if (!stillMine()) return;
|
|
2836
3237
|
const shown = `$ ${command}\n${output}`;
|
|
2837
|
-
t.history.push({ who: 'bot', text:
|
|
2838
|
-
paint({ type: 'final', name: t.name, color: t.color, text:
|
|
3238
|
+
t.history.push({ who: 'bot', text: command, runStatus: 'done', runOutput: output, thinking: settled.thinking });
|
|
3239
|
+
paint({ type: 'final', name: t.name, color: t.color, text: command, thinking: settled.thinking });
|
|
2839
3240
|
// FEED THE OUTPUT BACK. The 'ask' path already does this on approve, so
|
|
2840
3241
|
// auto mode was strictly LESS capable than the gated one: the command
|
|
2841
3242
|
// ran, the result was shown, and the model never saw it — no diagnosis,
|
|
@@ -2860,8 +3261,8 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2860
3261
|
{
|
|
2861
3262
|
const runId = randomUUID();
|
|
2862
3263
|
t.pendingRun = { runId, command, cwd: dirFor(t.id) };
|
|
2863
|
-
t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending' });
|
|
2864
|
-
paint({ type: 'run-pending', runId, command, name: t.name, color: t.color });
|
|
3264
|
+
t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending', thinking: settled.thinking });
|
|
3265
|
+
paint({ type: 'run-pending', runId, command, name: t.name, color: t.color, thinking: settled.thinking });
|
|
2865
3266
|
}
|
|
2866
3267
|
parked = true;
|
|
2867
3268
|
return;
|
|
@@ -2869,8 +3270,8 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2869
3270
|
const ack = await tryDirective(reply, t.id, paint);
|
|
2870
3271
|
if (!stillMine()) return;
|
|
2871
3272
|
const finalText = ack ?? (reply || '(no response)');
|
|
2872
|
-
t.history.push({ who: 'bot', text: finalText });
|
|
2873
|
-
paint({ type: 'final', name: t.name, color: t.color, text: finalText });
|
|
3273
|
+
t.history.push({ who: 'bot', text: finalText, thinking: settled.thinking });
|
|
3274
|
+
paint({ type: 'final', name: t.name, color: t.color, text: finalText, thinking: settled.thinking });
|
|
2874
3275
|
|
|
2875
3276
|
// AUTO CONTINUES AFTER *ANY* DIRECTIVE, not just RUN.
|
|
2876
3277
|
//
|
|
@@ -2883,7 +3284,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2883
3284
|
//
|
|
2884
3285
|
// Same budget as RUN (shared t.autoSteps, reset when the user speaks), so
|
|
2885
3286
|
// this cannot spend more than a chained RUN loop already could.
|
|
2886
|
-
if (t.runMode === 'auto' && ack !== null && ack !== undefined
|
|
3287
|
+
if (t.runMode === 'auto' && !claudeFallback && ack !== null && ack !== undefined
|
|
2887
3288
|
&& (isEmptyDirectiveAck(ack) || !isDoneReply(reply))) {
|
|
2888
3289
|
chained = enqueueAutoHop(
|
|
2889
3290
|
t, threadId,
|
|
@@ -2907,7 +3308,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2907
3308
|
// NUDGE announcements (stronger than a bare continue). Plain replies used
|
|
2908
3309
|
// to park here; they now fall through to AUTO_CONTINUE unless DONE: or a
|
|
2909
3310
|
// real blocking question. The step cap is the wallet bound.
|
|
2910
|
-
if (t.runMode === 'auto' && (ack === null || ack === undefined)
|
|
3311
|
+
if (t.runMode === 'auto' && !claudeFallback && (ack === null || ack === undefined)
|
|
2911
3312
|
&& (STALLED_OFFER.test(reply) || ANNOUNCEMENT.test(reply))) {
|
|
2912
3313
|
// Offers and "Spawned X — working on it" with no directive must not end
|
|
2913
3314
|
// the run. The old once-only autoNudged gate parked the thread after one
|
|
@@ -2919,7 +3320,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2919
3320
|
|
|
2920
3321
|
// After any auto reply that is not DONE: and not waiting on approval,
|
|
2921
3322
|
// kick immediately. Race/empty/error uses AUTO_RACE_RETRY.
|
|
2922
|
-
if (shouldKeepAuto(t, reply, userText)) {
|
|
3323
|
+
if (!claudeFallback && shouldKeepAuto(t, reply, userText)) {
|
|
2923
3324
|
chained = enqueueAutoHop(t, threadId, autoHopText(reply, userText), onEvent);
|
|
2924
3325
|
return;
|
|
2925
3326
|
}
|
|
@@ -2929,7 +3330,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2929
3330
|
// ask mode, 402/empty-wallet, or the hard cap. Empty /(no output) is not
|
|
2930
3331
|
// DONE — AUTO_EMPTY_RETRY. Otherwise kick again.
|
|
2931
3332
|
if (stillMine() && !chained && !parked) {
|
|
2932
|
-
if (shouldKeepAuto(t, lastReply, userText)) {
|
|
3333
|
+
if (!usedClaude && !claudeFallback && shouldKeepAuto(t, lastReply, userText)) {
|
|
2933
3334
|
enqueueAutoHop(t, threadId, autoHopText(lastReply, userText), onEvent);
|
|
2934
3335
|
} else if (!t.pendingRun) {
|
|
2935
3336
|
t.status = 'idle';
|
|
@@ -3058,7 +3459,8 @@ function subtreeOf(id, includeSelf = false) {
|
|
|
3058
3459
|
}
|
|
3059
3460
|
|
|
3060
3461
|
function threadSummary(t) {
|
|
3061
|
-
const
|
|
3462
|
+
const vis = visibleHistory(t.history);
|
|
3463
|
+
const last = vis[vis.length - 1];
|
|
3062
3464
|
return { id: t.id, name: t.name, color: t.color, parent: t.parent, status: t.status,
|
|
3063
3465
|
preview: last ? (last.who === 'user' ? last.text : last.text).slice(0, 60) : '',
|
|
3064
3466
|
createdAt: t.createdAt, lastActivityAt: t.lastActivityAt || t.createdAt,
|
|
@@ -3090,19 +3492,19 @@ const APP_HTML = `<!doctype html>
|
|
|
3090
3492
|
<style>
|
|
3091
3493
|
:root { color-scheme: dark; }
|
|
3092
3494
|
* { box-sizing: border-box; }
|
|
3093
|
-
html, body { margin: 0; height: 100%; background: #000; }
|
|
3495
|
+
html, body { margin: 0; height: 100%; width: 100%; overflow: hidden; background: #000; }
|
|
3094
3496
|
body { color: #ececec; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
3095
3497
|
display: flex; }
|
|
3096
3498
|
#dragbar { -webkit-app-region: drag; position: fixed; top: 0; left: 0; right: 0; height: 28px; z-index: 1000; }
|
|
3097
3499
|
#sidebar { width: 280px; flex: 0 0 280px; border-right: 1px solid #1c1c1e; display: flex; flex-direction: column;
|
|
3098
|
-
height:
|
|
3500
|
+
height: 100%; padding-top: 28px; min-height: 0; position: relative; }
|
|
3099
3501
|
#main { padding-top: 28px; }
|
|
3100
3502
|
#sideTop { display: flex; align-items: center; gap: 4px; padding: 0 8px; }
|
|
3101
3503
|
#sideTop #search { flex: 1; }
|
|
3102
3504
|
#search { margin: 12px; padding: 8px 12px; background: #1c1c1e; border-radius: 10px; color: #ececec;
|
|
3103
3505
|
border: none; font: inherit; }
|
|
3104
3506
|
#search::placeholder { color: #8e8e93; }
|
|
3105
|
-
#threads { flex: 1; overflow-y: auto; }
|
|
3507
|
+
#threads { flex: 1; min-height: 0; overflow-y: auto; }
|
|
3106
3508
|
.trow { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; border-radius: 10px;
|
|
3107
3509
|
margin: 0 6px 2px; }
|
|
3108
3510
|
/* PROJECT HEADER. The tree indentation shows who spawned whom, but there was
|
|
@@ -3167,7 +3569,7 @@ const APP_HTML = `<!doctype html>
|
|
|
3167
3569
|
@media (prefers-reduced-motion: reduce) {
|
|
3168
3570
|
.twarn, .bot-pfp .bot-bob, .bot-pfp .bot-eyes { animation: none; }
|
|
3169
3571
|
}
|
|
3170
|
-
#main { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; height:
|
|
3572
|
+
#main { position: relative; flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; height: 100%; }
|
|
3171
3573
|
#chatHeader { padding: 14px 20px; border-bottom: 1px solid #1c1c1e; display: flex; align-items: center;
|
|
3172
3574
|
flex-wrap: wrap; gap: 8px 10px; font-weight: 600; }
|
|
3173
3575
|
#chatHeader .tavatar { width: 26px; height: 26px; border-radius: 50%; font-size: 11px; flex: 0 0 26px; }
|
|
@@ -3212,6 +3614,10 @@ const APP_HTML = `<!doctype html>
|
|
|
3212
3614
|
#walletOverlay, #sitrepOverlay { position: fixed; inset: 0; background: rgba(0,0,0,.66); z-index: 1200;
|
|
3213
3615
|
display: none; align-items: center; justify-content: center; padding: 24px; }
|
|
3214
3616
|
#walletOverlay.show, #sitrepOverlay.show { display: flex; }
|
|
3617
|
+
.payneed-btn { display: block; margin-top: 10px; border: 0; cursor: pointer;
|
|
3618
|
+
background: #b8f240; color: #0b0b0d; font: 700 12px/1.2 inherit;
|
|
3619
|
+
letter-spacing: .04em; padding: 7px 14px; border-radius: 999px; }
|
|
3620
|
+
.payneed-btn:hover { filter: brightness(1.05); }
|
|
3215
3621
|
#walletBox, #sitrepBox { width: 100%; max-width: 560px; max-height: 82vh; overflow-y: auto; background: #111113;
|
|
3216
3622
|
border: 1px solid #2c2c2e; border-radius: 16px; padding: 20px 22px; }
|
|
3217
3623
|
#walletBox h3, #sitrepBox h3 { margin: 0 0 2px; font-size: 15px; font-weight: 600; }
|
|
@@ -3290,6 +3696,21 @@ const APP_HTML = `<!doctype html>
|
|
|
3290
3696
|
#hud { position: absolute; right: 14px; width: 270px; background: rgba(14,14,17,.94);
|
|
3291
3697
|
border: 1px solid #333340; border-radius: 10px; padding: 12px 14px; font: 11px/1.5 Menlo, monospace;
|
|
3292
3698
|
display: none; z-index: 300; box-shadow: 0 12px 30px rgba(0,0,0,.5); }
|
|
3699
|
+
/* Always-on strip. Sidebar footer — bottom-left of the window, inside the
|
|
3700
|
+
bot list column. Never a child of #main: position:absolute; left:14px
|
|
3701
|
+
there sat on the transcript and covered the last bubbles. ◎ stays a
|
|
3702
|
+
toggle; this one never hides. pointer-events none so it cannot steal
|
|
3703
|
+
thread-row clicks. */
|
|
3704
|
+
#dockHud { flex: 0 0 auto; position: relative; left: auto; bottom: auto;
|
|
3705
|
+
width: 100%; max-width: 100%; z-index: 1;
|
|
3706
|
+
pointer-events: none; background: rgba(14,14,17,.94);
|
|
3707
|
+
border: 0; border-top: 1px solid #333340; border-radius: 0;
|
|
3708
|
+
padding: 8px 12px 12px; color: #8e8e93;
|
|
3709
|
+
font: 10.5px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
3710
|
+
display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: baseline; }
|
|
3711
|
+
#dockHud .dk { color: #6f7080; }
|
|
3712
|
+
#dockHud .dv { color: #c8c8c2; }
|
|
3713
|
+
#dockHud .dv.hlime { color: #b8f240; }
|
|
3293
3714
|
#hud.show { display: block; }
|
|
3294
3715
|
#hud .htitle { color: #b8f240; font-size: 10px; letter-spacing: .04em; margin-bottom: 10px; }
|
|
3295
3716
|
#hud .htitle.hsession { margin-top: 12px; padding-top: 10px; border-top: 1px solid #333340; color: #6f7080; }
|
|
@@ -3307,8 +3728,8 @@ const APP_HTML = `<!doctype html>
|
|
|
3307
3728
|
color: #f0c9a8; font-size: 10.5px; line-height: 1.45; }
|
|
3308
3729
|
#hud .hhint.show { display: block; }
|
|
3309
3730
|
#hud .hhint b { color: #f28c4d; font-weight: 600; }
|
|
3310
|
-
#sidebar, #main, #walletOverlay, #sitrepOverlay, #composeOverlay,
|
|
3311
|
-
#inp, #search, #composeInp, .bubble, .md-pre, .runoutput, .runcmd {
|
|
3731
|
+
#sidebar, #main, #walletOverlay, #sitrepOverlay, #composeOverlay, #findBar,
|
|
3732
|
+
#inp, #search, #composeInp, #findInp, .bubble, .md-pre, .runoutput, .runcmd {
|
|
3312
3733
|
-webkit-app-region: no-drag; }
|
|
3313
3734
|
#log { flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 20px 24px 12px;
|
|
3314
3735
|
display: flex; flex-direction: column; gap: 6px;
|
|
@@ -3390,6 +3811,18 @@ const APP_HTML = `<!doctype html>
|
|
|
3390
3811
|
}
|
|
3391
3812
|
.runcard { background: #1c1c1e; border: 1px solid #333; border-radius: 14px; padding: 12px 14px;
|
|
3392
3813
|
max-width: 100%; min-width: 0; overflow: hidden; }
|
|
3814
|
+
/* Done RUNs collapse like thinking… — raw curl JSON must not be the message. */
|
|
3815
|
+
.runcard.folded { background: transparent; border: none; padding: 0; }
|
|
3816
|
+
.runfold { align-self: flex-start; max-width: 100%; min-width: 0; }
|
|
3817
|
+
.runchip {
|
|
3818
|
+
border: 0; background: transparent; color: #8e8e93; padding: 0;
|
|
3819
|
+
font: 12px/1.4 inherit; cursor: pointer; letter-spacing: .01em;
|
|
3820
|
+
-webkit-user-select: none; user-select: none;
|
|
3821
|
+
}
|
|
3822
|
+
.runchip:hover { color: #c8c8d0; }
|
|
3823
|
+
.runchip:focus-visible { outline: 2px solid #ff9500; outline-offset: 2px; border-radius: 4px; }
|
|
3824
|
+
.runbody { display: none; margin: 4px 0 2px; }
|
|
3825
|
+
.runfold.open .runbody { display: block; }
|
|
3393
3826
|
.runcmd { font-family: Menlo, monospace; font-size: 12.5px; color: #ececec; white-space: pre-wrap;
|
|
3394
3827
|
word-break: break-word; margin-bottom: 8px; }
|
|
3395
3828
|
.runactions { display: flex; gap: 8px; }
|
|
@@ -3402,6 +3835,34 @@ const APP_HTML = `<!doctype html>
|
|
|
3402
3835
|
word-break: break-word; max-height: 240px; overflow-y: auto; margin: 0; }
|
|
3403
3836
|
.row.user .bubble { background: #57575c; }
|
|
3404
3837
|
.row.bot .bubble { background: #262626; color: #ececec; }
|
|
3838
|
+
/* Folded chain-of-thought. Not the Auto run-mode chip — that is /mode auto.
|
|
3839
|
+
Default collapsed; click the label to unfurl, click again to furl.
|
|
3840
|
+
Live Auto turns always show thinking… while the Claude child is alive. */
|
|
3841
|
+
.msgcol { display: flex; flex-direction: column; gap: 6px; min-width: 0; max-width: 100%; flex: 1; }
|
|
3842
|
+
.thinkfold { align-self: flex-start; max-width: 100%; min-width: 0; }
|
|
3843
|
+
.thinkchip {
|
|
3844
|
+
border: 0; background: transparent; color: #8e8e93; padding: 0;
|
|
3845
|
+
font: 12px/1.4 inherit; cursor: pointer; letter-spacing: .01em;
|
|
3846
|
+
-webkit-user-select: none; user-select: none;
|
|
3847
|
+
}
|
|
3848
|
+
.thinkchip:hover { color: #c8c8d0; }
|
|
3849
|
+
.thinkchip:focus-visible { outline: 2px solid #b8f240; outline-offset: 2px; border-radius: 4px; }
|
|
3850
|
+
.thinkbody {
|
|
3851
|
+
display: none; margin: 4px 0 2px; padding: 8px 12px;
|
|
3852
|
+
color: #8e8e93; font-size: 12.5px; line-height: 1.45;
|
|
3853
|
+
white-space: pre-wrap; word-break: break-word;
|
|
3854
|
+
border-left: 2px solid #3a3a3c; max-height: 240px; overflow-y: auto;
|
|
3855
|
+
}
|
|
3856
|
+
.thinkfold.open .thinkbody { display: block; }
|
|
3857
|
+
/* Compact Claude TUI surface. Stripped text is the bubble; this is the
|
|
3858
|
+
optional monospace frame (folded by default, same chip pattern). */
|
|
3859
|
+
.claudeterm {
|
|
3860
|
+
display: none; margin: 4px 0 2px; padding: 8px 10px;
|
|
3861
|
+
font: 11.5px/1.4 Menlo, ui-monospace, monospace; color: #c8c8d0;
|
|
3862
|
+
background: #141416; border: 1px solid #2a2a2d; border-radius: 8px;
|
|
3863
|
+
white-space: pre-wrap; word-break: break-word; max-height: 220px; overflow: auto;
|
|
3864
|
+
}
|
|
3865
|
+
.thinkfold.open .claudeterm { display: block; }
|
|
3405
3866
|
.row.bot.pending .bubble { color: #8e8e93; }
|
|
3406
3867
|
.dots span { display: inline-block; width: 5px; height: 5px; margin-right: 3px; border-radius: 50%;
|
|
3407
3868
|
background: #8e8e93; animation: blink 1.2s infinite ease-in-out; }
|
|
@@ -3526,6 +3987,23 @@ const APP_HTML = `<!doctype html>
|
|
|
3526
3987
|
transition: opacity .14s ease, transform .14s ease;
|
|
3527
3988
|
}
|
|
3528
3989
|
#copiedToast.show { opacity: 1; transform: translate(-50%, 0); }
|
|
3990
|
+
/* FIND IN THREAD. Cmd/Ctrl+F — not the sidebar thread search (that's Cmd+K).
|
|
3991
|
+
Sits over #log so matches stay in the conversation, not the thread list. */
|
|
3992
|
+
#findBar { display: none; position: absolute; right: 16px; z-index: 320;
|
|
3993
|
+
align-items: center; gap: 6px; background: rgba(18,18,22,.98);
|
|
3994
|
+
border: 1px solid #3a3a3c; border-radius: 12px; padding: 6px 8px;
|
|
3995
|
+
box-shadow: 0 10px 28px rgba(0,0,0,.45); }
|
|
3996
|
+
#findBar.show { display: flex; }
|
|
3997
|
+
#findInp { width: 180px; background: #0b0b0d; border: 1px solid #2c2c2e; border-radius: 8px;
|
|
3998
|
+
color: #ececec; font: inherit; font-size: 13px; padding: 5px 8px; }
|
|
3999
|
+
#findInp:focus { outline: 2px solid #6ab0ff; outline-offset: 1px; }
|
|
4000
|
+
#findCount { min-width: 48px; color: #8e8e93; font-size: 12px; text-align: right;
|
|
4001
|
+
font-variant-numeric: tabular-nums; }
|
|
4002
|
+
#findBar button { border: 0; background: transparent; color: #ececec; width: 26px; height: 26px;
|
|
4003
|
+
border-radius: 8px; cursor: pointer; font: inherit; line-height: 1; }
|
|
4004
|
+
#findBar button:hover { background: #3a3a3c; }
|
|
4005
|
+
mark.findhit { background: #f2d64b; color: #111; border-radius: 3px; padding: 0 1px; }
|
|
4006
|
+
mark.findhit.cur { background: #b8f240; }
|
|
3529
4007
|
</style></head>
|
|
3530
4008
|
<body>
|
|
3531
4009
|
<div id="copiedToast" role="status" aria-live="polite">copied</div>
|
|
@@ -3538,6 +4016,13 @@ const APP_HTML = `<!doctype html>
|
|
|
3538
4016
|
<input id="search" placeholder="Search">
|
|
3539
4017
|
</div>
|
|
3540
4018
|
<div id="threads"></div>
|
|
4019
|
+
<div id="dockHud" data-component="dock-hud">
|
|
4020
|
+
<span><span class="dk">spill</span> <span id="dockSpill" class="dv">—</span></span>
|
|
4021
|
+
<span><span class="dk">session</span> <span id="dockSession" class="dv">—</span></span>
|
|
4022
|
+
<span><span class="dk">paid</span> <span id="dockPaid" class="dv">—</span></span>
|
|
4023
|
+
<span><span class="dk">bind</span> <span id="dockBind" class="dv">no</span></span>
|
|
4024
|
+
<span><span class="dk">calls</span> <span id="dockCalls" class="dv">0</span></span>
|
|
4025
|
+
</div>
|
|
3541
4026
|
</div>
|
|
3542
4027
|
<div id="composeOverlay">
|
|
3543
4028
|
<div id="composeBox">
|
|
@@ -3598,9 +4083,10 @@ const APP_HTML = `<!doctype html>
|
|
|
3598
4083
|
title="Shell commands run immediately, with no approval prompt">auto</button>
|
|
3599
4084
|
</div>
|
|
3600
4085
|
<select class="dial" id="tierSel" data-component="model-tier" aria-label="Model tier"
|
|
3601
|
-
title="
|
|
4086
|
+
title="Auto = cheapest model that clears the bar. Other tiers only apply to /race.">
|
|
4087
|
+
<option value="auto" selected>auto</option>
|
|
3602
4088
|
<option value="cheap">cheap</option>
|
|
3603
|
-
<option value="medium"
|
|
4089
|
+
<option value="medium">medium</option>
|
|
3604
4090
|
<option value="expensive">expensive</option>
|
|
3605
4091
|
<option value="grok4.6">grok 4.6</option>
|
|
3606
4092
|
</select>
|
|
@@ -3638,6 +4124,13 @@ const APP_HTML = `<!doctype html>
|
|
|
3638
4124
|
<div class="hfoot" id="hFoot">loading…</div>
|
|
3639
4125
|
</div>
|
|
3640
4126
|
<div id="log"></div>
|
|
4127
|
+
<div id="findBar" role="search" data-component="find-in-thread">
|
|
4128
|
+
<input id="findInp" type="search" placeholder="Find in conversation" autocomplete="off" spellcheck="false">
|
|
4129
|
+
<span id="findCount" aria-live="polite"></span>
|
|
4130
|
+
<button type="button" id="findPrev" title="Previous" aria-label="Previous match">↑</button>
|
|
4131
|
+
<button type="button" id="findNext" title="Next" aria-label="Next match">↓</button>
|
|
4132
|
+
<button type="button" id="findClose" title="Close" aria-label="Close find">×</button>
|
|
4133
|
+
</div>
|
|
3641
4134
|
<div id="bar">
|
|
3642
4135
|
<div id="plusMenu">
|
|
3643
4136
|
<div class="pop-item" id="attachBtn">
|
|
@@ -3669,6 +4162,17 @@ const APP_HTML = `<!doctype html>
|
|
|
3669
4162
|
</div>
|
|
3670
4163
|
</div>
|
|
3671
4164
|
<script>
|
|
4165
|
+
function formatSavingLabel(you) {
|
|
4166
|
+
const spent = Number(you && you.spentUsd) || 0;
|
|
4167
|
+
if (spent <= 0) return { text: '—', mult: null, spilled: false };
|
|
4168
|
+
const spillX = Number(you && you.spilled && you.spilled.savingX);
|
|
4169
|
+
const sessionX = (Number(you && you.directUsd) || 0) / spent;
|
|
4170
|
+
const spilled = Number.isFinite(spillX) && spillX > 0;
|
|
4171
|
+
const mult = spilled ? spillX : sessionX;
|
|
4172
|
+
if (!Number.isFinite(mult)) return { text: '—', mult: null, spilled: false };
|
|
4173
|
+
const num = (mult >= 100 ? String(Math.round(mult)) : Number(mult).toFixed(mult >= 10 ? 1 : 2)) + 'x';
|
|
4174
|
+
return { text: num + (spilled ? ' spilled' : ' session'), mult, spilled };
|
|
4175
|
+
}
|
|
3672
4176
|
const threadsEl = document.getElementById('threads');
|
|
3673
4177
|
const chatHeader = document.getElementById('chatHeader');
|
|
3674
4178
|
const log = document.getElementById('log');
|
|
@@ -3999,7 +4503,7 @@ const APP_HTML = `<!doctype html>
|
|
|
3999
4503
|
const tierSel = document.getElementById('tierSel');
|
|
4000
4504
|
const raceSel = document.getElementById('raceSel');
|
|
4001
4505
|
if (!tierSel || !raceSel) return;
|
|
4002
|
-
tierSel.value = t.tier || '
|
|
4506
|
+
tierSel.value = t.tier || 'auto';
|
|
4003
4507
|
raceSel.value = (t.race || 0) < 2 ? '0'
|
|
4004
4508
|
: ((t.raceNeed || 1) > 1 ? t.raceNeed + ' ' + t.race : String(t.race));
|
|
4005
4509
|
// A pinned /model makes BOTH dials inert. Showing them live while they do
|
|
@@ -4022,15 +4526,18 @@ const APP_HTML = `<!doctype html>
|
|
|
4022
4526
|
}
|
|
4023
4527
|
}
|
|
4024
4528
|
|
|
4025
|
-
|
|
4529
|
+
// Header dials and Pay / ◎ all go through /drive so handleSlash (or the
|
|
4530
|
+
// /mode handler) appends the same short bot line as typing the command.
|
|
4531
|
+
async function echoSlash(task) {
|
|
4026
4532
|
if (!activeId) return;
|
|
4027
|
-
// Reuses the SAME slash-command path, so there is one implementation of the
|
|
4028
|
-
// rule rather than a second that can disagree with it.
|
|
4029
4533
|
await fetch(API + '/drive', { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
4030
|
-
body: JSON.stringify({ threadId: activeId, task:
|
|
4534
|
+
body: JSON.stringify({ threadId: activeId, task: task }) });
|
|
4031
4535
|
await loadThreads();
|
|
4032
4536
|
await render();
|
|
4033
4537
|
}
|
|
4538
|
+
async function setDial(cmd, value) {
|
|
4539
|
+
await echoSlash('/' + cmd + ' ' + value);
|
|
4540
|
+
}
|
|
4034
4541
|
// WALLET MODAL.
|
|
4035
4542
|
//
|
|
4036
4543
|
// Public addresses and balances only — /wallet proxies the proxy's own
|
|
@@ -4056,6 +4563,21 @@ const APP_HTML = `<!doctype html>
|
|
|
4056
4563
|
});
|
|
4057
4564
|
return row;
|
|
4058
4565
|
}
|
|
4566
|
+
function isEmptyWalletPayment(text) {
|
|
4567
|
+
// String ops. A word-boundary regex inside APP_HTML is eaten: \b
|
|
4568
|
+
// becomes a literal backspace and the client never matches a real 402.
|
|
4569
|
+
const s = String(text || '').toLowerCase();
|
|
4570
|
+
return s.includes('wallet is empty')
|
|
4571
|
+
|| s.includes('empty wallet')
|
|
4572
|
+
|| s.includes('wallet underfunded')
|
|
4573
|
+
|| s.includes('underfunded');
|
|
4574
|
+
}
|
|
4575
|
+
var openedPayForEmpty = false;
|
|
4576
|
+
function maybeOpenPayForEmptyWallet(text) {
|
|
4577
|
+
if (openedPayForEmpty || !isEmptyWalletPayment(text)) return;
|
|
4578
|
+
openedPayForEmpty = true;
|
|
4579
|
+
openWallet();
|
|
4580
|
+
}
|
|
4059
4581
|
async function openWallet() {
|
|
4060
4582
|
walletOverlay.classList.add('show');
|
|
4061
4583
|
walletBody.textContent = 'loading…';
|
|
@@ -4269,7 +4791,10 @@ const APP_HTML = `<!doctype html>
|
|
|
4269
4791
|
setSubNote('Subscription key removed. Wallet/x402 is the pay method again.');
|
|
4270
4792
|
await openWallet();
|
|
4271
4793
|
}
|
|
4272
|
-
document.getElementById('walletBtn').addEventListener('click',
|
|
4794
|
+
document.getElementById('walletBtn').addEventListener('click', () => {
|
|
4795
|
+
echoSlash('/pay');
|
|
4796
|
+
openWallet();
|
|
4797
|
+
});
|
|
4273
4798
|
const subKeyBtn = document.getElementById('subKeyBtn');
|
|
4274
4799
|
if (subKeyBtn) subKeyBtn.addEventListener('click', savePastedSub);
|
|
4275
4800
|
const subForgetBtn = document.getElementById('subForgetBtn');
|
|
@@ -4338,10 +4863,10 @@ const APP_HTML = `<!doctype html>
|
|
|
4338
4863
|
const spent = Number(you.spentUsd) || 0;
|
|
4339
4864
|
const cogs = Number(you.cogsUsd) || 0;
|
|
4340
4865
|
const direct = Number(you.directUsd) || 0;
|
|
4341
|
-
const
|
|
4342
|
-
const
|
|
4343
|
-
|
|
4344
|
-
const savedCls = mult == null ? '' : (mult >= 1 ? 'hlime' : 'hember');
|
|
4866
|
+
const proxyDown = you.proxyReachable === false;
|
|
4867
|
+
const sav = formatSavingLabel(you);
|
|
4868
|
+
const saved = proxyDown ? 'proxy unreachable' : sav.text;
|
|
4869
|
+
const savedCls = proxyDown ? 'hember' : (sav.mult == null ? '' : (sav.mult >= 1 ? 'hlime' : 'hember'));
|
|
4345
4870
|
const thinking = (full && full.status === 'thinking') || t.status === 'thinking';
|
|
4346
4871
|
const race = (full && full.liveRace) || null;
|
|
4347
4872
|
let flight = 'idle';
|
|
@@ -4358,11 +4883,12 @@ const APP_HTML = `<!doctype html>
|
|
|
4358
4883
|
+ sitrepRow('cwd', cwd)
|
|
4359
4884
|
+ sitrepRow('in flight', flight)
|
|
4360
4885
|
+ '<div class="wlanetitle" style="margin-top:16px">this session</div>'
|
|
4361
|
-
+ sitrepRow('
|
|
4362
|
-
+ sitrepRow('
|
|
4363
|
-
+ sitrepRow('
|
|
4886
|
+
+ sitrepRow('proxy', proxyDown ? 'unreachable' : 'ok', proxyDown ? 'hember' : 'hlime')
|
|
4887
|
+
+ sitrepRow('paid', proxyDown ? 'proxy unreachable' : ('$' + (spent >= 0.01 || spent === 0 ? spent.toFixed(2) : spent.toFixed(5))))
|
|
4888
|
+
+ sitrepRow('cogs', proxyDown ? '—' : ('$' + (cogs >= 0.01 || cogs === 0 ? cogs.toFixed(2) : cogs.toFixed(5))))
|
|
4889
|
+
+ sitrepRow('direct', proxyDown ? '—' : ('$' + (direct >= 0.01 || direct === 0 ? direct.toFixed(2) : direct.toFixed(5))))
|
|
4364
4890
|
+ sitrepRow('saved vs naked', saved, savedCls)
|
|
4365
|
-
+ sitrepRow('paid calls', String(you.paidCalls || 0))
|
|
4891
|
+
+ sitrepRow('paid calls', proxyDown ? '—' : String(you.paidCalls || 0))
|
|
4366
4892
|
+ sitrepRow('prepaid', (Number(you.creditUsd) > 0) ? 'yes' : 'no');
|
|
4367
4893
|
}
|
|
4368
4894
|
function closeSitrep() { sitrepOverlay.classList.remove('show'); }
|
|
@@ -4390,22 +4916,44 @@ const APP_HTML = `<!doctype html>
|
|
|
4390
4916
|
setModeButtons(mode); // optimistic: the click should feel instant
|
|
4391
4917
|
// Reuses the SAME "/mode" path the chat command takes, so there is one
|
|
4392
4918
|
// implementation of the rule rather than a second one that can disagree.
|
|
4393
|
-
await
|
|
4394
|
-
body: JSON.stringify({ threadId: activeId, task: '/mode ' + mode }) });
|
|
4395
|
-
await loadThreads(); // refresh runMode + the confirmation line /mode appends
|
|
4396
|
-
await render();
|
|
4919
|
+
await echoSlash('/mode ' + mode);
|
|
4397
4920
|
}
|
|
4398
4921
|
document.getElementById('modeAsk').addEventListener('click', () => setMode('ask'));
|
|
4399
4922
|
document.getElementById('modeAuto').addEventListener('click', () => setMode('auto'));
|
|
4400
4923
|
|
|
4401
4924
|
function escapeHtml(s) { return s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
|
4402
|
-
function
|
|
4925
|
+
function scrubBotText(s) {
|
|
4926
|
+
s = String(s == null ? '' : s);
|
|
4927
|
+
s = s.replace(/<system-reminder\\b[^>]*>[\\s\\S]*?<\\/system-reminder>/gi, '');
|
|
4928
|
+
s = s.replace(/<system-reminder\\b[^>]*>[\\s\\S]*$/i, '');
|
|
4929
|
+
s = s.replace(/^[ \\t]*(?:#\\s*)?currentDir\\b[^\\n]*\\n?/gim, '');
|
|
4930
|
+
if (/\\uFFFD/.test(s) || /^\\s*API Error:\\s*(\\d{3})\\b/im.test(s)) {
|
|
4931
|
+
var m = /API Error:\\s*(\\d{3})/i.exec(s);
|
|
4932
|
+
return 'upstream HTTP ' + (m ? m[1] : '400');
|
|
4933
|
+
}
|
|
4934
|
+
return s;
|
|
4935
|
+
}
|
|
4936
|
+
function splitThinkTags(s) {
|
|
4403
4937
|
s = String(s == null ? '' : s);
|
|
4404
|
-
|
|
4405
|
-
s = s.replace(/<think(?:ing)?\\b[^>]*>[\\s\\S]
|
|
4938
|
+
var bits = [];
|
|
4939
|
+
s = s.replace(/<think(?:ing)?\\b[^>]*>[\\s\\S]*?<\\/think(?:ing)?>/gi, function (m) {
|
|
4940
|
+
var a = m.indexOf('>');
|
|
4941
|
+
var b = m.toLowerCase().lastIndexOf('</think');
|
|
4942
|
+
if (a >= 0 && b > a) bits.push(m.slice(a + 1, b));
|
|
4943
|
+
return '';
|
|
4944
|
+
});
|
|
4945
|
+
s = s.replace(/<think(?:ing)?\\b[^>]*>[\\s\\S]*$/i, function (m) {
|
|
4946
|
+
var a = m.indexOf('>');
|
|
4947
|
+
bits.push(a >= 0 ? m.slice(a + 1) : '');
|
|
4948
|
+
return '';
|
|
4949
|
+
});
|
|
4406
4950
|
s = s.replace(/<\\/think(?:ing)?>/gi, '');
|
|
4407
|
-
return
|
|
4951
|
+
return {
|
|
4952
|
+
visible: s.replace(/^\\n+|\\n+$/g, '').trim(),
|
|
4953
|
+
thinking: bits.join('\\n').replace(/^\\n+|\\n+$/g, '').trim()
|
|
4954
|
+
};
|
|
4408
4955
|
}
|
|
4956
|
+
function stripThinkTags(s) { return splitThinkTags(s).visible; }
|
|
4409
4957
|
function clientWorkspaceUrl(rel) {
|
|
4410
4958
|
if (!workspacePort || !activeId) return '';
|
|
4411
4959
|
rel = String(rel || '').replace(/^\\/+/, '');
|
|
@@ -4788,8 +5336,140 @@ const APP_HTML = `<!doctype html>
|
|
|
4788
5336
|
}
|
|
4789
5337
|
|
|
4790
5338
|
let lastSpeaker = null;
|
|
4791
|
-
|
|
4792
|
-
|
|
5339
|
+
let liveThinkOpen = false;
|
|
5340
|
+
let streamTools = [];
|
|
5341
|
+
let pendingTurns = [];
|
|
5342
|
+
let sending = false;
|
|
5343
|
+
// Mirror of server isHarnessUserText. No regex — APP_HTML template
|
|
5344
|
+
// literals eat \\b / \\/ and kill the whole script.
|
|
5345
|
+
function isHarnessUserText(text) {
|
|
5346
|
+
const s = String(text || '');
|
|
5347
|
+
if (s.indexOf('(command output)') === 0 || s.indexOf('(directive result)') === 0) return true;
|
|
5348
|
+
if (s === 'Please continue the current job. Do not stop to ask the user.') return true;
|
|
5349
|
+
if (s.indexOf('AUTO is still on ') === 0) return true;
|
|
5350
|
+
if (s.indexOf('AUTO_EMPTY_RETRY:') === 0) return true;
|
|
5351
|
+
if (s.indexOf('That reply announced work instead of doing it') === 0) return true;
|
|
5352
|
+
return false;
|
|
5353
|
+
}
|
|
5354
|
+
function pendingStoreKey(id) { return 'openzoo.userTurn.' + id; }
|
|
5355
|
+
function rememberUserTurn(id, text, images) {
|
|
5356
|
+
if (!id || !text || isHarnessUserText(text)) return;
|
|
5357
|
+
try { localStorage.setItem(pendingStoreKey(id), JSON.stringify({ text: text, images: images || [] })); } catch (e) {}
|
|
5358
|
+
}
|
|
5359
|
+
function recalledUserTurn(id) {
|
|
5360
|
+
try {
|
|
5361
|
+
const raw = localStorage.getItem(pendingStoreKey(id));
|
|
5362
|
+
if (!raw) return null;
|
|
5363
|
+
const j = JSON.parse(raw);
|
|
5364
|
+
if (!j || !j.text || isHarnessUserText(j.text)) return null;
|
|
5365
|
+
return j;
|
|
5366
|
+
} catch (e) { return null; }
|
|
5367
|
+
}
|
|
5368
|
+
function forgetUserTurn(id, text) {
|
|
5369
|
+
try {
|
|
5370
|
+
const j = recalledUserTurn(id);
|
|
5371
|
+
if (!id) return;
|
|
5372
|
+
if (!text || (j && j.text === text)) localStorage.removeItem(pendingStoreKey(id));
|
|
5373
|
+
} catch (e) {}
|
|
5374
|
+
}
|
|
5375
|
+
function thinkLabel(live) { return live ? 'thinking...' : 'thought'; }
|
|
5376
|
+
function foldBodyText(think) {
|
|
5377
|
+
const tools = streamTools.length ? streamTools.join('\\n') : '';
|
|
5378
|
+
const t = String(think || '').trim();
|
|
5379
|
+
if (t && tools) return t + '\\n' + tools;
|
|
5380
|
+
return t || tools;
|
|
5381
|
+
}
|
|
5382
|
+
function makeThinkFold(text, live, open) {
|
|
5383
|
+
const fold = document.createElement('div');
|
|
5384
|
+
fold.className = 'thinkfold' + (open ? ' open' : '');
|
|
5385
|
+
const chip = document.createElement('button');
|
|
5386
|
+
chip.type = 'button';
|
|
5387
|
+
chip.className = 'thinkchip';
|
|
5388
|
+
chip.textContent = thinkLabel(live);
|
|
5389
|
+
chip.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
5390
|
+
const body = document.createElement('div');
|
|
5391
|
+
body.className = 'thinkbody';
|
|
5392
|
+
if (open) body.textContent = text;
|
|
5393
|
+
chip.addEventListener('click', function (e) {
|
|
5394
|
+
e.preventDefault();
|
|
5395
|
+
const next = !fold.classList.contains('open');
|
|
5396
|
+
fold.classList.toggle('open', next);
|
|
5397
|
+
chip.setAttribute('aria-expanded', next ? 'true' : 'false');
|
|
5398
|
+
if (live) liveThinkOpen = next;
|
|
5399
|
+
if (next) body.textContent = fold.getAttribute('data-think') || text || '';
|
|
5400
|
+
else body.textContent = '';
|
|
5401
|
+
if (live) paintStream();
|
|
5402
|
+
});
|
|
5403
|
+
fold.setAttribute('data-think', text || '');
|
|
5404
|
+
fold.appendChild(chip);
|
|
5405
|
+
fold.appendChild(body);
|
|
5406
|
+
return fold;
|
|
5407
|
+
}
|
|
5408
|
+
function runFoldLabel(status) {
|
|
5409
|
+
if (status === 'pending') return 'run';
|
|
5410
|
+
if (status === 'running') return 'running...';
|
|
5411
|
+
if (status === 'denied') return 'denied';
|
|
5412
|
+
return 'ran';
|
|
5413
|
+
}
|
|
5414
|
+
function parseLegacyRun(text) {
|
|
5415
|
+
const raw = String(text || '');
|
|
5416
|
+
if (!raw.startsWith('$ ')) return null;
|
|
5417
|
+
const nl = raw.indexOf('\\n');
|
|
5418
|
+
if (nl < 0) return { command: raw.slice(2), output: '', status: 'done' };
|
|
5419
|
+
return { command: raw.slice(2, nl), output: raw.slice(nl + 1), status: 'done' };
|
|
5420
|
+
}
|
|
5421
|
+
function makeRunFold(command, output, status) {
|
|
5422
|
+
const pending = status === 'pending' || status === 'running';
|
|
5423
|
+
const fold = document.createElement('div');
|
|
5424
|
+
fold.className = 'runfold' + (pending ? ' open' : '');
|
|
5425
|
+
const chip = document.createElement('button');
|
|
5426
|
+
chip.type = 'button';
|
|
5427
|
+
chip.className = 'runchip';
|
|
5428
|
+
chip.textContent = runFoldLabel(status);
|
|
5429
|
+
chip.setAttribute('aria-expanded', pending ? 'true' : 'false');
|
|
5430
|
+
const body = document.createElement('div');
|
|
5431
|
+
body.className = 'runbody';
|
|
5432
|
+
function fillBody() {
|
|
5433
|
+
body.innerHTML = '';
|
|
5434
|
+
const cmdEl = document.createElement('div');
|
|
5435
|
+
cmdEl.className = 'runcmd';
|
|
5436
|
+
cmdEl.textContent = '$ ' + command;
|
|
5437
|
+
cmdEl.appendChild(copyBtn(() => command, 'copy'));
|
|
5438
|
+
body.appendChild(cmdEl);
|
|
5439
|
+
if (status && status !== 'pending') {
|
|
5440
|
+
const st = document.createElement('div');
|
|
5441
|
+
st.className = 'runstatus';
|
|
5442
|
+
st.textContent = status === 'running' ? 'Running…' : status === 'denied' ? 'Denied' : 'Done';
|
|
5443
|
+
body.appendChild(st);
|
|
5444
|
+
}
|
|
5445
|
+
if (output) {
|
|
5446
|
+
const out = document.createElement('pre');
|
|
5447
|
+
out.className = 'runoutput';
|
|
5448
|
+
out.textContent = output;
|
|
5449
|
+
out.appendChild(copyBtn(() => output, 'copy'));
|
|
5450
|
+
body.appendChild(out);
|
|
5451
|
+
}
|
|
5452
|
+
}
|
|
5453
|
+
if (pending) fillBody();
|
|
5454
|
+
chip.addEventListener('click', function (e) {
|
|
5455
|
+
e.preventDefault();
|
|
5456
|
+
const next = !fold.classList.contains('open');
|
|
5457
|
+
fold.classList.toggle('open', next);
|
|
5458
|
+
chip.setAttribute('aria-expanded', next ? 'true' : 'false');
|
|
5459
|
+
if (next) fillBody();
|
|
5460
|
+
else body.innerHTML = '';
|
|
5461
|
+
});
|
|
5462
|
+
fold.appendChild(chip);
|
|
5463
|
+
fold.appendChild(body);
|
|
5464
|
+
return fold;
|
|
5465
|
+
}
|
|
5466
|
+
function addRow(who, text, color, name, run, images, thinking, live) {
|
|
5467
|
+
if (who === 'bot') {
|
|
5468
|
+
text = scrubBotText(text);
|
|
5469
|
+
const parts = splitThinkTags(text);
|
|
5470
|
+
text = parts.visible;
|
|
5471
|
+
if (!thinking) thinking = parts.thinking;
|
|
5472
|
+
}
|
|
4793
5473
|
const speakerKey = who + '|' + name;
|
|
4794
5474
|
if (who === 'bot' && speakerKey !== lastSpeaker) {
|
|
4795
5475
|
const hdr = document.createElement('div');
|
|
@@ -4800,17 +5480,25 @@ const APP_HTML = `<!doctype html>
|
|
|
4800
5480
|
lastSpeaker = speakerKey;
|
|
4801
5481
|
const row = document.createElement('div');
|
|
4802
5482
|
row.className = 'row ' + who;
|
|
5483
|
+
const col = document.createElement('div');
|
|
5484
|
+
col.className = 'msgcol';
|
|
5485
|
+
const thinkText = (who === 'bot' && thinking) ? String(thinking).trim() : '';
|
|
5486
|
+
if (live) {
|
|
5487
|
+
const fold = makeThinkFold(foldBodyText(thinkText), true, !!liveThinkOpen);
|
|
5488
|
+
fold.id = 'streamThink';
|
|
5489
|
+
fold.hidden = false;
|
|
5490
|
+
col.appendChild(fold);
|
|
5491
|
+
} else if (thinkText) {
|
|
5492
|
+
col.appendChild(makeThinkFold(thinkText, false, false));
|
|
5493
|
+
}
|
|
4803
5494
|
if (run) {
|
|
5495
|
+
const cmd = run.command || text;
|
|
5496
|
+
const st = run.status || 'pending';
|
|
5497
|
+
const pending = st === 'pending' || st === 'running';
|
|
4804
5498
|
const card = document.createElement('div');
|
|
4805
|
-
card.className = 'runcard';
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
cmdEl.textContent = '$ ' + text;
|
|
4809
|
-
// Copy WITHOUT the '$ ' prompt — pasting that into a shell is a syntax
|
|
4810
|
-
// error, and this is the single most re-run thing in the UI.
|
|
4811
|
-
cmdEl.appendChild(copyBtn(() => text, 'copy'));
|
|
4812
|
-
card.appendChild(cmdEl);
|
|
4813
|
-
if (run.status === 'pending') {
|
|
5499
|
+
card.className = 'runcard' + (pending ? ' pending' : ' folded');
|
|
5500
|
+
card.appendChild(makeRunFold(cmd, run.output || '', st));
|
|
5501
|
+
if (run.id && st === 'pending') {
|
|
4814
5502
|
const actions = document.createElement('div');
|
|
4815
5503
|
actions.className = 'runactions';
|
|
4816
5504
|
const approve = document.createElement('button');
|
|
@@ -4832,20 +5520,8 @@ const APP_HTML = `<!doctype html>
|
|
|
4832
5520
|
actions.appendChild(approve);
|
|
4833
5521
|
actions.appendChild(deny);
|
|
4834
5522
|
card.appendChild(actions);
|
|
4835
|
-
} else {
|
|
4836
|
-
const status = document.createElement('div');
|
|
4837
|
-
status.className = 'runstatus';
|
|
4838
|
-
status.textContent = run.status === 'running' ? 'Running…' : run.status === 'denied' ? 'Denied' : 'Done';
|
|
4839
|
-
card.appendChild(status);
|
|
4840
|
-
if (run.output) {
|
|
4841
|
-
const out = document.createElement('pre');
|
|
4842
|
-
out.className = 'runoutput';
|
|
4843
|
-
out.textContent = run.output;
|
|
4844
|
-
out.appendChild(copyBtn(() => run.output, 'copy'));
|
|
4845
|
-
card.appendChild(out);
|
|
4846
|
-
}
|
|
4847
5523
|
}
|
|
4848
|
-
|
|
5524
|
+
col.appendChild(card);
|
|
4849
5525
|
} else {
|
|
4850
5526
|
const bubble = document.createElement('div');
|
|
4851
5527
|
bubble.className = 'bubble';
|
|
@@ -4861,12 +5537,22 @@ const APP_HTML = `<!doctype html>
|
|
|
4861
5537
|
}
|
|
4862
5538
|
const textEl = document.createElement('div');
|
|
4863
5539
|
textEl.innerHTML = renderMentions(text);
|
|
5540
|
+
if (live && (!text || text === '…')) bubble.hidden = true;
|
|
4864
5541
|
if (who === 'bot') {
|
|
4865
5542
|
const preview = htmlPreviewUrl(text);
|
|
4866
5543
|
if (preview) textEl.appendChild(previewFrame(preview, htmlPreviewKey(text, preview)));
|
|
4867
5544
|
}
|
|
5545
|
+
if (who === 'bot' && isEmptyWalletPayment(text)) {
|
|
5546
|
+
const pay = document.createElement('button');
|
|
5547
|
+
pay.type = 'button';
|
|
5548
|
+
pay.className = 'payneed-btn';
|
|
5549
|
+
pay.textContent = 'payment required';
|
|
5550
|
+
pay.addEventListener('click', function (e) { e.preventDefault(); openWallet(); });
|
|
5551
|
+
textEl.appendChild(pay);
|
|
5552
|
+
maybeOpenPayForEmptyWallet(text);
|
|
5553
|
+
}
|
|
4868
5554
|
bubble.appendChild(textEl);
|
|
4869
|
-
|
|
5555
|
+
col.appendChild(bubble);
|
|
4870
5556
|
// Copy the message SOURCE, not rendered HTML — markdown, code fences and
|
|
4871
5557
|
// directive lines are what people want back; innerText drops fences and
|
|
4872
5558
|
// mangles indentation.
|
|
@@ -4882,6 +5568,7 @@ const APP_HTML = `<!doctype html>
|
|
|
4882
5568
|
}, 'copy'));
|
|
4883
5569
|
}
|
|
4884
5570
|
}
|
|
5571
|
+
row.appendChild(col);
|
|
4885
5572
|
log.appendChild(row);
|
|
4886
5573
|
}
|
|
4887
5574
|
|
|
@@ -4894,10 +5581,11 @@ const APP_HTML = `<!doctype html>
|
|
|
4894
5581
|
const full = await loadActiveMessages();
|
|
4895
5582
|
if (!full || full.id !== activeId) return;
|
|
4896
5583
|
const renderKey = String(workspacePort) + '|' + full.id + '|' + full.status + '|' + (full.history || []).map(function (h) {
|
|
4897
|
-
return [h.who, h.text, h.runStatus, h.runOutput, (h.images || []).join(',')].join('|#');
|
|
4898
|
-
}).join('||');
|
|
5584
|
+
return [h.who, h.text, h.thinking, h.runStatus, h.runOutput, (h.images || []).join(',')].join('|#');
|
|
5585
|
+
}).join('||') + '|pending:' + pendingTurns.filter(function (p) { return p.threadId === activeId; }).map(function (p) { return p.text; }).join('\\n')
|
|
5586
|
+
+ '|recall:' + ((recalledUserTurn(activeId) || {}).text || '');
|
|
4899
5587
|
if (renderKey === lastRenderKey) {
|
|
4900
|
-
if (streamBuf) paintStream();
|
|
5588
|
+
if (streamBuf || streamThink) paintStream();
|
|
4901
5589
|
return;
|
|
4902
5590
|
}
|
|
4903
5591
|
lastRenderKey = renderKey;
|
|
@@ -4907,9 +5595,33 @@ const APP_HTML = `<!doctype html>
|
|
|
4907
5595
|
parkedPreviews = parkPreviews();
|
|
4908
5596
|
log.innerHTML = '';
|
|
4909
5597
|
lastSpeaker = null;
|
|
5598
|
+
const seenUser = {};
|
|
4910
5599
|
for (const h of full.history) {
|
|
4911
|
-
|
|
4912
|
-
|
|
5600
|
+
if (h.who === 'user' && isHarnessUserText(h.text)) continue;
|
|
5601
|
+
if (h.who === 'user') seenUser[h.text] = true;
|
|
5602
|
+
let run;
|
|
5603
|
+
if (h.runId || h.runStatus) {
|
|
5604
|
+
run = { id: h.runId, status: h.runStatus, output: h.runOutput, command: h.text };
|
|
5605
|
+
} else if (h.who === 'bot') {
|
|
5606
|
+
const legacy = parseLegacyRun(h.text);
|
|
5607
|
+
if (legacy) run = { status: legacy.status, output: legacy.output, command: legacy.command };
|
|
5608
|
+
}
|
|
5609
|
+
addRow(h.who, h.text, h.color || t.color, h.name || t.name, run, h.images, h.thinking);
|
|
5610
|
+
}
|
|
5611
|
+
const keepPending = [];
|
|
5612
|
+
for (let i = 0; i < pendingTurns.length; i++) {
|
|
5613
|
+
const p = pendingTurns[i];
|
|
5614
|
+
if (p.threadId !== activeId) { keepPending.push(p); continue; }
|
|
5615
|
+
if (seenUser[p.text]) { forgetUserTurn(p.threadId, p.text); continue; }
|
|
5616
|
+
addRow('user', p.text, t.color, t.name, undefined, p.images);
|
|
5617
|
+
seenUser[p.text] = true;
|
|
5618
|
+
keepPending.push(p);
|
|
5619
|
+
}
|
|
5620
|
+
pendingTurns = keepPending;
|
|
5621
|
+
const recalled = recalledUserTurn(activeId);
|
|
5622
|
+
if (recalled && recalled.text) {
|
|
5623
|
+
if (seenUser[recalled.text]) forgetUserTurn(activeId, recalled.text);
|
|
5624
|
+
else addRow('user', recalled.text, t.color, t.name, undefined, recalled.images);
|
|
4913
5625
|
}
|
|
4914
5626
|
if (full.status === 'thinking') {
|
|
4915
5627
|
if (full.liveStatus) streamStatus = full.liveStatus;
|
|
@@ -4919,19 +5631,21 @@ const APP_HTML = `<!doctype html>
|
|
|
4919
5631
|
} else if (streamRaceId !== full.id) {
|
|
4920
5632
|
streamRace = null;
|
|
4921
5633
|
}
|
|
4922
|
-
addRow('bot', streamBuf || '…', t.color, t.name);
|
|
5634
|
+
addRow('bot', streamBuf || '…', t.color, t.name, undefined, undefined, streamThink, true);
|
|
4923
5635
|
// Tag the live bubble so deltas can repaint just this node instead of
|
|
4924
5636
|
// re-rendering (and re-fetching) the whole thread on every token.
|
|
4925
5637
|
const b = log.querySelector('.row:last-child .bubble');
|
|
4926
5638
|
if (b) { b.id = 'streamBubble'; paintStream(); }
|
|
4927
5639
|
}
|
|
4928
5640
|
if (wasNearBottom) log.scrollTop = log.scrollHeight;
|
|
5641
|
+
if (findBarOpen()) applyFind(true);
|
|
4929
5642
|
}
|
|
4930
5643
|
|
|
4931
5644
|
// --- live token stream ---------------------------------------------------
|
|
4932
5645
|
// The server has always been able to stream; /drive just never asked for it,
|
|
4933
5646
|
// so a turn showed "…" for its whole duration and then arrived in one lump.
|
|
4934
5647
|
let streamBuf = '';
|
|
5648
|
+
let streamThink = '';
|
|
4935
5649
|
let streamStatus = '';
|
|
4936
5650
|
let streamRace = null;
|
|
4937
5651
|
let streamRaceId = '';
|
|
@@ -4985,44 +5699,117 @@ const APP_HTML = `<!doctype html>
|
|
|
4985
5699
|
return '<div class="racewrap"><div class="racecaption">' + escapeHtml(caption) + '</div>'
|
|
4986
5700
|
+ '<div class="racegrid n' + n + '">' + cells + '</div>' + judge + '</div>';
|
|
4987
5701
|
}
|
|
5702
|
+
function streamParts() {
|
|
5703
|
+
const parts = splitThinkTags(streamBuf);
|
|
5704
|
+
var think = streamThink;
|
|
5705
|
+
if (parts.thinking) think = think ? (think + '\\n' + parts.thinking) : parts.thinking;
|
|
5706
|
+
return { visible: parts.visible, think: think };
|
|
5707
|
+
}
|
|
4988
5708
|
function liveBubbleHtml() {
|
|
4989
5709
|
if (raceIsLive(streamRace)) return raceGridHtml(streamRace);
|
|
4990
|
-
|
|
4991
|
-
|
|
4992
|
-
|
|
4993
|
-
|
|
5710
|
+
const vis = streamParts().visible;
|
|
5711
|
+
return vis ? escapeHtml(vis) : '';
|
|
5712
|
+
}
|
|
5713
|
+
function paintThinkFoldLive(think) {
|
|
5714
|
+
const fold = document.getElementById('streamThink');
|
|
5715
|
+
if (!fold) return;
|
|
5716
|
+
const chip = fold.querySelector('.thinkchip');
|
|
5717
|
+
const body = fold.querySelector('.thinkbody');
|
|
5718
|
+
const text = foldBodyText(think);
|
|
5719
|
+
fold.hidden = false;
|
|
5720
|
+
fold.setAttribute('data-think', text);
|
|
5721
|
+
fold.classList.toggle('open', !!liveThinkOpen);
|
|
5722
|
+
if (chip) {
|
|
5723
|
+
chip.textContent = 'thinking...';
|
|
5724
|
+
chip.setAttribute('aria-expanded', liveThinkOpen ? 'true' : 'false');
|
|
4994
5725
|
}
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
5726
|
+
if (body) body.textContent = liveThinkOpen ? text : '';
|
|
5727
|
+
}
|
|
5728
|
+
// Follow the live assistant bubble only while the reader is still on
|
|
5729
|
+
// it. A post-paint pad check is what broke autoscroll: a lump >140px
|
|
5730
|
+
// (long reply, 400 dump, table) put them past the pad, then every
|
|
5731
|
+
// later token also refused to follow. Snapshot first; sticky-follow
|
|
5732
|
+
// the turn they are watching; drop follow if they scroll up.
|
|
5733
|
+
let followLive = false;
|
|
5734
|
+
let pinningLog = false;
|
|
5735
|
+
let pinRaf = 0;
|
|
5736
|
+
function pinLogBottom() {
|
|
5737
|
+
pinningLog = true;
|
|
5738
|
+
log.scrollTop = log.scrollHeight;
|
|
5739
|
+
// Tables / markdown can grow the bubble after this paint's layout.
|
|
5740
|
+
// A second pin on the next frame follows that growth. Do not
|
|
5741
|
+
// re-check the pad here — the snapshot already decided to follow.
|
|
5742
|
+
if (pinRaf) cancelAnimationFrame(pinRaf);
|
|
5743
|
+
pinRaf = requestAnimationFrame(function () {
|
|
5744
|
+
pinRaf = 0;
|
|
5745
|
+
log.scrollTop = log.scrollHeight;
|
|
5746
|
+
pinningLog = false;
|
|
5747
|
+
});
|
|
5748
|
+
}
|
|
5749
|
+
log.addEventListener('scroll', function () {
|
|
5750
|
+
if (pinningLog) return;
|
|
5751
|
+
followLive = log.scrollHeight - log.scrollTop - log.clientHeight < 140;
|
|
5752
|
+
});
|
|
5753
|
+
function ensureLiveBotRow() {
|
|
5754
|
+
if (document.getElementById('streamBubble')) return;
|
|
5755
|
+
const t = knownThreads.find((x) => x.id === activeId);
|
|
5756
|
+
if (!t) return;
|
|
5757
|
+
addRow('bot', streamBuf || '…', t.color, t.name, undefined, undefined, streamThink, true);
|
|
5758
|
+
const row = log.querySelector('.row:last-child');
|
|
5759
|
+
if (!row) return;
|
|
5760
|
+
const b = row.querySelector('.bubble');
|
|
5761
|
+
if (b) b.id = 'streamBubble';
|
|
5762
|
+
const fold = row.querySelector('.thinkfold');
|
|
5763
|
+
if (fold) fold.id = 'streamThink';
|
|
4998
5764
|
}
|
|
4999
5765
|
function paintStream() {
|
|
5000
|
-
|
|
5001
|
-
if (!b) {
|
|
5766
|
+
let b = document.getElementById('streamBubble');
|
|
5767
|
+
if (!b) {
|
|
5768
|
+
// Claude TUI start/replace used to call render(), which wiped #log
|
|
5769
|
+
// and rebuilt from a fetch that could miss the just-sent user turn.
|
|
5770
|
+
// Keep existing user rows; only attach the live assistant bubble.
|
|
5771
|
+
ensureLiveBotRow();
|
|
5772
|
+
b = document.getElementById('streamBubble');
|
|
5773
|
+
if (!b) return;
|
|
5774
|
+
}
|
|
5775
|
+
// Snapshot BEFORE the bubble grows. Same idea as render()'s wasNearBottom.
|
|
5776
|
+
const wasNearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 140;
|
|
5777
|
+
if (wasNearBottom) followLive = true;
|
|
5778
|
+
const parts = streamParts();
|
|
5779
|
+
paintThinkFoldLive(parts.think);
|
|
5002
5780
|
if (raceIsLive(streamRace)) {
|
|
5781
|
+
b.hidden = false;
|
|
5003
5782
|
b.classList.add('raceboard');
|
|
5004
5783
|
b.innerHTML = liveBubbleHtml();
|
|
5005
|
-
if (
|
|
5784
|
+
if (wasNearBottom || followLive) pinLogBottom();
|
|
5785
|
+
scheduleFindPaint();
|
|
5006
5786
|
return;
|
|
5007
5787
|
}
|
|
5008
5788
|
b.classList.remove('raceboard');
|
|
5009
|
-
//
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
b.textContent =
|
|
5789
|
+
// Model text only. Tool status / CoT live in the thinking… fold.
|
|
5790
|
+
if (parts.visible) {
|
|
5791
|
+
b.hidden = false;
|
|
5792
|
+
b.textContent = scrubBotText(parts.visible);
|
|
5013
5793
|
} else {
|
|
5794
|
+
b.hidden = true;
|
|
5014
5795
|
b.innerHTML = liveBubbleHtml();
|
|
5015
5796
|
}
|
|
5016
|
-
if (
|
|
5797
|
+
if (wasNearBottom || followLive) pinLogBottom();
|
|
5798
|
+
scheduleFindPaint();
|
|
5017
5799
|
}
|
|
5018
5800
|
function connectStream(id) {
|
|
5019
5801
|
if (!id || esId === id) return;
|
|
5020
5802
|
if (es) es.close();
|
|
5021
5803
|
esId = id;
|
|
5022
5804
|
streamBuf = '';
|
|
5805
|
+
streamThink = '';
|
|
5806
|
+
streamTools = [];
|
|
5023
5807
|
streamStatus = '';
|
|
5024
5808
|
streamRace = null;
|
|
5025
5809
|
streamRaceId = id;
|
|
5810
|
+
liveThinkOpen = false;
|
|
5811
|
+
// New thread: do not inherit follow from the previous log position.
|
|
5812
|
+
followLive = log.scrollHeight - log.scrollTop - log.clientHeight < 140;
|
|
5026
5813
|
raceHandoff += 1;
|
|
5027
5814
|
es = new EventSource('/stream/' + id); // EventSource reconnects on its own
|
|
5028
5815
|
es.onmessage = (e) => {
|
|
@@ -5030,9 +5817,17 @@ const APP_HTML = `<!doctype html>
|
|
|
5030
5817
|
try { ev = JSON.parse(e.data); } catch { return; }
|
|
5031
5818
|
if (ev.type === 'start') {
|
|
5032
5819
|
streamBuf = '';
|
|
5820
|
+
streamThink = '';
|
|
5821
|
+
streamTools = [];
|
|
5033
5822
|
streamStatus = ev.detail || 'waiting on model…';
|
|
5034
5823
|
streamRace = null;
|
|
5035
5824
|
streamRaceId = id;
|
|
5825
|
+
liveThinkOpen = false;
|
|
5826
|
+
paintStream();
|
|
5827
|
+
}
|
|
5828
|
+
else if (ev.type === 'tool') {
|
|
5829
|
+
const line = ev.detail || '';
|
|
5830
|
+
if (line && streamTools[streamTools.length - 1] !== line) streamTools.push(line);
|
|
5036
5831
|
paintStream();
|
|
5037
5832
|
}
|
|
5038
5833
|
else if (ev.type === 'status') { streamStatus = ev.detail || streamStatus; paintStream(); }
|
|
@@ -5043,8 +5838,14 @@ const APP_HTML = `<!doctype html>
|
|
|
5043
5838
|
}
|
|
5044
5839
|
paintStream();
|
|
5045
5840
|
}
|
|
5841
|
+
else if (ev.type === 'think') {
|
|
5842
|
+
streamThink = ev.replace ? (ev.delta || '') : streamThink + (ev.delta || '');
|
|
5843
|
+
paintStream();
|
|
5844
|
+
}
|
|
5046
5845
|
else if (ev.type === 'delta') {
|
|
5047
5846
|
streamBuf = ev.replace ? (ev.delta || '') : streamBuf + (ev.delta || '');
|
|
5847
|
+
// TUI frames replace the bubble without eating the folded thinking row.
|
|
5848
|
+
if (ev.replace && /<think/i.test(ev.delta || '')) streamThink = '';
|
|
5048
5849
|
paintStream();
|
|
5049
5850
|
}
|
|
5050
5851
|
else if (ev.type === 'final' || ev.type === 'run-pending') {
|
|
@@ -5054,6 +5855,8 @@ const APP_HTML = `<!doctype html>
|
|
|
5054
5855
|
setTimeout(function () {
|
|
5055
5856
|
if (token !== raceHandoff) return;
|
|
5056
5857
|
streamBuf = '';
|
|
5858
|
+
streamThink = '';
|
|
5859
|
+
streamTools = [];
|
|
5057
5860
|
streamStatus = '';
|
|
5058
5861
|
streamRace = null;
|
|
5059
5862
|
render();
|
|
@@ -5061,6 +5864,8 @@ const APP_HTML = `<!doctype html>
|
|
|
5061
5864
|
return;
|
|
5062
5865
|
}
|
|
5063
5866
|
streamBuf = '';
|
|
5867
|
+
streamThink = '';
|
|
5868
|
+
streamTools = [];
|
|
5064
5869
|
streamStatus = '';
|
|
5065
5870
|
streamRace = null;
|
|
5066
5871
|
render();
|
|
@@ -5121,15 +5926,22 @@ const APP_HTML = `<!doctype html>
|
|
|
5121
5926
|
|
|
5122
5927
|
async function submit() {
|
|
5123
5928
|
const task = inp.value.trim();
|
|
5124
|
-
|
|
5929
|
+
// String compare — never a regex. A sitrep word-boundary regex inside
|
|
5930
|
+
// this template literal is eaten by the backtick parser and the whole
|
|
5931
|
+
// client script dies (empty sidebar, send is a no-op).
|
|
5932
|
+
const s = String(task).trim().toLowerCase();
|
|
5933
|
+
if (s === '/sitrep' || s.startsWith('/sitrep ')) {
|
|
5125
5934
|
inp.value = '';
|
|
5126
5935
|
send.classList.remove('show');
|
|
5127
5936
|
openSitrep();
|
|
5128
5937
|
return;
|
|
5129
5938
|
}
|
|
5130
5939
|
if ((!task && !pendingFiles.length && !pendingImages.length) || !activeId) return;
|
|
5131
|
-
|
|
5132
|
-
|
|
5940
|
+
if (sending) return;
|
|
5941
|
+
sending = true;
|
|
5942
|
+
const draft = inp.value;
|
|
5943
|
+
const hadFiles = pendingFiles.slice();
|
|
5944
|
+
const hadImages = pendingImages.slice();
|
|
5133
5945
|
let full = task;
|
|
5134
5946
|
// an image with no caption still needs SOME text — an empty text block
|
|
5135
5947
|
// alongside image_url content gets rejected (400) by at least one path
|
|
@@ -5140,13 +5952,39 @@ const APP_HTML = `<!doctype html>
|
|
|
5140
5952
|
: '\\n\\n(attached binary file: ' + f.name + ', ' + f.size + ' bytes — content not readable as text)';
|
|
5141
5953
|
}
|
|
5142
5954
|
const images = pendingImages.map((i) => i.dataUrl);
|
|
5143
|
-
|
|
5144
|
-
|
|
5145
|
-
|
|
5146
|
-
|
|
5147
|
-
|
|
5148
|
-
|
|
5149
|
-
|
|
5955
|
+
const t = knownThreads.find((x) => x.id === activeId) || { color: '', name: '' };
|
|
5956
|
+
pendingTurns.push({ threadId: activeId, text: full, images: images });
|
|
5957
|
+
rememberUserTurn(activeId, full, images);
|
|
5958
|
+
addRow('user', full, t.color, t.name, undefined, images);
|
|
5959
|
+
try { log.scrollTop = log.scrollHeight; } catch (e) {}
|
|
5960
|
+
followLive = true;
|
|
5961
|
+
let persisted = false;
|
|
5962
|
+
try {
|
|
5963
|
+
const r = await fetch(API + '/drive', {
|
|
5964
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
5965
|
+
body: JSON.stringify({ threadId: activeId, task: full, images }),
|
|
5966
|
+
});
|
|
5967
|
+
let body = {};
|
|
5968
|
+
try { body = await r.json(); } catch (e) { body = {}; }
|
|
5969
|
+
persisted = r.ok && body.persisted !== false;
|
|
5970
|
+
} catch (e) { persisted = false; }
|
|
5971
|
+
if (persisted) {
|
|
5972
|
+
inp.value = '';
|
|
5973
|
+
send.classList.remove('show');
|
|
5974
|
+
pendingFiles = [];
|
|
5975
|
+
pendingImages = [];
|
|
5976
|
+
renderAttachChips();
|
|
5977
|
+
} else {
|
|
5978
|
+
pendingTurns = pendingTurns.filter((p) => !(p.threadId === activeId && p.text === full));
|
|
5979
|
+
forgetUserTurn(activeId, full);
|
|
5980
|
+
inp.value = draft;
|
|
5981
|
+
pendingFiles = hadFiles;
|
|
5982
|
+
pendingImages = hadImages;
|
|
5983
|
+
renderAttachChips();
|
|
5984
|
+
send.classList.toggle('show', inp.value.trim().length > 0 || pendingFiles.length > 0 || pendingImages.length > 0);
|
|
5985
|
+
lastRenderKey = '';
|
|
5986
|
+
}
|
|
5987
|
+
sending = false;
|
|
5150
5988
|
render();
|
|
5151
5989
|
}
|
|
5152
5990
|
|
|
@@ -5339,26 +6177,196 @@ const APP_HTML = `<!doctype html>
|
|
|
5339
6177
|
// It cannot change the version baked into the IMAGE — only the site's spawn
|
|
5340
6178
|
// path can — so it says restart, not update. Promising an upgrade it cannot
|
|
5341
6179
|
// deliver is how a UI teaches people to distrust it.
|
|
5342
|
-
// Cmd/Ctrl+K ->
|
|
5343
|
-
//
|
|
5344
|
-
//
|
|
5345
|
-
//
|
|
6180
|
+
// Cmd/Ctrl+K -> sidebar thread search (GET /search). Cmd/Ctrl+F is find
|
|
6181
|
+
// inside the current #log — a different box, on purpose. Bound on document
|
|
6182
|
+
// keydown so it works no matter which pane has focus; preventDefault so
|
|
6183
|
+
// Chromium cannot swallow F for a page-find that was never wired.
|
|
5346
6184
|
document.addEventListener('keydown', (e) => {
|
|
5347
6185
|
const k = (e.key || '').toLowerCase();
|
|
5348
|
-
|
|
6186
|
+
const withMod = e.metaKey || e.ctrlKey;
|
|
6187
|
+
if (withMod && k === 'k') {
|
|
5349
6188
|
e.preventDefault();
|
|
5350
6189
|
const el = document.getElementById('search');
|
|
5351
6190
|
if (el) { el.focus(); el.select(); }
|
|
5352
6191
|
return;
|
|
5353
6192
|
}
|
|
6193
|
+
if (withMod && k === 'f') {
|
|
6194
|
+
e.preventDefault();
|
|
6195
|
+
openFindBar();
|
|
6196
|
+
return;
|
|
6197
|
+
}
|
|
6198
|
+
if (withMod && k === 'g' && findBarOpen()) {
|
|
6199
|
+
e.preventDefault();
|
|
6200
|
+
findStep(e.shiftKey ? -1 : 1);
|
|
6201
|
+
return;
|
|
6202
|
+
}
|
|
6203
|
+
if (k === 'escape' && findBarOpen()) {
|
|
6204
|
+
e.preventDefault();
|
|
6205
|
+
closeFindBar();
|
|
6206
|
+
return;
|
|
6207
|
+
}
|
|
5354
6208
|
// Cmd/Ctrl+Enter sends from anywhere — useful when focus drifted into a
|
|
5355
6209
|
// RUN card or a copy button mid-thought.
|
|
5356
|
-
if (
|
|
6210
|
+
if (withMod && k === 'enter') {
|
|
5357
6211
|
e.preventDefault();
|
|
5358
6212
|
try { submit(); } catch (err) { /* not ready */ }
|
|
5359
6213
|
}
|
|
5360
6214
|
});
|
|
5361
6215
|
|
|
6216
|
+
// FIND IN THREAD. Highlights visible .bubble text only — not sidebar
|
|
6217
|
+
// threads, not think folds, not RUN cards, not tool JSON dumps.
|
|
6218
|
+
let findMarks = [];
|
|
6219
|
+
let findIndex = 0;
|
|
6220
|
+
let findPaintTimer = null;
|
|
6221
|
+
const findBar = document.getElementById('findBar');
|
|
6222
|
+
const findInp = document.getElementById('findInp');
|
|
6223
|
+
const findCount = document.getElementById('findCount');
|
|
6224
|
+
function scheduleFindPaint() {
|
|
6225
|
+
if (!findBarOpen()) return;
|
|
6226
|
+
clearTimeout(findPaintTimer);
|
|
6227
|
+
findPaintTimer = setTimeout(function () { applyFind(true); }, 160);
|
|
6228
|
+
}
|
|
6229
|
+
function findBarOpen() {
|
|
6230
|
+
const el = document.getElementById('findBar');
|
|
6231
|
+
return !!(el && el.classList.contains('show'));
|
|
6232
|
+
}
|
|
6233
|
+
function placeFindBar() {
|
|
6234
|
+
const bar = document.getElementById('findBar');
|
|
6235
|
+
const main = document.getElementById('main');
|
|
6236
|
+
if (!bar || !chatHeader || !main) return;
|
|
6237
|
+
const top = chatHeader.getBoundingClientRect().bottom - main.getBoundingClientRect().top + 8;
|
|
6238
|
+
bar.style.top = Math.max(8, Math.round(top)) + 'px';
|
|
6239
|
+
}
|
|
6240
|
+
function clearFindMarks() {
|
|
6241
|
+
const logEl = document.getElementById('log');
|
|
6242
|
+
if (!logEl) { findMarks = []; return; }
|
|
6243
|
+
const marks = logEl.querySelectorAll('mark.findhit');
|
|
6244
|
+
for (let i = 0; i < marks.length; i++) {
|
|
6245
|
+
const mark = marks[i];
|
|
6246
|
+
const parent = mark.parentNode;
|
|
6247
|
+
if (!parent) continue;
|
|
6248
|
+
parent.replaceChild(document.createTextNode(mark.textContent), mark);
|
|
6249
|
+
parent.normalize();
|
|
6250
|
+
}
|
|
6251
|
+
findMarks = [];
|
|
6252
|
+
}
|
|
6253
|
+
function paintFindCount() {
|
|
6254
|
+
if (!findCount) return;
|
|
6255
|
+
const n = findMarks.length;
|
|
6256
|
+
findCount.textContent = n ? ((findIndex + 1) + ' / ' + n) : (findInp && findInp.value.trim() ? '0 / 0' : '');
|
|
6257
|
+
}
|
|
6258
|
+
function skipFindNode(node) {
|
|
6259
|
+
const p = node && node.parentNode;
|
|
6260
|
+
if (!p || !p.closest) return true;
|
|
6261
|
+
return !!p.closest('.copybtn, .html-preview-wrap, button, script, style');
|
|
6262
|
+
}
|
|
6263
|
+
function highlightTextNode(node, query) {
|
|
6264
|
+
const text = node.nodeValue || '';
|
|
6265
|
+
const hay = text.toLowerCase();
|
|
6266
|
+
const needle = query.toLowerCase();
|
|
6267
|
+
if (!needle) return;
|
|
6268
|
+
let from = 0;
|
|
6269
|
+
const parts = [];
|
|
6270
|
+
let idx = hay.indexOf(needle, from);
|
|
6271
|
+
while (idx !== -1) {
|
|
6272
|
+
if (idx > from) parts.push({ t: text.slice(from, idx), hit: false });
|
|
6273
|
+
parts.push({ t: text.slice(idx, idx + needle.length), hit: true });
|
|
6274
|
+
from = idx + needle.length;
|
|
6275
|
+
idx = hay.indexOf(needle, from);
|
|
6276
|
+
}
|
|
6277
|
+
if (!parts.length) return;
|
|
6278
|
+
if (from < text.length) parts.push({ t: text.slice(from), hit: false });
|
|
6279
|
+
const frag = document.createDocumentFragment();
|
|
6280
|
+
for (let i = 0; i < parts.length; i++) {
|
|
6281
|
+
if (parts[i].hit) {
|
|
6282
|
+
const mark = document.createElement('mark');
|
|
6283
|
+
mark.className = 'findhit';
|
|
6284
|
+
mark.textContent = parts[i].t;
|
|
6285
|
+
findMarks.push(mark);
|
|
6286
|
+
frag.appendChild(mark);
|
|
6287
|
+
} else {
|
|
6288
|
+
frag.appendChild(document.createTextNode(parts[i].t));
|
|
6289
|
+
}
|
|
6290
|
+
}
|
|
6291
|
+
node.parentNode.replaceChild(frag, node);
|
|
6292
|
+
}
|
|
6293
|
+
function applyFind(preserve) {
|
|
6294
|
+
const q = findInp ? findInp.value.trim() : '';
|
|
6295
|
+
const keep = preserve ? findIndex : 0;
|
|
6296
|
+
clearFindMarks();
|
|
6297
|
+
if (!q) { findIndex = 0; paintFindCount(); return; }
|
|
6298
|
+
const logEl = document.getElementById('log');
|
|
6299
|
+
if (!logEl) { paintFindCount(); return; }
|
|
6300
|
+
const bubbles = logEl.querySelectorAll('.bubble');
|
|
6301
|
+
for (let b = 0; b < bubbles.length; b++) {
|
|
6302
|
+
const nodes = [];
|
|
6303
|
+
const walker = document.createTreeWalker(bubbles[b], NodeFilter.SHOW_TEXT, null);
|
|
6304
|
+
let n = walker.nextNode();
|
|
6305
|
+
while (n) {
|
|
6306
|
+
if (n.nodeValue && !skipFindNode(n)) nodes.push(n);
|
|
6307
|
+
n = walker.nextNode();
|
|
6308
|
+
}
|
|
6309
|
+
for (let i = 0; i < nodes.length; i++) highlightTextNode(nodes[i], q);
|
|
6310
|
+
}
|
|
6311
|
+
if (!findMarks.length) { findIndex = 0; paintFindCount(); return; }
|
|
6312
|
+
findIndex = keep % findMarks.length;
|
|
6313
|
+
if (findIndex < 0) findIndex = 0;
|
|
6314
|
+
focusFindHit(findIndex);
|
|
6315
|
+
}
|
|
6316
|
+
function focusFindHit(i) {
|
|
6317
|
+
for (let m = 0; m < findMarks.length; m++) findMarks[m].classList.remove('cur');
|
|
6318
|
+
const mark = findMarks[i];
|
|
6319
|
+
if (!mark) { paintFindCount(); return; }
|
|
6320
|
+
mark.classList.add('cur');
|
|
6321
|
+
if (mark.scrollIntoView) mark.scrollIntoView({ block: 'center', inline: 'nearest' });
|
|
6322
|
+
paintFindCount();
|
|
6323
|
+
}
|
|
6324
|
+
function findStep(dir) {
|
|
6325
|
+
if (!findMarks.length) return;
|
|
6326
|
+
findIndex = (findIndex + dir + findMarks.length) % findMarks.length;
|
|
6327
|
+
focusFindHit(findIndex);
|
|
6328
|
+
}
|
|
6329
|
+
function openFindBar() {
|
|
6330
|
+
if (!findBar || !findInp) return;
|
|
6331
|
+
findBar.classList.add('show');
|
|
6332
|
+
placeFindBar();
|
|
6333
|
+
findInp.focus();
|
|
6334
|
+
findInp.select();
|
|
6335
|
+
if (findInp.value.trim()) applyFind(true);
|
|
6336
|
+
else paintFindCount();
|
|
6337
|
+
}
|
|
6338
|
+
function closeFindBar() {
|
|
6339
|
+
if (findBar) findBar.classList.remove('show');
|
|
6340
|
+
clearFindMarks();
|
|
6341
|
+
findIndex = 0;
|
|
6342
|
+
if (findCount) findCount.textContent = '';
|
|
6343
|
+
if (inp) inp.focus();
|
|
6344
|
+
}
|
|
6345
|
+
if (findInp) {
|
|
6346
|
+
findInp.addEventListener('input', () => applyFind(false));
|
|
6347
|
+
findInp.addEventListener('keydown', (e) => {
|
|
6348
|
+
if (e.key === 'Enter') {
|
|
6349
|
+
e.preventDefault();
|
|
6350
|
+
findStep(e.shiftKey ? -1 : 1);
|
|
6351
|
+
}
|
|
6352
|
+
if (e.key === 'Escape') {
|
|
6353
|
+
e.preventDefault();
|
|
6354
|
+
closeFindBar();
|
|
6355
|
+
}
|
|
6356
|
+
});
|
|
6357
|
+
}
|
|
6358
|
+
if (findBar) {
|
|
6359
|
+
const prevBtn = document.getElementById('findPrev');
|
|
6360
|
+
const nextBtn = document.getElementById('findNext');
|
|
6361
|
+
const closeBtn = document.getElementById('findClose');
|
|
6362
|
+
if (prevBtn) prevBtn.addEventListener('click', () => findStep(-1));
|
|
6363
|
+
if (nextBtn) nextBtn.addEventListener('click', () => findStep(1));
|
|
6364
|
+
if (closeBtn) closeBtn.addEventListener('click', closeFindBar);
|
|
6365
|
+
}
|
|
6366
|
+
if (window.electronAPI && typeof window.electronAPI.onFindInThread === 'function') {
|
|
6367
|
+
window.electronAPI.onFindInThread(function () { openFindBar(); });
|
|
6368
|
+
}
|
|
6369
|
+
|
|
5362
6370
|
const reloadBtn = document.getElementById('reloadBtn');
|
|
5363
6371
|
if (reloadBtn) {
|
|
5364
6372
|
reloadBtn.addEventListener('click', async () => {
|
|
@@ -5386,6 +6394,42 @@ const APP_HTML = `<!doctype html>
|
|
|
5386
6394
|
const top = chatHeader.getBoundingClientRect().bottom - mainEl.getBoundingClientRect().top + 8;
|
|
5387
6395
|
hud.style.top = Math.max(8, Math.round(top)) + 'px';
|
|
5388
6396
|
}
|
|
6397
|
+
function fmtDockX(n) {
|
|
6398
|
+
if (n == null || !Number.isFinite(n)) return '—';
|
|
6399
|
+
return (n >= 100 ? String(Math.round(n)) : Number(n).toFixed(n >= 10 ? 1 : 2)) + 'x';
|
|
6400
|
+
}
|
|
6401
|
+
function paintDock(you) {
|
|
6402
|
+
const spillEl = document.getElementById('dockSpill');
|
|
6403
|
+
const sessEl = document.getElementById('dockSession');
|
|
6404
|
+
const paidEl = document.getElementById('dockPaid');
|
|
6405
|
+
const bindEl = document.getElementById('dockBind');
|
|
6406
|
+
const callsEl = document.getElementById('dockCalls');
|
|
6407
|
+
if (!spillEl || !sessEl || !paidEl || !bindEl || !callsEl) return;
|
|
6408
|
+
if (you && you.proxyReachable === false) {
|
|
6409
|
+
spillEl.textContent = '—';
|
|
6410
|
+
spillEl.className = 'dv';
|
|
6411
|
+
sessEl.textContent = '—';
|
|
6412
|
+
sessEl.className = 'dv';
|
|
6413
|
+
paidEl.textContent = 'proxy unreachable';
|
|
6414
|
+
bindEl.textContent = '—';
|
|
6415
|
+
callsEl.textContent = '—';
|
|
6416
|
+
return;
|
|
6417
|
+
}
|
|
6418
|
+
const spent = Number(you && you.spentUsd) || 0;
|
|
6419
|
+
const direct = Number(you && you.directUsd) || 0;
|
|
6420
|
+
const spillX = Number(you && you.spilled && you.spilled.savingX);
|
|
6421
|
+
const spillCalls = Number(you && you.spilled && you.spilled.calls) || 0;
|
|
6422
|
+
const spillOn = Number.isFinite(spillX) && spillX > 0;
|
|
6423
|
+
const bound = spillOn || spillCalls > 0;
|
|
6424
|
+
const sessionX = spent > 0 ? direct / spent : null;
|
|
6425
|
+
spillEl.textContent = spillOn ? fmtDockX(spillX) : '—';
|
|
6426
|
+
spillEl.className = spillOn ? 'dv hlime' : 'dv';
|
|
6427
|
+
sessEl.textContent = sessionX == null ? '—' : fmtDockX(sessionX);
|
|
6428
|
+
sessEl.className = 'dv';
|
|
6429
|
+
paidEl.textContent = usd(spent);
|
|
6430
|
+
bindEl.textContent = bound ? 'yes' : 'no';
|
|
6431
|
+
callsEl.textContent = String((you && you.paidCalls) || 0);
|
|
6432
|
+
}
|
|
5389
6433
|
function usd(n) {
|
|
5390
6434
|
if (n === null || n === undefined) return '—';
|
|
5391
6435
|
if (n === 0) return '$0';
|
|
@@ -5398,6 +6442,22 @@ const APP_HTML = `<!doctype html>
|
|
|
5398
6442
|
// straight to localhost:8402 would work fine, but routing it through
|
|
5399
6443
|
// our own backend keeps one fetch path if that ever needs to change.
|
|
5400
6444
|
const you = await (await fetch(API + '/hud-summary')).json();
|
|
6445
|
+
const proxyDown = you.proxyReachable === false;
|
|
6446
|
+
setHudTick(proxyDown ? 2000 : 30000);
|
|
6447
|
+
if (proxyDown) {
|
|
6448
|
+
const creditEl = document.getElementById('hCredit');
|
|
6449
|
+
if (creditEl) creditEl.textContent = '—';
|
|
6450
|
+
document.getElementById('hYouSpent').textContent = 'proxy unreachable';
|
|
6451
|
+
document.getElementById('hYouCogs').textContent = '—';
|
|
6452
|
+
document.getElementById('hYouMargin').textContent = '—';
|
|
6453
|
+
document.getElementById('hYouDirect').textContent = '—';
|
|
6454
|
+
document.getElementById('hYouSaved').textContent = '—';
|
|
6455
|
+
document.getElementById('hFoot').textContent = 'proxy unreachable';
|
|
6456
|
+
const hintEl = document.getElementById('hHint');
|
|
6457
|
+
if (hintEl) hintEl.className = 'hhint';
|
|
6458
|
+
paintDock(you);
|
|
6459
|
+
return;
|
|
6460
|
+
}
|
|
5401
6461
|
const creditEl = document.getElementById('hCredit');
|
|
5402
6462
|
if (creditEl) creditEl.textContent = (you.creditUsd == null) ? '—' : usd(Number(you.creditUsd) || 0);
|
|
5403
6463
|
const subRow = document.getElementById('hSubRow');
|
|
@@ -5426,13 +6486,15 @@ const APP_HTML = `<!doctype html>
|
|
|
5426
6486
|
const savedEl = document.getElementById('hYouSaved');
|
|
5427
6487
|
const hintEl = document.getElementById('hHint');
|
|
5428
6488
|
if (spent > 0) {
|
|
5429
|
-
const
|
|
6489
|
+
const sav = formatSavingLabel(you);
|
|
6490
|
+
const mult = sav.mult;
|
|
5430
6491
|
// honest either way: >=1x is a real saving vs a naked direct call,
|
|
5431
6492
|
// <1x means you're currently paying MORE than direct would cost —
|
|
5432
6493
|
// don't dress that up as green when it isn't one.
|
|
5433
6494
|
// Asking a bound corpus makes this genuinely large (the counterfactual
|
|
5434
6495
|
// is shipping the WHOLE corpus), so 2dp would read as noise up there.
|
|
5435
|
-
|
|
6496
|
+
// Label the number: "Nx spilled" when bound, else "Nx session".
|
|
6497
|
+
savedEl.textContent = sav.text;
|
|
5436
6498
|
savedEl.className = mult >= 1 ? 'hlime' : 'hember';
|
|
5437
6499
|
// Session direct/spent (never first-call). Ember when cogs > spent —
|
|
5438
6500
|
// house losing. Do not treat race_unused as a user refund.
|
|
@@ -5444,7 +6506,7 @@ const APP_HTML = `<!doctype html>
|
|
|
5444
6506
|
hintEl.className = mult >= 1 ? 'hhint' : 'hhint show';
|
|
5445
6507
|
hintEl.innerHTML = '<b>feed it more.</b> you\\'re billed on the slice actually sent, '
|
|
5446
6508
|
+ 'not the corpus — so the more you bind, the further ahead this gets. '
|
|
5447
|
-
+ 'small inputs cost more than sending them straight.';
|
|
6509
|
+
+ 'small inputs cost more than sending them straight. HUD is spilled-call x when any call bound.';
|
|
5448
6510
|
}
|
|
5449
6511
|
} else {
|
|
5450
6512
|
savedEl.textContent = '—';
|
|
@@ -5456,25 +6518,45 @@ const APP_HTML = `<!doctype html>
|
|
|
5456
6518
|
}
|
|
5457
6519
|
}
|
|
5458
6520
|
document.getElementById('hFoot').textContent = (you.paidCalls || 0) + ' paid calls this session';
|
|
6521
|
+
paintDock(you);
|
|
5459
6522
|
} catch (e) {
|
|
5460
6523
|
document.getElementById('hFoot').textContent = 'error: ' + e.message;
|
|
5461
6524
|
}
|
|
5462
6525
|
}
|
|
5463
6526
|
let hudTimer = null;
|
|
6527
|
+
let hudTickMs = 30000;
|
|
6528
|
+
function setHudTick(ms) {
|
|
6529
|
+
if (hudTimer && hudTickMs === ms) return;
|
|
6530
|
+
hudTickMs = ms;
|
|
6531
|
+
if (hudTimer) clearInterval(hudTimer);
|
|
6532
|
+
hudTimer = setInterval(refreshHud, hudTickMs);
|
|
6533
|
+
}
|
|
6534
|
+
function ensureHudTick() {
|
|
6535
|
+
if (hudTimer) return;
|
|
6536
|
+
hudTimer = setInterval(refreshHud, hudTickMs);
|
|
6537
|
+
}
|
|
5464
6538
|
hudBtn.addEventListener('click', (e) => {
|
|
5465
6539
|
e.stopPropagation();
|
|
5466
6540
|
hud.classList.toggle('show');
|
|
5467
6541
|
if (hud.classList.contains('show')) {
|
|
5468
6542
|
placeHud();
|
|
5469
6543
|
refreshHud();
|
|
5470
|
-
hudTimer = setInterval(refreshHud, 30000);
|
|
5471
|
-
} else if (hudTimer) {
|
|
5472
|
-
clearInterval(hudTimer); hudTimer = null;
|
|
5473
6544
|
}
|
|
6545
|
+
echoSlash('/hud');
|
|
5474
6546
|
});
|
|
5475
|
-
|
|
5476
|
-
|
|
5477
|
-
|
|
6547
|
+
refreshHud();
|
|
6548
|
+
ensureHudTick();
|
|
6549
|
+
window.addEventListener('resize', () => {
|
|
6550
|
+
if (hud.classList.contains('show')) placeHud();
|
|
6551
|
+
if (findBarOpen()) placeFindBar();
|
|
6552
|
+
});
|
|
6553
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
6554
|
+
if (chatHeader) {
|
|
6555
|
+
new ResizeObserver(() => {
|
|
6556
|
+
if (hud.classList.contains('show')) placeHud();
|
|
6557
|
+
if (findBarOpen()) placeFindBar();
|
|
6558
|
+
}).observe(chatHeader);
|
|
6559
|
+
}
|
|
5478
6560
|
}
|
|
5479
6561
|
document.addEventListener('click', (e) => { if (!hud.contains(e.target)) hud.classList.remove('show'); });
|
|
5480
6562
|
</script>
|
|
@@ -5652,9 +6734,25 @@ const server = http.createServer((req, res) => {
|
|
|
5652
6734
|
|
|
5653
6735
|
if (req.method === 'GET' && req.url === '/hud-summary') {
|
|
5654
6736
|
(async () => {
|
|
5655
|
-
let you = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0, creditUsd: null, chainUsd: null };
|
|
5656
|
-
try {
|
|
5657
|
-
|
|
6737
|
+
let you = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0, creditUsd: null, chainUsd: null, proxyReachable: false };
|
|
6738
|
+
try {
|
|
6739
|
+
const r = await fetch('http://127.0.0.1:8402/v1/session', { signal: AbortSignal.timeout(2000) });
|
|
6740
|
+
// 402 = empty wallet / Pay — the sidecar is up. Do not paint that as dead.
|
|
6741
|
+
if (r.ok || r.status === 402) {
|
|
6742
|
+
you.proxyReachable = true;
|
|
6743
|
+
if (r.ok) {
|
|
6744
|
+
try { you = { ...you, ...(await r.json()), proxyReachable: true }; }
|
|
6745
|
+
catch { /* body optional */ }
|
|
6746
|
+
}
|
|
6747
|
+
}
|
|
6748
|
+
} catch { /* session down — try /v1/models before calling the proxy dead */ }
|
|
6749
|
+
if (!you.proxyReachable) {
|
|
6750
|
+
try {
|
|
6751
|
+
const r = await fetch('http://127.0.0.1:8402/v1/models', { signal: AbortSignal.timeout(2000) });
|
|
6752
|
+
if (r.ok || r.status === 402) you.proxyReachable = true;
|
|
6753
|
+
} catch { /* local proxy not running — HUD must not pretend spend is $0 */ }
|
|
6754
|
+
}
|
|
6755
|
+
await attachSpilled(you);
|
|
5658
6756
|
try {
|
|
5659
6757
|
you.creditUsd = await creditBalance();
|
|
5660
6758
|
} catch { /* credit is advisory */ }
|
|
@@ -5740,7 +6838,7 @@ const server = http.createServer((req, res) => {
|
|
|
5740
6838
|
const t = threads.get(req.url.split('/')[2]);
|
|
5741
6839
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
5742
6840
|
res.end(t ? JSON.stringify({
|
|
5743
|
-
id: t.id, history: t.history, status: t.status,
|
|
6841
|
+
id: t.id, history: visibleHistory(t.history), status: t.status,
|
|
5744
6842
|
liveStatus: t.status === 'thinking' ? (t.liveStatus || '') : '',
|
|
5745
6843
|
liveRace: t.status === 'thinking' ? (t.liveRace || null) : null,
|
|
5746
6844
|
lastRaceFail: t.lastRaceFail || null,
|
|
@@ -5829,28 +6927,39 @@ const server = http.createServer((req, res) => {
|
|
|
5829
6927
|
threadId = j.threadId; task = (j.task || '').toString();
|
|
5830
6928
|
images = Array.isArray(j.images) ? j.images.filter((u) => typeof u === 'string') : [];
|
|
5831
6929
|
} catch { /* ignore */ }
|
|
5832
|
-
|
|
5833
|
-
|
|
6930
|
+
const ack = (ok, extra = {}) => {
|
|
6931
|
+
if (res.writableEnded) return;
|
|
6932
|
+
const status = extra.status || (ok ? 200 : 500);
|
|
6933
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
6934
|
+
res.end(JSON.stringify({ ok: Boolean(ok), persisted: extra.persisted !== false, ...extra.body }));
|
|
6935
|
+
};
|
|
5834
6936
|
const t = threads.get(threadId);
|
|
5835
|
-
|
|
5836
|
-
|
|
5837
|
-
|
|
5838
|
-
|
|
6937
|
+
if (!t) {
|
|
6938
|
+
ack(false, { status: 404, persisted: false });
|
|
6939
|
+
return;
|
|
6940
|
+
}
|
|
6941
|
+
// Grokui slashes stay local. In Auto, Claude's /agents /tasks /context
|
|
6942
|
+
// /model (and any slash grokui does not own) go to the PTY — print-mode
|
|
6943
|
+
// used to stub those as "wizard removed".
|
|
6944
|
+
if (/^\//.test(task.trim())) {
|
|
5839
6945
|
// Drawer-only. Never dump sitrep into the transcript.
|
|
5840
|
-
if (/^\/sitrep\b/i.test(task.trim())) return;
|
|
5841
|
-
|
|
5842
|
-
|
|
5843
|
-
|
|
5844
|
-
|
|
5845
|
-
|
|
5846
|
-
|
|
6946
|
+
if (/^\/sitrep\b/i.test(task.trim())) { ack(true, { persisted: true }); return; }
|
|
6947
|
+
if (!(t.runMode === 'auto' && !isGrokuiOwnedSlash(task.trim(), t.runMode))) {
|
|
6948
|
+
const handled = await handleSlash(task.trim(), t).catch((e) => `error: ${e.message}`);
|
|
6949
|
+
if (handled !== null && handled !== undefined) {
|
|
6950
|
+
t.history.push({ who: 'bot', text: handled });
|
|
6951
|
+
t.lastActivityAt = Date.now();
|
|
6952
|
+
saveThreads();
|
|
6953
|
+
ack(true, { persisted: true });
|
|
6954
|
+
return;
|
|
6955
|
+
}
|
|
5847
6956
|
}
|
|
5848
6957
|
}
|
|
5849
6958
|
// "/dir <path>" is a LOCAL control command, not sent to the model at
|
|
5850
6959
|
// all — free, instant, sets which folder this thread's WRITE/READ/SERVE
|
|
5851
6960
|
// are scoped to. Respecify any time by sending it again.
|
|
5852
6961
|
const dirCmd = /^\/dir\s+(.+)/.exec(task.trim());
|
|
5853
|
-
if (dirCmd
|
|
6962
|
+
if (dirCmd) {
|
|
5854
6963
|
const full = path.resolve(expandHome(dirCmd[1].trim()));
|
|
5855
6964
|
let ok = false;
|
|
5856
6965
|
try { ok = statSync(full).isDirectory(); } catch { /* not a dir / doesn't exist */ }
|
|
@@ -5861,17 +6970,29 @@ const server = http.createServer((req, res) => {
|
|
|
5861
6970
|
t.history.push({ who: 'bot', text: `"${full}" isn't a directory that exists.` });
|
|
5862
6971
|
}
|
|
5863
6972
|
saveThreads();
|
|
6973
|
+
ack(true, { persisted: true });
|
|
5864
6974
|
return;
|
|
5865
6975
|
}
|
|
5866
|
-
// "/mode auto|ask"
|
|
5867
|
-
//
|
|
6976
|
+
// "/mode auto|ask" — Auto is the Claude Code harness (openzoo claude
|
|
6977
|
+
// env). Ask stays a chat completion; RUN: waits for approve/deny.
|
|
5868
6978
|
const modeCmd = /^\/mode\s+(auto|ask)\b/.exec(task.trim());
|
|
5869
|
-
if (modeCmd
|
|
6979
|
+
if (modeCmd) {
|
|
5870
6980
|
t.runMode = modeCmd[1];
|
|
5871
|
-
|
|
6981
|
+
if (modeCmd[1] !== 'auto') closeClaudeSession(t.id);
|
|
6982
|
+
t.history.push({ who: 'bot', text: `Run mode set to ${modeCmd[1]}${modeCmd[1] === 'auto' ? ' — Claude Code via OpenZoo (x402). Interactive TUI on a PTY, not --print.' : ' — chat completion; RUN: waits for your approval.'}` });
|
|
5872
6983
|
saveThreads();
|
|
6984
|
+
ack(true, { persisted: true });
|
|
6985
|
+
return;
|
|
6986
|
+
}
|
|
6987
|
+
// Persist the user bubble to disk BEFORE the model call and before
|
|
6988
|
+
// the HTTP ACK. A proxy 500 / sidecar bounce / remount must not
|
|
6989
|
+
// forget the turn, and must not rewrite it as an AUTO continue.
|
|
6990
|
+
const flushed = persistUserTurn(t, task, images);
|
|
6991
|
+
if (!flushed.ok) {
|
|
6992
|
+
ack(false, { status: 500, persisted: false });
|
|
5873
6993
|
return;
|
|
5874
6994
|
}
|
|
6995
|
+
ack(true, { persisted: true });
|
|
5875
6996
|
// Stream to whoever is watching this thread. emitToThread is a no-op
|
|
5876
6997
|
// when nobody is, so a spawned subagent nobody has open costs nothing.
|
|
5877
6998
|
runTurn(threadId, task, (ev) => emitToThread(threadId, ev), images).catch(() => {});
|
|
@@ -5886,12 +7007,16 @@ server.listen(PORT, BIND, () => console.log(`[grokui] http://${BIND === '0.0.0.0
|
|
|
5886
7007
|
|
|
5887
7008
|
export {
|
|
5888
7009
|
tryDirective, ensureWorkspacePort, isPreviewableRel, previewAck, workspaceFileUrl,
|
|
5889
|
-
parseRun, looksLikeMcpAsBash, stripThinkTags, safeResolveIn, inDir, listDir,
|
|
5890
|
-
handleSlash, newThread, setRunTurnForTest, setBrainAskForTest, runTurn,
|
|
7010
|
+
parseRun, looksLikeMcpAsBash, stripThinkTags, takeThink, safeResolveIn, inDir, listDir,
|
|
7011
|
+
handleSlash, isGrokuiOwnedSlash, newThread, setRunTurnForTest, setBrainAskForTest, setClaudeRunnerForTest, runTurn,
|
|
7012
|
+
runAutoClaudeTurn, autoClaudePrompt,
|
|
5891
7013
|
AUTO_CONTINUE, AUTO_RACE_RETRY, AUTO_EMPTY_RETRY, pingWakeText, pingCanWake, shouldKeepAuto,
|
|
5892
|
-
isDoneReply, isTransientModelFail, isEmptyToolResult, enqueueAutoHop, childKickoff, findByName,
|
|
7014
|
+
isDoneReply, isTransientModelFail, isPaymentFailed, isEmptyWalletPayment, isEmptyToolResult, enqueueAutoHop, childKickoff, findByName,
|
|
5893
7015
|
attachChildDir, finishChildDir,
|
|
5894
7016
|
lockWorktree, unlockWorktree, parsePrRef, fetchSpecsForOrigin, agentSlug,
|
|
5895
7017
|
filesForCorpus, noteFileForCorpus, noteRunForCorpus, resetFilesForCorpus,
|
|
5896
7018
|
filesForCorpusKeys, scheduleFilesForCorpus, inFlightChars, BIND_MIN_CHARS, KEEP_MAX,
|
|
7019
|
+
formatSavingLabel, holobrainOf, looksLikeProxyShell, CHAT_NOT_PROXY, PROXY_SHELL_REFUSE,
|
|
7020
|
+
persistUserTurn, isHarnessUserText, visibleHistory, isVisibleHistoryEntry, saveThreads,
|
|
7021
|
+
isClaudeFallbackReply, popClaudeFallbackBot, threadHasVisibleBotReply,
|
|
5897
7022
|
};
|