openzoo 0.49.8 → 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 +26 -7
- package/bin/openzoo.js +3 -2
- package/lib/claudecode.js +847 -0
- package/lib/grokui.mjs +1323 -225
- package/lib/launch.js +220 -91
- package/lib/livestatus.js +4 -2
- 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 +155 -64
- package/lib/package.json +3 -0
- package/lib/podagent.mjs +73 -27
- package/lib/proxy.js +106 -46
- 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,6 +746,34 @@ 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
|
+
}
|
|
719
777
|
function isEmptyWalletPayment(text) {
|
|
720
778
|
// Empty/underfunded only — not a generic HTTP 402 handshake.
|
|
721
779
|
return /\b(?:wallet is empty|empty wallet|wallet underfunded|underfunded)\b/i.test(String(text || ''));
|
|
@@ -852,7 +910,22 @@ without a fact only the user has.
|
|
|
852
910
|
When the job is actually finished, emit DONE: as the first line. A status
|
|
853
911
|
sentence is not a stop — the harness keeps this thread working until DONE:,
|
|
854
912
|
a real blocking question, or the step cap. Empty output, "(no output)", and
|
|
855
|
-
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
|
+
|
|
856
929
|
async function bindThread(t) {
|
|
857
930
|
// Only bind what's NEW since the last successful bind, continuing the
|
|
858
931
|
// existing context_id — previously this rebuilt and re-sent the WHOLE
|
|
@@ -867,15 +940,13 @@ async function bindThread(t) {
|
|
|
867
940
|
const corpus = delta.map((h) => '[' + t.name + '] ' + (h.who === 'user' ? 'you' : (h.name || t.name)) + ': ' + h.text).join('\n');
|
|
868
941
|
if (!corpus.trim()) { t.boundHistoryCount = t.history.length; return; }
|
|
869
942
|
try {
|
|
870
|
-
//
|
|
871
|
-
//
|
|
872
|
-
//
|
|
873
|
-
//
|
|
874
|
-
//
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
const root = threads.get(rootOf(t).rootId) || t;
|
|
878
|
-
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;
|
|
879
950
|
for (let i = 0; i < corpus.length; i += BIND_CHUNK_BYTES) {
|
|
880
951
|
const part = corpus.slice(i, i + BIND_CHUNK_BYTES);
|
|
881
952
|
const body = ctx ? { corpus: part, context_id: ctx } : { corpus: part };
|
|
@@ -889,12 +960,12 @@ async function bindThread(t) {
|
|
|
889
960
|
// How many chunks the project's holobrain now holds. This is the number
|
|
890
961
|
// adaptive top_k scales on — without it we would be guessing, which is
|
|
891
962
|
// exactly how top_k ended up pinned at 8 in the first place.
|
|
892
|
-
if (Number(j?.bound))
|
|
963
|
+
if (Number(j?.bound)) brain.boundItems = (brain.boundItems || 0) + Number(j.bound);
|
|
893
964
|
else break; // this chunk failed — stop, keep whatever bound so far rather than lose it all
|
|
894
965
|
}
|
|
895
|
-
// Write to the
|
|
896
|
-
// the per-call header is
|
|
897
|
-
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(); }
|
|
898
969
|
} catch { /* leCore sidecar unreachable — thread still works, just not bound this round */ }
|
|
899
970
|
}
|
|
900
971
|
|
|
@@ -1035,8 +1106,10 @@ const RUN_SHELL = process.platform !== 'win32' && existsSync('/bin/bash') ? '/bi
|
|
|
1035
1106
|
const RUN_TIMEOUT_MS = Number(process.env.OZ_RUN_TIMEOUT_MS || 600000);
|
|
1036
1107
|
|
|
1037
1108
|
function execCommand(command, cwd) {
|
|
1109
|
+
if (looksLikeProxyShell(command)) return Promise.resolve(PROXY_SHELL_REFUSE);
|
|
1110
|
+
const guarded = guardFindCwd(command, cwd);
|
|
1038
1111
|
return new Promise((resolve) => {
|
|
1039
|
-
exec(
|
|
1112
|
+
exec(guarded, { cwd, shell: RUN_SHELL, timeout: RUN_TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
1040
1113
|
let out = (stdout || '') + (stderr ? '\n' + stderr : '');
|
|
1041
1114
|
if (err) out += `\n(exit ${err.code ?? 1})`;
|
|
1042
1115
|
resolve(keepWhole(out).trim() || '(no output)');
|
|
@@ -1083,11 +1156,41 @@ const SLASH_COMMANDS = [
|
|
|
1083
1156
|
{ name: '/cron', args: '<mins> | <message>', help: 'repeat a message on a timer' },
|
|
1084
1157
|
{ name: '/crons', args: '', help: 'list timers (/cron del <id> removes one)' },
|
|
1085
1158
|
{ name: '/dir', args: '<path>', help: 'set this thread’s working directory' },
|
|
1086
|
-
{ name: '/mode', args: 'auto|ask', help: '
|
|
1159
|
+
{ name: '/mode', args: 'auto|ask', help: 'Auto = Claude Code via OpenZoo; ask = chat + approve RUN' },
|
|
1087
1160
|
];
|
|
1088
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
|
+
|
|
1089
1175
|
const usd = (n) => (n >= 0.01 || n === 0 ? '$' + n.toFixed(2) : '$' + n.toFixed(5));
|
|
1090
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
|
+
|
|
1091
1194
|
// BIND, DON'T PASTE — the whole point of the thing this runs on.
|
|
1092
1195
|
//
|
|
1093
1196
|
// Directive results get fed back to the model as a user message, verbatim. A
|
|
@@ -1215,8 +1318,8 @@ function inFlightChars(t) {
|
|
|
1215
1318
|
*/
|
|
1216
1319
|
function scheduleFilesForCorpus(t, collected, opts = {}) {
|
|
1217
1320
|
if (!collected?.pending?.length) return null;
|
|
1218
|
-
const
|
|
1219
|
-
const ctx = opts.contextId ||
|
|
1321
|
+
const brain = t ? holobrainOf(t) : null;
|
|
1322
|
+
const ctx = opts.contextId || brain?.contextId || t?.contextId || null;
|
|
1220
1323
|
const chars = opts.sentChars ?? inFlightChars(t);
|
|
1221
1324
|
const background = chars < BIND_MIN_CHARS;
|
|
1222
1325
|
const fetchImpl = opts.fetchImpl || fetch;
|
|
@@ -1233,7 +1336,7 @@ function scheduleFilesForCorpus(t, collected, opts = {}) {
|
|
|
1233
1336
|
}).then(async (r) => {
|
|
1234
1337
|
const j = await r.json().catch(() => ({}));
|
|
1235
1338
|
if (j?.context_id && t) {
|
|
1236
|
-
const live =
|
|
1339
|
+
const live = holobrainOf(t) || t;
|
|
1237
1340
|
live.contextId = j.context_id;
|
|
1238
1341
|
t.contextId = j.context_id;
|
|
1239
1342
|
if (Number(j.bound)) live.boundItems = (live.boundItems || 0) + Number(j.bound);
|
|
@@ -1301,9 +1404,20 @@ function emitToThread(threadId, ev) {
|
|
|
1301
1404
|
}
|
|
1302
1405
|
}
|
|
1303
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
|
+
|
|
1304
1416
|
async function sessionStats() {
|
|
1305
|
-
try {
|
|
1306
|
-
|
|
1417
|
+
try {
|
|
1418
|
+
const s = await (await fetch(`${PROXY}/session`, { signal: AbortSignal.timeout(2000) })).json();
|
|
1419
|
+
return attachSpilled(s);
|
|
1420
|
+
} catch { return null; }
|
|
1307
1421
|
}
|
|
1308
1422
|
|
|
1309
1423
|
function todoBlock(t) {
|
|
@@ -1328,7 +1442,7 @@ async function handleSlash(task, t) {
|
|
|
1328
1442
|
}
|
|
1329
1443
|
if (cmd === 'tools') {
|
|
1330
1444
|
return 'Directives:\n'
|
|
1331
|
-
+ ' RUN: <cmd> real shell, in this thread’s dir\n'
|
|
1445
|
+
+ ' RUN: <cmd> real shell, in this thread’s dir (never find /)\n'
|
|
1332
1446
|
+ ' WRITE: <path> | <content> create/overwrite a file\n'
|
|
1333
1447
|
+ ' EDIT: <path> | <old> ||| <new> change part of a file\n'
|
|
1334
1448
|
+ ' MULTIEDIT: <path> | a|||b ;; c|||d several edits, all-or-nothing\n'
|
|
@@ -1349,6 +1463,20 @@ async function handleSlash(task, t) {
|
|
|
1349
1463
|
+ 'READ, LS, GLOB, GREP, FETCH, PEEK and MCP run CONCURRENTLY when several\n'
|
|
1350
1464
|
+ 'appear in one reply — four files cost one round trip, not four.';
|
|
1351
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
|
+
}
|
|
1352
1480
|
if (cmd === 'cost' || cmd === 'tokens') {
|
|
1353
1481
|
const s = await sessionStats();
|
|
1354
1482
|
if (!s) return 'The local openzoo proxy isn’t reachable, so there are no real numbers to show. (Not zero — unknown.)';
|
|
@@ -1360,9 +1488,9 @@ async function handleSlash(task, t) {
|
|
|
1360
1488
|
` paid calls ${s.paidCalls || 0}`,
|
|
1361
1489
|
];
|
|
1362
1490
|
if (spent > 0) {
|
|
1363
|
-
const
|
|
1364
|
-
lines.push(` multiple ${
|
|
1365
|
-
+ (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' : ''));
|
|
1366
1494
|
}
|
|
1367
1495
|
return 'This session:\n' + lines.join('\n');
|
|
1368
1496
|
}
|
|
@@ -1377,16 +1505,27 @@ async function handleSlash(task, t) {
|
|
|
1377
1505
|
// tier silently overriding an explicit id would make /model a suggestion.
|
|
1378
1506
|
if (cmd === 'tier') {
|
|
1379
1507
|
if (!arg) {
|
|
1380
|
-
const
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
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`
|
|
1384
1519
|
+ (t.model ? `NOTE: /model ${t.model} is pinned on this thread, so the tier is ignored until you /model default.\n` : '')
|
|
1385
1520
|
+ 'Switch with /tier <name> · /race <n> to ask several at once.';
|
|
1386
1521
|
}
|
|
1387
1522
|
const want = normalizeTier(arg);
|
|
1388
|
-
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).`;
|
|
1389
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
|
+
}
|
|
1390
1529
|
const picks = await tierModels(want, 3);
|
|
1391
1530
|
return `This thread now runs on the ${want} tier — ${picks.join(', ')}…`
|
|
1392
1531
|
+ (t.model ? `\nBut /model ${t.model} is still pinned and wins. Run /model default to let the tier take over.` : '');
|
|
@@ -1521,7 +1660,7 @@ async function handleSlash(task, t) {
|
|
|
1521
1660
|
|
|
1522
1661
|
// Wake the room. Used to be a free last-line dump — idle children stayed
|
|
1523
1662
|
// idle, and a parent reading "kid: <old reply>" thought they had acted.
|
|
1524
|
-
// Empty extra is a
|
|
1663
|
+
// Empty extra is a continue wake (Claude Code on Auto), not a cancel. Thinking stays
|
|
1525
1664
|
// thinking; pendingRun stays on the human. Same branch scope as /all.
|
|
1526
1665
|
if (cmd === 'ping') {
|
|
1527
1666
|
const crew = subtreeOf(t.id, true);
|
|
@@ -1884,6 +2023,69 @@ function isHarnessUserText(text) {
|
|
|
1884
2023
|
|| String(text || '').startsWith('AUTO_EMPTY_RETRY:');
|
|
1885
2024
|
}
|
|
1886
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
|
+
|
|
1887
2089
|
function firstUserAsk(t) {
|
|
1888
2090
|
return (t?.history || []).find((m) => m.who === 'user' && !isHarnessUserText(m.text));
|
|
1889
2091
|
}
|
|
@@ -1911,7 +2113,7 @@ function spawnBrief(parent, { refresh = false, child } = {}) {
|
|
|
1911
2113
|
const cwd = child?.dir || WORKSPACE_DIR;
|
|
1912
2114
|
const branch = child?.worktree?.branch;
|
|
1913
2115
|
const mode = parent.runMode || 'ask';
|
|
1914
|
-
const tier = parent.tier || '
|
|
2116
|
+
const tier = parent.tier || 'auto';
|
|
1915
2117
|
const race = Number(parent.race) || 0;
|
|
1916
2118
|
const raceNeed = Number(parent.raceNeed) || 1;
|
|
1917
2119
|
const model = parent.model || '';
|
|
@@ -2622,12 +2824,141 @@ async function mcpDirective(url, tool, args) {
|
|
|
2622
2824
|
}
|
|
2623
2825
|
}
|
|
2624
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
|
+
|
|
2625
2955
|
// onEvent (optional) gets live progress for whoever's actually watching this
|
|
2626
2956
|
// call: {type:'start',name,color} when a bot begins its turn, {type:'status',
|
|
2627
2957
|
// detail} while paying / waiting / racing / walking tools, {type:'race',race}
|
|
2628
2958
|
// for the spectator grid (one cell per launched model + a judging beat),
|
|
2629
2959
|
// {type:'delta',name,color,delta} per streamed token (replace:true swaps the
|
|
2630
|
-
// 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
|
|
2631
2962
|
// directive ack) is settled. Background turns go through kickTurn →
|
|
2632
2963
|
// emitToThread, which is a no-op if nobody has the thread open.
|
|
2633
2964
|
async function runTurn(threadId, userText, onEvent, images) {
|
|
@@ -2647,14 +2978,15 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2647
2978
|
if (!stillMine()) return;
|
|
2648
2979
|
if (ev.type === 'status' && ev.detail && t.status === 'thinking') t.liveStatus = ev.detail;
|
|
2649
2980
|
if (ev.type === 'race' && ev.race && t.status === 'thinking') t.liveRace = ev.race;
|
|
2650
|
-
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();
|
|
2651
2982
|
onEvent?.(ev);
|
|
2652
2983
|
};
|
|
2653
|
-
t
|
|
2984
|
+
persistUserTurn(t, userText, images);
|
|
2654
2985
|
t.lastActivityAt = Date.now();
|
|
2655
2986
|
t.status = 'thinking';
|
|
2656
2987
|
t.thinkingAt = Date.now();
|
|
2657
2988
|
t.lastDeltaAt = Date.now();
|
|
2989
|
+
saveThreads();
|
|
2658
2990
|
try { turnAborts.get(t)?.abort(); } catch { /* none */ }
|
|
2659
2991
|
const turnAbort = new AbortController();
|
|
2660
2992
|
turnAborts.set(t, turnAbort);
|
|
@@ -2665,6 +2997,8 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2665
2997
|
t.liveStatus = (!t.model && raceN >= 2) ? formatRaceStatus(0, raceNeed) : 'waiting on model…';
|
|
2666
2998
|
let chained = false;
|
|
2667
2999
|
let parked = false;
|
|
3000
|
+
let usedClaude = false;
|
|
3001
|
+
let claudeFallback = false;
|
|
2668
3002
|
let lastReply = '';
|
|
2669
3003
|
try {
|
|
2670
3004
|
if (t.members) {
|
|
@@ -2680,11 +3014,15 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2680
3014
|
const emitStatus = (detail) => paint({ type: 'status', name: m.name, color: m.color, detail });
|
|
2681
3015
|
try {
|
|
2682
3016
|
r = onEvent
|
|
2683
|
-
? (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()
|
|
2684
3021
|
: (await brain(msgs, t.contextId)).trim();
|
|
2685
3022
|
} catch (e) { r = `error: ${e.message}`; }
|
|
2686
3023
|
if (!stillMine()) return;
|
|
2687
|
-
|
|
3024
|
+
const memberThink = takeThink(r);
|
|
3025
|
+
r = memberThink.text;
|
|
2688
3026
|
memberReply = r;
|
|
2689
3027
|
const runCmd = parseRun(r);
|
|
2690
3028
|
if (runCmd) {
|
|
@@ -2694,16 +3032,19 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2694
3032
|
const output = await execCommand(command, dirFor(t.id));
|
|
2695
3033
|
noteRunForCorpus(t.id, command, { cwd: dirFor(t.id) });
|
|
2696
3034
|
const shown = `$ ${command}\n${output}`;
|
|
2697
|
-
t.history.push({
|
|
2698
|
-
|
|
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 });
|
|
2699
3040
|
memberReply = shown;
|
|
2700
3041
|
// this member's turn is done; the round continues to the next member
|
|
2701
3042
|
continue;
|
|
2702
3043
|
}
|
|
2703
3044
|
const runId = randomUUID();
|
|
2704
3045
|
t.pendingRun = { runId, command, cwd: dirFor(t.id) };
|
|
2705
|
-
t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending', name: m.name, color: m.color });
|
|
2706
|
-
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 });
|
|
2707
3048
|
// pauses the WHOLE round here — the rest of the group gets their turn
|
|
2708
3049
|
// on the round that runs after the user approves/denies
|
|
2709
3050
|
parked = true;
|
|
@@ -2711,8 +3052,8 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2711
3052
|
}
|
|
2712
3053
|
const ack = await tryDirective(r, t.id, paint);
|
|
2713
3054
|
const finalText = ack ?? (r || '(no response)');
|
|
2714
|
-
t.history.push({ who: 'bot', text: finalText, name: m.name, color: m.color });
|
|
2715
|
-
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 });
|
|
2716
3057
|
memberReply = r;
|
|
2717
3058
|
}
|
|
2718
3059
|
bindThread(t).catch(() => {});
|
|
@@ -2722,6 +3063,38 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2722
3063
|
}
|
|
2723
3064
|
return;
|
|
2724
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
|
+
}
|
|
2725
3098
|
// A real message from the user resets the auto budget AND the announcement
|
|
2726
3099
|
// nudge. Harness-injected hops (command output, directive result, nudge,
|
|
2727
3100
|
// auto-continue) must not re-arm — that would make AUTO_MAX_STEPS a no-op
|
|
@@ -2731,7 +3104,12 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2731
3104
|
t.autoSteps = 0;
|
|
2732
3105
|
delete t.autoNudged;
|
|
2733
3106
|
}
|
|
2734
|
-
|
|
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
|
+
}
|
|
2735
3113
|
let reply = '';
|
|
2736
3114
|
paint({ type: 'start', name: t.name, color: t.color, detail: t.liveStatus || 'waiting on model…' });
|
|
2737
3115
|
// Transient: the nudge is appended for THIS call only and never pushed into
|
|
@@ -2748,7 +3126,8 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2748
3126
|
if (t.todos?.length) {
|
|
2749
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')}` });
|
|
2750
3128
|
}
|
|
2751
|
-
|
|
3129
|
+
extras.push({ role: 'system', content: CHAT_NOT_PROXY });
|
|
3130
|
+
if (t.runMode === 'auto' && !claudeFallback) extras.push({ role: 'system', content: AUTO_DIRECTIVE });
|
|
2752
3131
|
const callMsgs = extras.length ? [...t.messages, ...extras] : t.messages;
|
|
2753
3132
|
// WHICH MODEL SERVES THIS TURN.
|
|
2754
3133
|
// /model <id> pins one explicitly and always wins — an explicit choice is
|
|
@@ -2763,18 +3142,34 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2763
3142
|
thread: t, attempt, userText, messages: callMsgs,
|
|
2764
3143
|
}) ?? '').trim();
|
|
2765
3144
|
}
|
|
2766
|
-
const emit = (delta, meta) =>
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
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
|
+
};
|
|
2771
3156
|
const emitStatus = (detail) => paint({ type: 'status', name: t.name, color: t.color, detail });
|
|
2772
|
-
//
|
|
2773
|
-
//
|
|
2774
|
-
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);
|
|
2775
3160
|
const race = Math.min(Number(t.race) || 0, 4);
|
|
2776
3161
|
if (!t.model && race >= 2) {
|
|
2777
|
-
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);
|
|
2778
3173
|
// need = how many must come BACK before judging. need 1 is a plain
|
|
2779
3174
|
// first-past-the-post race; need N waits for all of them. The point of
|
|
2780
3175
|
// the middle (2 of 3) is a judged answer without the slowest entrant
|
|
@@ -2789,7 +3184,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2789
3184
|
})).trim();
|
|
2790
3185
|
}
|
|
2791
3186
|
// A retry draws a DIFFERENT model from the tier rather than the same one.
|
|
2792
|
-
const model = t.model ||
|
|
3187
|
+
const model = t.model || 'openzoo/auto';
|
|
2793
3188
|
return (onEvent
|
|
2794
3189
|
? (await brainStream(callMsgs, emit, t.contextId, model, undefined, 0, topK, emitStatus)).trim()
|
|
2795
3190
|
: (await brain(callMsgs, t.contextId, model, topK)).trim());
|
|
@@ -2827,20 +3222,21 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2827
3222
|
reply = `error: ${e.message}`;
|
|
2828
3223
|
}
|
|
2829
3224
|
if (!stillMine()) return;
|
|
2830
|
-
|
|
3225
|
+
const settled = takeThink(reply);
|
|
3226
|
+
reply = settled.text;
|
|
2831
3227
|
lastReply = reply;
|
|
2832
3228
|
t.messages.push({ role: 'assistant', content: reply });
|
|
2833
3229
|
const runCmd = parseRun(reply);
|
|
2834
3230
|
if (runCmd) {
|
|
2835
3231
|
const command = runCmd;
|
|
2836
|
-
if (t.runMode === 'auto') {
|
|
3232
|
+
if (t.runMode === 'auto' && !claudeFallback) {
|
|
2837
3233
|
emitLiveStatus(t.id, paint, peekDirectiveStatus('', command));
|
|
2838
3234
|
const output = await execCommand(command, dirFor(t.id));
|
|
2839
3235
|
noteRunForCorpus(t.id, command, { cwd: dirFor(t.id) });
|
|
2840
3236
|
if (!stillMine()) return;
|
|
2841
3237
|
const shown = `$ ${command}\n${output}`;
|
|
2842
|
-
t.history.push({ who: 'bot', text:
|
|
2843
|
-
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 });
|
|
2844
3240
|
// FEED THE OUTPUT BACK. The 'ask' path already does this on approve, so
|
|
2845
3241
|
// auto mode was strictly LESS capable than the gated one: the command
|
|
2846
3242
|
// ran, the result was shown, and the model never saw it — no diagnosis,
|
|
@@ -2865,8 +3261,8 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2865
3261
|
{
|
|
2866
3262
|
const runId = randomUUID();
|
|
2867
3263
|
t.pendingRun = { runId, command, cwd: dirFor(t.id) };
|
|
2868
|
-
t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending' });
|
|
2869
|
-
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 });
|
|
2870
3266
|
}
|
|
2871
3267
|
parked = true;
|
|
2872
3268
|
return;
|
|
@@ -2874,8 +3270,8 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2874
3270
|
const ack = await tryDirective(reply, t.id, paint);
|
|
2875
3271
|
if (!stillMine()) return;
|
|
2876
3272
|
const finalText = ack ?? (reply || '(no response)');
|
|
2877
|
-
t.history.push({ who: 'bot', text: finalText });
|
|
2878
|
-
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 });
|
|
2879
3275
|
|
|
2880
3276
|
// AUTO CONTINUES AFTER *ANY* DIRECTIVE, not just RUN.
|
|
2881
3277
|
//
|
|
@@ -2888,7 +3284,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2888
3284
|
//
|
|
2889
3285
|
// Same budget as RUN (shared t.autoSteps, reset when the user speaks), so
|
|
2890
3286
|
// this cannot spend more than a chained RUN loop already could.
|
|
2891
|
-
if (t.runMode === 'auto' && ack !== null && ack !== undefined
|
|
3287
|
+
if (t.runMode === 'auto' && !claudeFallback && ack !== null && ack !== undefined
|
|
2892
3288
|
&& (isEmptyDirectiveAck(ack) || !isDoneReply(reply))) {
|
|
2893
3289
|
chained = enqueueAutoHop(
|
|
2894
3290
|
t, threadId,
|
|
@@ -2912,7 +3308,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2912
3308
|
// NUDGE announcements (stronger than a bare continue). Plain replies used
|
|
2913
3309
|
// to park here; they now fall through to AUTO_CONTINUE unless DONE: or a
|
|
2914
3310
|
// real blocking question. The step cap is the wallet bound.
|
|
2915
|
-
if (t.runMode === 'auto' && (ack === null || ack === undefined)
|
|
3311
|
+
if (t.runMode === 'auto' && !claudeFallback && (ack === null || ack === undefined)
|
|
2916
3312
|
&& (STALLED_OFFER.test(reply) || ANNOUNCEMENT.test(reply))) {
|
|
2917
3313
|
// Offers and "Spawned X — working on it" with no directive must not end
|
|
2918
3314
|
// the run. The old once-only autoNudged gate parked the thread after one
|
|
@@ -2924,7 +3320,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2924
3320
|
|
|
2925
3321
|
// After any auto reply that is not DONE: and not waiting on approval,
|
|
2926
3322
|
// kick immediately. Race/empty/error uses AUTO_RACE_RETRY.
|
|
2927
|
-
if (shouldKeepAuto(t, reply, userText)) {
|
|
3323
|
+
if (!claudeFallback && shouldKeepAuto(t, reply, userText)) {
|
|
2928
3324
|
chained = enqueueAutoHop(t, threadId, autoHopText(reply, userText), onEvent);
|
|
2929
3325
|
return;
|
|
2930
3326
|
}
|
|
@@ -2934,7 +3330,7 @@ async function runTurn(threadId, userText, onEvent, images) {
|
|
|
2934
3330
|
// ask mode, 402/empty-wallet, or the hard cap. Empty /(no output) is not
|
|
2935
3331
|
// DONE — AUTO_EMPTY_RETRY. Otherwise kick again.
|
|
2936
3332
|
if (stillMine() && !chained && !parked) {
|
|
2937
|
-
if (shouldKeepAuto(t, lastReply, userText)) {
|
|
3333
|
+
if (!usedClaude && !claudeFallback && shouldKeepAuto(t, lastReply, userText)) {
|
|
2938
3334
|
enqueueAutoHop(t, threadId, autoHopText(lastReply, userText), onEvent);
|
|
2939
3335
|
} else if (!t.pendingRun) {
|
|
2940
3336
|
t.status = 'idle';
|
|
@@ -3063,7 +3459,8 @@ function subtreeOf(id, includeSelf = false) {
|
|
|
3063
3459
|
}
|
|
3064
3460
|
|
|
3065
3461
|
function threadSummary(t) {
|
|
3066
|
-
const
|
|
3462
|
+
const vis = visibleHistory(t.history);
|
|
3463
|
+
const last = vis[vis.length - 1];
|
|
3067
3464
|
return { id: t.id, name: t.name, color: t.color, parent: t.parent, status: t.status,
|
|
3068
3465
|
preview: last ? (last.who === 'user' ? last.text : last.text).slice(0, 60) : '',
|
|
3069
3466
|
createdAt: t.createdAt, lastActivityAt: t.lastActivityAt || t.createdAt,
|
|
@@ -3095,19 +3492,19 @@ const APP_HTML = `<!doctype html>
|
|
|
3095
3492
|
<style>
|
|
3096
3493
|
:root { color-scheme: dark; }
|
|
3097
3494
|
* { box-sizing: border-box; }
|
|
3098
|
-
html, body { margin: 0; height: 100%; background: #000; }
|
|
3495
|
+
html, body { margin: 0; height: 100%; width: 100%; overflow: hidden; background: #000; }
|
|
3099
3496
|
body { color: #ececec; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
3100
3497
|
display: flex; }
|
|
3101
3498
|
#dragbar { -webkit-app-region: drag; position: fixed; top: 0; left: 0; right: 0; height: 28px; z-index: 1000; }
|
|
3102
3499
|
#sidebar { width: 280px; flex: 0 0 280px; border-right: 1px solid #1c1c1e; display: flex; flex-direction: column;
|
|
3103
|
-
height:
|
|
3500
|
+
height: 100%; padding-top: 28px; min-height: 0; position: relative; }
|
|
3104
3501
|
#main { padding-top: 28px; }
|
|
3105
3502
|
#sideTop { display: flex; align-items: center; gap: 4px; padding: 0 8px; }
|
|
3106
3503
|
#sideTop #search { flex: 1; }
|
|
3107
3504
|
#search { margin: 12px; padding: 8px 12px; background: #1c1c1e; border-radius: 10px; color: #ececec;
|
|
3108
3505
|
border: none; font: inherit; }
|
|
3109
3506
|
#search::placeholder { color: #8e8e93; }
|
|
3110
|
-
#threads { flex: 1; overflow-y: auto; }
|
|
3507
|
+
#threads { flex: 1; min-height: 0; overflow-y: auto; }
|
|
3111
3508
|
.trow { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; border-radius: 10px;
|
|
3112
3509
|
margin: 0 6px 2px; }
|
|
3113
3510
|
/* PROJECT HEADER. The tree indentation shows who spawned whom, but there was
|
|
@@ -3172,7 +3569,7 @@ const APP_HTML = `<!doctype html>
|
|
|
3172
3569
|
@media (prefers-reduced-motion: reduce) {
|
|
3173
3570
|
.twarn, .bot-pfp .bot-bob, .bot-pfp .bot-eyes { animation: none; }
|
|
3174
3571
|
}
|
|
3175
|
-
#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%; }
|
|
3176
3573
|
#chatHeader { padding: 14px 20px; border-bottom: 1px solid #1c1c1e; display: flex; align-items: center;
|
|
3177
3574
|
flex-wrap: wrap; gap: 8px 10px; font-weight: 600; }
|
|
3178
3575
|
#chatHeader .tavatar { width: 26px; height: 26px; border-radius: 50%; font-size: 11px; flex: 0 0 26px; }
|
|
@@ -3299,6 +3696,21 @@ const APP_HTML = `<!doctype html>
|
|
|
3299
3696
|
#hud { position: absolute; right: 14px; width: 270px; background: rgba(14,14,17,.94);
|
|
3300
3697
|
border: 1px solid #333340; border-radius: 10px; padding: 12px 14px; font: 11px/1.5 Menlo, monospace;
|
|
3301
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; }
|
|
3302
3714
|
#hud.show { display: block; }
|
|
3303
3715
|
#hud .htitle { color: #b8f240; font-size: 10px; letter-spacing: .04em; margin-bottom: 10px; }
|
|
3304
3716
|
#hud .htitle.hsession { margin-top: 12px; padding-top: 10px; border-top: 1px solid #333340; color: #6f7080; }
|
|
@@ -3316,8 +3728,8 @@ const APP_HTML = `<!doctype html>
|
|
|
3316
3728
|
color: #f0c9a8; font-size: 10.5px; line-height: 1.45; }
|
|
3317
3729
|
#hud .hhint.show { display: block; }
|
|
3318
3730
|
#hud .hhint b { color: #f28c4d; font-weight: 600; }
|
|
3319
|
-
#sidebar, #main, #walletOverlay, #sitrepOverlay, #composeOverlay,
|
|
3320
|
-
#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 {
|
|
3321
3733
|
-webkit-app-region: no-drag; }
|
|
3322
3734
|
#log { flex: 1; min-width: 0; overflow-y: auto; overflow-x: hidden; padding: 20px 24px 12px;
|
|
3323
3735
|
display: flex; flex-direction: column; gap: 6px;
|
|
@@ -3399,6 +3811,18 @@ const APP_HTML = `<!doctype html>
|
|
|
3399
3811
|
}
|
|
3400
3812
|
.runcard { background: #1c1c1e; border: 1px solid #333; border-radius: 14px; padding: 12px 14px;
|
|
3401
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; }
|
|
3402
3826
|
.runcmd { font-family: Menlo, monospace; font-size: 12.5px; color: #ececec; white-space: pre-wrap;
|
|
3403
3827
|
word-break: break-word; margin-bottom: 8px; }
|
|
3404
3828
|
.runactions { display: flex; gap: 8px; }
|
|
@@ -3411,6 +3835,34 @@ const APP_HTML = `<!doctype html>
|
|
|
3411
3835
|
word-break: break-word; max-height: 240px; overflow-y: auto; margin: 0; }
|
|
3412
3836
|
.row.user .bubble { background: #57575c; }
|
|
3413
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; }
|
|
3414
3866
|
.row.bot.pending .bubble { color: #8e8e93; }
|
|
3415
3867
|
.dots span { display: inline-block; width: 5px; height: 5px; margin-right: 3px; border-radius: 50%;
|
|
3416
3868
|
background: #8e8e93; animation: blink 1.2s infinite ease-in-out; }
|
|
@@ -3535,6 +3987,23 @@ const APP_HTML = `<!doctype html>
|
|
|
3535
3987
|
transition: opacity .14s ease, transform .14s ease;
|
|
3536
3988
|
}
|
|
3537
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; }
|
|
3538
4007
|
</style></head>
|
|
3539
4008
|
<body>
|
|
3540
4009
|
<div id="copiedToast" role="status" aria-live="polite">copied</div>
|
|
@@ -3547,6 +4016,13 @@ const APP_HTML = `<!doctype html>
|
|
|
3547
4016
|
<input id="search" placeholder="Search">
|
|
3548
4017
|
</div>
|
|
3549
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>
|
|
3550
4026
|
</div>
|
|
3551
4027
|
<div id="composeOverlay">
|
|
3552
4028
|
<div id="composeBox">
|
|
@@ -3607,9 +4083,10 @@ const APP_HTML = `<!doctype html>
|
|
|
3607
4083
|
title="Shell commands run immediately, with no approval prompt">auto</button>
|
|
3608
4084
|
</div>
|
|
3609
4085
|
<select class="dial" id="tierSel" data-component="model-tier" aria-label="Model tier"
|
|
3610
|
-
title="
|
|
4086
|
+
title="Auto = cheapest model that clears the bar. Other tiers only apply to /race.">
|
|
4087
|
+
<option value="auto" selected>auto</option>
|
|
3611
4088
|
<option value="cheap">cheap</option>
|
|
3612
|
-
<option value="medium"
|
|
4089
|
+
<option value="medium">medium</option>
|
|
3613
4090
|
<option value="expensive">expensive</option>
|
|
3614
4091
|
<option value="grok4.6">grok 4.6</option>
|
|
3615
4092
|
</select>
|
|
@@ -3647,6 +4124,13 @@ const APP_HTML = `<!doctype html>
|
|
|
3647
4124
|
<div class="hfoot" id="hFoot">loading…</div>
|
|
3648
4125
|
</div>
|
|
3649
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>
|
|
3650
4134
|
<div id="bar">
|
|
3651
4135
|
<div id="plusMenu">
|
|
3652
4136
|
<div class="pop-item" id="attachBtn">
|
|
@@ -3678,6 +4162,17 @@ const APP_HTML = `<!doctype html>
|
|
|
3678
4162
|
</div>
|
|
3679
4163
|
</div>
|
|
3680
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
|
+
}
|
|
3681
4176
|
const threadsEl = document.getElementById('threads');
|
|
3682
4177
|
const chatHeader = document.getElementById('chatHeader');
|
|
3683
4178
|
const log = document.getElementById('log');
|
|
@@ -4008,7 +4503,7 @@ const APP_HTML = `<!doctype html>
|
|
|
4008
4503
|
const tierSel = document.getElementById('tierSel');
|
|
4009
4504
|
const raceSel = document.getElementById('raceSel');
|
|
4010
4505
|
if (!tierSel || !raceSel) return;
|
|
4011
|
-
tierSel.value = t.tier || '
|
|
4506
|
+
tierSel.value = t.tier || 'auto';
|
|
4012
4507
|
raceSel.value = (t.race || 0) < 2 ? '0'
|
|
4013
4508
|
: ((t.raceNeed || 1) > 1 ? t.raceNeed + ' ' + t.race : String(t.race));
|
|
4014
4509
|
// A pinned /model makes BOTH dials inert. Showing them live while they do
|
|
@@ -4031,15 +4526,18 @@ const APP_HTML = `<!doctype html>
|
|
|
4031
4526
|
}
|
|
4032
4527
|
}
|
|
4033
4528
|
|
|
4034
|
-
|
|
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) {
|
|
4035
4532
|
if (!activeId) return;
|
|
4036
|
-
// Reuses the SAME slash-command path, so there is one implementation of the
|
|
4037
|
-
// rule rather than a second that can disagree with it.
|
|
4038
4533
|
await fetch(API + '/drive', { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
4039
|
-
body: JSON.stringify({ threadId: activeId, task:
|
|
4534
|
+
body: JSON.stringify({ threadId: activeId, task: task }) });
|
|
4040
4535
|
await loadThreads();
|
|
4041
4536
|
await render();
|
|
4042
4537
|
}
|
|
4538
|
+
async function setDial(cmd, value) {
|
|
4539
|
+
await echoSlash('/' + cmd + ' ' + value);
|
|
4540
|
+
}
|
|
4043
4541
|
// WALLET MODAL.
|
|
4044
4542
|
//
|
|
4045
4543
|
// Public addresses and balances only — /wallet proxies the proxy's own
|
|
@@ -4066,7 +4564,13 @@ const APP_HTML = `<!doctype html>
|
|
|
4066
4564
|
return row;
|
|
4067
4565
|
}
|
|
4068
4566
|
function isEmptyWalletPayment(text) {
|
|
4069
|
-
|
|
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');
|
|
4070
4574
|
}
|
|
4071
4575
|
var openedPayForEmpty = false;
|
|
4072
4576
|
function maybeOpenPayForEmptyWallet(text) {
|
|
@@ -4287,7 +4791,10 @@ const APP_HTML = `<!doctype html>
|
|
|
4287
4791
|
setSubNote('Subscription key removed. Wallet/x402 is the pay method again.');
|
|
4288
4792
|
await openWallet();
|
|
4289
4793
|
}
|
|
4290
|
-
document.getElementById('walletBtn').addEventListener('click',
|
|
4794
|
+
document.getElementById('walletBtn').addEventListener('click', () => {
|
|
4795
|
+
echoSlash('/pay');
|
|
4796
|
+
openWallet();
|
|
4797
|
+
});
|
|
4291
4798
|
const subKeyBtn = document.getElementById('subKeyBtn');
|
|
4292
4799
|
if (subKeyBtn) subKeyBtn.addEventListener('click', savePastedSub);
|
|
4293
4800
|
const subForgetBtn = document.getElementById('subForgetBtn');
|
|
@@ -4356,10 +4863,10 @@ const APP_HTML = `<!doctype html>
|
|
|
4356
4863
|
const spent = Number(you.spentUsd) || 0;
|
|
4357
4864
|
const cogs = Number(you.cogsUsd) || 0;
|
|
4358
4865
|
const direct = Number(you.directUsd) || 0;
|
|
4359
|
-
const
|
|
4360
|
-
const
|
|
4361
|
-
|
|
4362
|
-
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'));
|
|
4363
4870
|
const thinking = (full && full.status === 'thinking') || t.status === 'thinking';
|
|
4364
4871
|
const race = (full && full.liveRace) || null;
|
|
4365
4872
|
let flight = 'idle';
|
|
@@ -4376,11 +4883,12 @@ const APP_HTML = `<!doctype html>
|
|
|
4376
4883
|
+ sitrepRow('cwd', cwd)
|
|
4377
4884
|
+ sitrepRow('in flight', flight)
|
|
4378
4885
|
+ '<div class="wlanetitle" style="margin-top:16px">this session</div>'
|
|
4379
|
-
+ sitrepRow('
|
|
4380
|
-
+ sitrepRow('
|
|
4381
|
-
+ 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))))
|
|
4382
4890
|
+ sitrepRow('saved vs naked', saved, savedCls)
|
|
4383
|
-
+ sitrepRow('paid calls', String(you.paidCalls || 0))
|
|
4891
|
+
+ sitrepRow('paid calls', proxyDown ? '—' : String(you.paidCalls || 0))
|
|
4384
4892
|
+ sitrepRow('prepaid', (Number(you.creditUsd) > 0) ? 'yes' : 'no');
|
|
4385
4893
|
}
|
|
4386
4894
|
function closeSitrep() { sitrepOverlay.classList.remove('show'); }
|
|
@@ -4408,22 +4916,44 @@ const APP_HTML = `<!doctype html>
|
|
|
4408
4916
|
setModeButtons(mode); // optimistic: the click should feel instant
|
|
4409
4917
|
// Reuses the SAME "/mode" path the chat command takes, so there is one
|
|
4410
4918
|
// implementation of the rule rather than a second one that can disagree.
|
|
4411
|
-
await
|
|
4412
|
-
body: JSON.stringify({ threadId: activeId, task: '/mode ' + mode }) });
|
|
4413
|
-
await loadThreads(); // refresh runMode + the confirmation line /mode appends
|
|
4414
|
-
await render();
|
|
4919
|
+
await echoSlash('/mode ' + mode);
|
|
4415
4920
|
}
|
|
4416
4921
|
document.getElementById('modeAsk').addEventListener('click', () => setMode('ask'));
|
|
4417
4922
|
document.getElementById('modeAuto').addEventListener('click', () => setMode('auto'));
|
|
4418
4923
|
|
|
4419
4924
|
function escapeHtml(s) { return s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
|
4420
|
-
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) {
|
|
4421
4937
|
s = String(s == null ? '' : s);
|
|
4422
|
-
|
|
4423
|
-
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
|
+
});
|
|
4424
4950
|
s = s.replace(/<\\/think(?:ing)?>/gi, '');
|
|
4425
|
-
return
|
|
4951
|
+
return {
|
|
4952
|
+
visible: s.replace(/^\\n+|\\n+$/g, '').trim(),
|
|
4953
|
+
thinking: bits.join('\\n').replace(/^\\n+|\\n+$/g, '').trim()
|
|
4954
|
+
};
|
|
4426
4955
|
}
|
|
4956
|
+
function stripThinkTags(s) { return splitThinkTags(s).visible; }
|
|
4427
4957
|
function clientWorkspaceUrl(rel) {
|
|
4428
4958
|
if (!workspacePort || !activeId) return '';
|
|
4429
4959
|
rel = String(rel || '').replace(/^\\/+/, '');
|
|
@@ -4806,8 +5336,140 @@ const APP_HTML = `<!doctype html>
|
|
|
4806
5336
|
}
|
|
4807
5337
|
|
|
4808
5338
|
let lastSpeaker = null;
|
|
4809
|
-
|
|
4810
|
-
|
|
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
|
+
}
|
|
4811
5473
|
const speakerKey = who + '|' + name;
|
|
4812
5474
|
if (who === 'bot' && speakerKey !== lastSpeaker) {
|
|
4813
5475
|
const hdr = document.createElement('div');
|
|
@@ -4818,17 +5480,25 @@ const APP_HTML = `<!doctype html>
|
|
|
4818
5480
|
lastSpeaker = speakerKey;
|
|
4819
5481
|
const row = document.createElement('div');
|
|
4820
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
|
+
}
|
|
4821
5494
|
if (run) {
|
|
5495
|
+
const cmd = run.command || text;
|
|
5496
|
+
const st = run.status || 'pending';
|
|
5497
|
+
const pending = st === 'pending' || st === 'running';
|
|
4822
5498
|
const card = document.createElement('div');
|
|
4823
|
-
card.className = 'runcard';
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
cmdEl.textContent = '$ ' + text;
|
|
4827
|
-
// Copy WITHOUT the '$ ' prompt — pasting that into a shell is a syntax
|
|
4828
|
-
// error, and this is the single most re-run thing in the UI.
|
|
4829
|
-
cmdEl.appendChild(copyBtn(() => text, 'copy'));
|
|
4830
|
-
card.appendChild(cmdEl);
|
|
4831
|
-
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') {
|
|
4832
5502
|
const actions = document.createElement('div');
|
|
4833
5503
|
actions.className = 'runactions';
|
|
4834
5504
|
const approve = document.createElement('button');
|
|
@@ -4850,20 +5520,8 @@ const APP_HTML = `<!doctype html>
|
|
|
4850
5520
|
actions.appendChild(approve);
|
|
4851
5521
|
actions.appendChild(deny);
|
|
4852
5522
|
card.appendChild(actions);
|
|
4853
|
-
} else {
|
|
4854
|
-
const status = document.createElement('div');
|
|
4855
|
-
status.className = 'runstatus';
|
|
4856
|
-
status.textContent = run.status === 'running' ? 'Running…' : run.status === 'denied' ? 'Denied' : 'Done';
|
|
4857
|
-
card.appendChild(status);
|
|
4858
|
-
if (run.output) {
|
|
4859
|
-
const out = document.createElement('pre');
|
|
4860
|
-
out.className = 'runoutput';
|
|
4861
|
-
out.textContent = run.output;
|
|
4862
|
-
out.appendChild(copyBtn(() => run.output, 'copy'));
|
|
4863
|
-
card.appendChild(out);
|
|
4864
|
-
}
|
|
4865
5523
|
}
|
|
4866
|
-
|
|
5524
|
+
col.appendChild(card);
|
|
4867
5525
|
} else {
|
|
4868
5526
|
const bubble = document.createElement('div');
|
|
4869
5527
|
bubble.className = 'bubble';
|
|
@@ -4879,6 +5537,7 @@ const APP_HTML = `<!doctype html>
|
|
|
4879
5537
|
}
|
|
4880
5538
|
const textEl = document.createElement('div');
|
|
4881
5539
|
textEl.innerHTML = renderMentions(text);
|
|
5540
|
+
if (live && (!text || text === '…')) bubble.hidden = true;
|
|
4882
5541
|
if (who === 'bot') {
|
|
4883
5542
|
const preview = htmlPreviewUrl(text);
|
|
4884
5543
|
if (preview) textEl.appendChild(previewFrame(preview, htmlPreviewKey(text, preview)));
|
|
@@ -4893,7 +5552,7 @@ const APP_HTML = `<!doctype html>
|
|
|
4893
5552
|
maybeOpenPayForEmptyWallet(text);
|
|
4894
5553
|
}
|
|
4895
5554
|
bubble.appendChild(textEl);
|
|
4896
|
-
|
|
5555
|
+
col.appendChild(bubble);
|
|
4897
5556
|
// Copy the message SOURCE, not rendered HTML — markdown, code fences and
|
|
4898
5557
|
// directive lines are what people want back; innerText drops fences and
|
|
4899
5558
|
// mangles indentation.
|
|
@@ -4909,6 +5568,7 @@ const APP_HTML = `<!doctype html>
|
|
|
4909
5568
|
}, 'copy'));
|
|
4910
5569
|
}
|
|
4911
5570
|
}
|
|
5571
|
+
row.appendChild(col);
|
|
4912
5572
|
log.appendChild(row);
|
|
4913
5573
|
}
|
|
4914
5574
|
|
|
@@ -4921,10 +5581,11 @@ const APP_HTML = `<!doctype html>
|
|
|
4921
5581
|
const full = await loadActiveMessages();
|
|
4922
5582
|
if (!full || full.id !== activeId) return;
|
|
4923
5583
|
const renderKey = String(workspacePort) + '|' + full.id + '|' + full.status + '|' + (full.history || []).map(function (h) {
|
|
4924
|
-
return [h.who, h.text, h.runStatus, h.runOutput, (h.images || []).join(',')].join('|#');
|
|
4925
|
-
}).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 || '');
|
|
4926
5587
|
if (renderKey === lastRenderKey) {
|
|
4927
|
-
if (streamBuf) paintStream();
|
|
5588
|
+
if (streamBuf || streamThink) paintStream();
|
|
4928
5589
|
return;
|
|
4929
5590
|
}
|
|
4930
5591
|
lastRenderKey = renderKey;
|
|
@@ -4934,9 +5595,33 @@ const APP_HTML = `<!doctype html>
|
|
|
4934
5595
|
parkedPreviews = parkPreviews();
|
|
4935
5596
|
log.innerHTML = '';
|
|
4936
5597
|
lastSpeaker = null;
|
|
5598
|
+
const seenUser = {};
|
|
4937
5599
|
for (const h of full.history) {
|
|
4938
|
-
|
|
4939
|
-
|
|
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);
|
|
4940
5625
|
}
|
|
4941
5626
|
if (full.status === 'thinking') {
|
|
4942
5627
|
if (full.liveStatus) streamStatus = full.liveStatus;
|
|
@@ -4946,19 +5631,21 @@ const APP_HTML = `<!doctype html>
|
|
|
4946
5631
|
} else if (streamRaceId !== full.id) {
|
|
4947
5632
|
streamRace = null;
|
|
4948
5633
|
}
|
|
4949
|
-
addRow('bot', streamBuf || '…', t.color, t.name);
|
|
5634
|
+
addRow('bot', streamBuf || '…', t.color, t.name, undefined, undefined, streamThink, true);
|
|
4950
5635
|
// Tag the live bubble so deltas can repaint just this node instead of
|
|
4951
5636
|
// re-rendering (and re-fetching) the whole thread on every token.
|
|
4952
5637
|
const b = log.querySelector('.row:last-child .bubble');
|
|
4953
5638
|
if (b) { b.id = 'streamBubble'; paintStream(); }
|
|
4954
5639
|
}
|
|
4955
5640
|
if (wasNearBottom) log.scrollTop = log.scrollHeight;
|
|
5641
|
+
if (findBarOpen()) applyFind(true);
|
|
4956
5642
|
}
|
|
4957
5643
|
|
|
4958
5644
|
// --- live token stream ---------------------------------------------------
|
|
4959
5645
|
// The server has always been able to stream; /drive just never asked for it,
|
|
4960
5646
|
// so a turn showed "…" for its whole duration and then arrived in one lump.
|
|
4961
5647
|
let streamBuf = '';
|
|
5648
|
+
let streamThink = '';
|
|
4962
5649
|
let streamStatus = '';
|
|
4963
5650
|
let streamRace = null;
|
|
4964
5651
|
let streamRaceId = '';
|
|
@@ -5012,44 +5699,117 @@ const APP_HTML = `<!doctype html>
|
|
|
5012
5699
|
return '<div class="racewrap"><div class="racecaption">' + escapeHtml(caption) + '</div>'
|
|
5013
5700
|
+ '<div class="racegrid n' + n + '">' + cells + '</div>' + judge + '</div>';
|
|
5014
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
|
+
}
|
|
5015
5708
|
function liveBubbleHtml() {
|
|
5016
5709
|
if (raceIsLive(streamRace)) return raceGridHtml(streamRace);
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
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');
|
|
5021
5725
|
}
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
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';
|
|
5025
5764
|
}
|
|
5026
5765
|
function paintStream() {
|
|
5027
|
-
|
|
5028
|
-
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);
|
|
5029
5780
|
if (raceIsLive(streamRace)) {
|
|
5781
|
+
b.hidden = false;
|
|
5030
5782
|
b.classList.add('raceboard');
|
|
5031
5783
|
b.innerHTML = liveBubbleHtml();
|
|
5032
|
-
if (
|
|
5784
|
+
if (wasNearBottom || followLive) pinLogBottom();
|
|
5785
|
+
scheduleFindPaint();
|
|
5033
5786
|
return;
|
|
5034
5787
|
}
|
|
5035
5788
|
b.classList.remove('raceboard');
|
|
5036
|
-
//
|
|
5037
|
-
|
|
5038
|
-
|
|
5039
|
-
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);
|
|
5040
5793
|
} else {
|
|
5794
|
+
b.hidden = true;
|
|
5041
5795
|
b.innerHTML = liveBubbleHtml();
|
|
5042
5796
|
}
|
|
5043
|
-
if (
|
|
5797
|
+
if (wasNearBottom || followLive) pinLogBottom();
|
|
5798
|
+
scheduleFindPaint();
|
|
5044
5799
|
}
|
|
5045
5800
|
function connectStream(id) {
|
|
5046
5801
|
if (!id || esId === id) return;
|
|
5047
5802
|
if (es) es.close();
|
|
5048
5803
|
esId = id;
|
|
5049
5804
|
streamBuf = '';
|
|
5805
|
+
streamThink = '';
|
|
5806
|
+
streamTools = [];
|
|
5050
5807
|
streamStatus = '';
|
|
5051
5808
|
streamRace = null;
|
|
5052
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;
|
|
5053
5813
|
raceHandoff += 1;
|
|
5054
5814
|
es = new EventSource('/stream/' + id); // EventSource reconnects on its own
|
|
5055
5815
|
es.onmessage = (e) => {
|
|
@@ -5057,9 +5817,17 @@ const APP_HTML = `<!doctype html>
|
|
|
5057
5817
|
try { ev = JSON.parse(e.data); } catch { return; }
|
|
5058
5818
|
if (ev.type === 'start') {
|
|
5059
5819
|
streamBuf = '';
|
|
5820
|
+
streamThink = '';
|
|
5821
|
+
streamTools = [];
|
|
5060
5822
|
streamStatus = ev.detail || 'waiting on model…';
|
|
5061
5823
|
streamRace = null;
|
|
5062
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);
|
|
5063
5831
|
paintStream();
|
|
5064
5832
|
}
|
|
5065
5833
|
else if (ev.type === 'status') { streamStatus = ev.detail || streamStatus; paintStream(); }
|
|
@@ -5070,8 +5838,14 @@ const APP_HTML = `<!doctype html>
|
|
|
5070
5838
|
}
|
|
5071
5839
|
paintStream();
|
|
5072
5840
|
}
|
|
5841
|
+
else if (ev.type === 'think') {
|
|
5842
|
+
streamThink = ev.replace ? (ev.delta || '') : streamThink + (ev.delta || '');
|
|
5843
|
+
paintStream();
|
|
5844
|
+
}
|
|
5073
5845
|
else if (ev.type === 'delta') {
|
|
5074
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 = '';
|
|
5075
5849
|
paintStream();
|
|
5076
5850
|
}
|
|
5077
5851
|
else if (ev.type === 'final' || ev.type === 'run-pending') {
|
|
@@ -5081,6 +5855,8 @@ const APP_HTML = `<!doctype html>
|
|
|
5081
5855
|
setTimeout(function () {
|
|
5082
5856
|
if (token !== raceHandoff) return;
|
|
5083
5857
|
streamBuf = '';
|
|
5858
|
+
streamThink = '';
|
|
5859
|
+
streamTools = [];
|
|
5084
5860
|
streamStatus = '';
|
|
5085
5861
|
streamRace = null;
|
|
5086
5862
|
render();
|
|
@@ -5088,6 +5864,8 @@ const APP_HTML = `<!doctype html>
|
|
|
5088
5864
|
return;
|
|
5089
5865
|
}
|
|
5090
5866
|
streamBuf = '';
|
|
5867
|
+
streamThink = '';
|
|
5868
|
+
streamTools = [];
|
|
5091
5869
|
streamStatus = '';
|
|
5092
5870
|
streamRace = null;
|
|
5093
5871
|
render();
|
|
@@ -5148,15 +5926,22 @@ const APP_HTML = `<!doctype html>
|
|
|
5148
5926
|
|
|
5149
5927
|
async function submit() {
|
|
5150
5928
|
const task = inp.value.trim();
|
|
5151
|
-
|
|
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 ')) {
|
|
5152
5934
|
inp.value = '';
|
|
5153
5935
|
send.classList.remove('show');
|
|
5154
5936
|
openSitrep();
|
|
5155
5937
|
return;
|
|
5156
5938
|
}
|
|
5157
5939
|
if ((!task && !pendingFiles.length && !pendingImages.length) || !activeId) return;
|
|
5158
|
-
|
|
5159
|
-
|
|
5940
|
+
if (sending) return;
|
|
5941
|
+
sending = true;
|
|
5942
|
+
const draft = inp.value;
|
|
5943
|
+
const hadFiles = pendingFiles.slice();
|
|
5944
|
+
const hadImages = pendingImages.slice();
|
|
5160
5945
|
let full = task;
|
|
5161
5946
|
// an image with no caption still needs SOME text — an empty text block
|
|
5162
5947
|
// alongside image_url content gets rejected (400) by at least one path
|
|
@@ -5167,13 +5952,39 @@ const APP_HTML = `<!doctype html>
|
|
|
5167
5952
|
: '\\n\\n(attached binary file: ' + f.name + ', ' + f.size + ' bytes — content not readable as text)';
|
|
5168
5953
|
}
|
|
5169
5954
|
const images = pendingImages.map((i) => i.dataUrl);
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
|
|
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;
|
|
5177
5988
|
render();
|
|
5178
5989
|
}
|
|
5179
5990
|
|
|
@@ -5366,26 +6177,196 @@ const APP_HTML = `<!doctype html>
|
|
|
5366
6177
|
// It cannot change the version baked into the IMAGE — only the site's spawn
|
|
5367
6178
|
// path can — so it says restart, not update. Promising an upgrade it cannot
|
|
5368
6179
|
// deliver is how a UI teaches people to distrust it.
|
|
5369
|
-
// Cmd/Ctrl+K ->
|
|
5370
|
-
//
|
|
5371
|
-
//
|
|
5372
|
-
//
|
|
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.
|
|
5373
6184
|
document.addEventListener('keydown', (e) => {
|
|
5374
6185
|
const k = (e.key || '').toLowerCase();
|
|
5375
|
-
|
|
6186
|
+
const withMod = e.metaKey || e.ctrlKey;
|
|
6187
|
+
if (withMod && k === 'k') {
|
|
5376
6188
|
e.preventDefault();
|
|
5377
6189
|
const el = document.getElementById('search');
|
|
5378
6190
|
if (el) { el.focus(); el.select(); }
|
|
5379
6191
|
return;
|
|
5380
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
|
+
}
|
|
5381
6208
|
// Cmd/Ctrl+Enter sends from anywhere — useful when focus drifted into a
|
|
5382
6209
|
// RUN card or a copy button mid-thought.
|
|
5383
|
-
if (
|
|
6210
|
+
if (withMod && k === 'enter') {
|
|
5384
6211
|
e.preventDefault();
|
|
5385
6212
|
try { submit(); } catch (err) { /* not ready */ }
|
|
5386
6213
|
}
|
|
5387
6214
|
});
|
|
5388
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
|
+
|
|
5389
6370
|
const reloadBtn = document.getElementById('reloadBtn');
|
|
5390
6371
|
if (reloadBtn) {
|
|
5391
6372
|
reloadBtn.addEventListener('click', async () => {
|
|
@@ -5413,6 +6394,42 @@ const APP_HTML = `<!doctype html>
|
|
|
5413
6394
|
const top = chatHeader.getBoundingClientRect().bottom - mainEl.getBoundingClientRect().top + 8;
|
|
5414
6395
|
hud.style.top = Math.max(8, Math.round(top)) + 'px';
|
|
5415
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
|
+
}
|
|
5416
6433
|
function usd(n) {
|
|
5417
6434
|
if (n === null || n === undefined) return '—';
|
|
5418
6435
|
if (n === 0) return '$0';
|
|
@@ -5425,6 +6442,22 @@ const APP_HTML = `<!doctype html>
|
|
|
5425
6442
|
// straight to localhost:8402 would work fine, but routing it through
|
|
5426
6443
|
// our own backend keeps one fetch path if that ever needs to change.
|
|
5427
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
|
+
}
|
|
5428
6461
|
const creditEl = document.getElementById('hCredit');
|
|
5429
6462
|
if (creditEl) creditEl.textContent = (you.creditUsd == null) ? '—' : usd(Number(you.creditUsd) || 0);
|
|
5430
6463
|
const subRow = document.getElementById('hSubRow');
|
|
@@ -5453,13 +6486,15 @@ const APP_HTML = `<!doctype html>
|
|
|
5453
6486
|
const savedEl = document.getElementById('hYouSaved');
|
|
5454
6487
|
const hintEl = document.getElementById('hHint');
|
|
5455
6488
|
if (spent > 0) {
|
|
5456
|
-
const
|
|
6489
|
+
const sav = formatSavingLabel(you);
|
|
6490
|
+
const mult = sav.mult;
|
|
5457
6491
|
// honest either way: >=1x is a real saving vs a naked direct call,
|
|
5458
6492
|
// <1x means you're currently paying MORE than direct would cost —
|
|
5459
6493
|
// don't dress that up as green when it isn't one.
|
|
5460
6494
|
// Asking a bound corpus makes this genuinely large (the counterfactual
|
|
5461
6495
|
// is shipping the WHOLE corpus), so 2dp would read as noise up there.
|
|
5462
|
-
|
|
6496
|
+
// Label the number: "Nx spilled" when bound, else "Nx session".
|
|
6497
|
+
savedEl.textContent = sav.text;
|
|
5463
6498
|
savedEl.className = mult >= 1 ? 'hlime' : 'hember';
|
|
5464
6499
|
// Session direct/spent (never first-call). Ember when cogs > spent —
|
|
5465
6500
|
// house losing. Do not treat race_unused as a user refund.
|
|
@@ -5471,7 +6506,7 @@ const APP_HTML = `<!doctype html>
|
|
|
5471
6506
|
hintEl.className = mult >= 1 ? 'hhint' : 'hhint show';
|
|
5472
6507
|
hintEl.innerHTML = '<b>feed it more.</b> you\\'re billed on the slice actually sent, '
|
|
5473
6508
|
+ 'not the corpus — so the more you bind, the further ahead this gets. '
|
|
5474
|
-
+ '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.';
|
|
5475
6510
|
}
|
|
5476
6511
|
} else {
|
|
5477
6512
|
savedEl.textContent = '—';
|
|
@@ -5483,25 +6518,45 @@ const APP_HTML = `<!doctype html>
|
|
|
5483
6518
|
}
|
|
5484
6519
|
}
|
|
5485
6520
|
document.getElementById('hFoot').textContent = (you.paidCalls || 0) + ' paid calls this session';
|
|
6521
|
+
paintDock(you);
|
|
5486
6522
|
} catch (e) {
|
|
5487
6523
|
document.getElementById('hFoot').textContent = 'error: ' + e.message;
|
|
5488
6524
|
}
|
|
5489
6525
|
}
|
|
5490
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
|
+
}
|
|
5491
6538
|
hudBtn.addEventListener('click', (e) => {
|
|
5492
6539
|
e.stopPropagation();
|
|
5493
6540
|
hud.classList.toggle('show');
|
|
5494
6541
|
if (hud.classList.contains('show')) {
|
|
5495
6542
|
placeHud();
|
|
5496
6543
|
refreshHud();
|
|
5497
|
-
hudTimer = setInterval(refreshHud, 30000);
|
|
5498
|
-
} else if (hudTimer) {
|
|
5499
|
-
clearInterval(hudTimer); hudTimer = null;
|
|
5500
6544
|
}
|
|
6545
|
+
echoSlash('/hud');
|
|
6546
|
+
});
|
|
6547
|
+
refreshHud();
|
|
6548
|
+
ensureHudTick();
|
|
6549
|
+
window.addEventListener('resize', () => {
|
|
6550
|
+
if (hud.classList.contains('show')) placeHud();
|
|
6551
|
+
if (findBarOpen()) placeFindBar();
|
|
5501
6552
|
});
|
|
5502
|
-
|
|
5503
|
-
|
|
5504
|
-
|
|
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
|
+
}
|
|
5505
6560
|
}
|
|
5506
6561
|
document.addEventListener('click', (e) => { if (!hud.contains(e.target)) hud.classList.remove('show'); });
|
|
5507
6562
|
</script>
|
|
@@ -5679,9 +6734,25 @@ const server = http.createServer((req, res) => {
|
|
|
5679
6734
|
|
|
5680
6735
|
if (req.method === 'GET' && req.url === '/hud-summary') {
|
|
5681
6736
|
(async () => {
|
|
5682
|
-
let you = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0, creditUsd: null, chainUsd: null };
|
|
5683
|
-
try {
|
|
5684
|
-
|
|
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);
|
|
5685
6756
|
try {
|
|
5686
6757
|
you.creditUsd = await creditBalance();
|
|
5687
6758
|
} catch { /* credit is advisory */ }
|
|
@@ -5767,7 +6838,7 @@ const server = http.createServer((req, res) => {
|
|
|
5767
6838
|
const t = threads.get(req.url.split('/')[2]);
|
|
5768
6839
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
5769
6840
|
res.end(t ? JSON.stringify({
|
|
5770
|
-
id: t.id, history: t.history, status: t.status,
|
|
6841
|
+
id: t.id, history: visibleHistory(t.history), status: t.status,
|
|
5771
6842
|
liveStatus: t.status === 'thinking' ? (t.liveStatus || '') : '',
|
|
5772
6843
|
liveRace: t.status === 'thinking' ? (t.liveRace || null) : null,
|
|
5773
6844
|
lastRaceFail: t.lastRaceFail || null,
|
|
@@ -5856,28 +6927,39 @@ const server = http.createServer((req, res) => {
|
|
|
5856
6927
|
threadId = j.threadId; task = (j.task || '').toString();
|
|
5857
6928
|
images = Array.isArray(j.images) ? j.images.filter((u) => typeof u === 'string') : [];
|
|
5858
6929
|
} catch { /* ignore */ }
|
|
5859
|
-
|
|
5860
|
-
|
|
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
|
+
};
|
|
5861
6936
|
const t = threads.get(threadId);
|
|
5862
|
-
|
|
5863
|
-
|
|
5864
|
-
|
|
5865
|
-
|
|
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())) {
|
|
5866
6945
|
// Drawer-only. Never dump sitrep into the transcript.
|
|
5867
|
-
if (/^\/sitrep\b/i.test(task.trim())) return;
|
|
5868
|
-
|
|
5869
|
-
|
|
5870
|
-
|
|
5871
|
-
|
|
5872
|
-
|
|
5873
|
-
|
|
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
|
+
}
|
|
5874
6956
|
}
|
|
5875
6957
|
}
|
|
5876
6958
|
// "/dir <path>" is a LOCAL control command, not sent to the model at
|
|
5877
6959
|
// all — free, instant, sets which folder this thread's WRITE/READ/SERVE
|
|
5878
6960
|
// are scoped to. Respecify any time by sending it again.
|
|
5879
6961
|
const dirCmd = /^\/dir\s+(.+)/.exec(task.trim());
|
|
5880
|
-
if (dirCmd
|
|
6962
|
+
if (dirCmd) {
|
|
5881
6963
|
const full = path.resolve(expandHome(dirCmd[1].trim()));
|
|
5882
6964
|
let ok = false;
|
|
5883
6965
|
try { ok = statSync(full).isDirectory(); } catch { /* not a dir / doesn't exist */ }
|
|
@@ -5888,17 +6970,29 @@ const server = http.createServer((req, res) => {
|
|
|
5888
6970
|
t.history.push({ who: 'bot', text: `"${full}" isn't a directory that exists.` });
|
|
5889
6971
|
}
|
|
5890
6972
|
saveThreads();
|
|
6973
|
+
ack(true, { persisted: true });
|
|
5891
6974
|
return;
|
|
5892
6975
|
}
|
|
5893
|
-
// "/mode auto|ask"
|
|
5894
|
-
//
|
|
6976
|
+
// "/mode auto|ask" — Auto is the Claude Code harness (openzoo claude
|
|
6977
|
+
// env). Ask stays a chat completion; RUN: waits for approve/deny.
|
|
5895
6978
|
const modeCmd = /^\/mode\s+(auto|ask)\b/.exec(task.trim());
|
|
5896
|
-
if (modeCmd
|
|
6979
|
+
if (modeCmd) {
|
|
5897
6980
|
t.runMode = modeCmd[1];
|
|
5898
|
-
|
|
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.'}` });
|
|
5899
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 });
|
|
5900
6993
|
return;
|
|
5901
6994
|
}
|
|
6995
|
+
ack(true, { persisted: true });
|
|
5902
6996
|
// Stream to whoever is watching this thread. emitToThread is a no-op
|
|
5903
6997
|
// when nobody is, so a spawned subagent nobody has open costs nothing.
|
|
5904
6998
|
runTurn(threadId, task, (ev) => emitToThread(threadId, ev), images).catch(() => {});
|
|
@@ -5913,12 +7007,16 @@ server.listen(PORT, BIND, () => console.log(`[grokui] http://${BIND === '0.0.0.0
|
|
|
5913
7007
|
|
|
5914
7008
|
export {
|
|
5915
7009
|
tryDirective, ensureWorkspacePort, isPreviewableRel, previewAck, workspaceFileUrl,
|
|
5916
|
-
parseRun, looksLikeMcpAsBash, stripThinkTags, safeResolveIn, inDir, listDir,
|
|
5917
|
-
handleSlash, newThread, setRunTurnForTest, setBrainAskForTest, runTurn,
|
|
7010
|
+
parseRun, looksLikeMcpAsBash, stripThinkTags, takeThink, safeResolveIn, inDir, listDir,
|
|
7011
|
+
handleSlash, isGrokuiOwnedSlash, newThread, setRunTurnForTest, setBrainAskForTest, setClaudeRunnerForTest, runTurn,
|
|
7012
|
+
runAutoClaudeTurn, autoClaudePrompt,
|
|
5918
7013
|
AUTO_CONTINUE, AUTO_RACE_RETRY, AUTO_EMPTY_RETRY, pingWakeText, pingCanWake, shouldKeepAuto,
|
|
5919
7014
|
isDoneReply, isTransientModelFail, isPaymentFailed, isEmptyWalletPayment, isEmptyToolResult, enqueueAutoHop, childKickoff, findByName,
|
|
5920
7015
|
attachChildDir, finishChildDir,
|
|
5921
7016
|
lockWorktree, unlockWorktree, parsePrRef, fetchSpecsForOrigin, agentSlug,
|
|
5922
7017
|
filesForCorpus, noteFileForCorpus, noteRunForCorpus, resetFilesForCorpus,
|
|
5923
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,
|
|
5924
7022
|
};
|