@semalt-ai/code 1.19.0 → 1.20.0
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/.claude/settings.local.json +2 -1
- package/ARCHITECTURE.md +6 -95
- package/CLAUDE.md +196 -1874
- package/README.md +1 -1
- package/docs/ARCHITECTURE.md +1321 -0
- package/docs/CONFIG.md +340 -0
- package/docs/HISTORY.md +245 -0
- package/index.js +1 -1
- package/lib/agent.js +145 -16
- package/lib/api.js +28 -3
- package/lib/commands/chat-session.js +187 -4
- package/lib/commands/chat-slash.js +16 -0
- package/lib/commands/chat-turn.js +272 -49
- package/lib/commands/chat.js +12 -8
- package/lib/config.js +27 -0
- package/lib/constants.js +30 -1
- package/lib/headless.js +36 -1
- package/lib/images.js +8 -2
- package/lib/permissions.js +23 -16
- package/lib/prompts.js +15 -3
- package/lib/tool_registry.js +357 -53
- package/lib/tool_specs.js +42 -8
- package/lib/tools.js +80 -19
- package/lib/ui/anim.js +86 -0
- package/lib/ui/ansi.js +17 -27
- package/lib/ui/chat-history.js +253 -71
- package/lib/ui/create-ui.js +67 -24
- package/lib/ui/diff.js +90 -25
- package/lib/ui/file-activity.js +236 -0
- package/lib/ui/format.js +173 -28
- package/lib/ui/input-field.js +5 -4
- package/lib/ui/md-stream.js +234 -0
- package/lib/ui/render-operation.js +113 -0
- package/lib/ui/select.js +1 -4
- package/lib/ui/status-bar.js +99 -57
- package/lib/ui/stream.js +20 -13
- package/lib/ui/theme.js +190 -45
- package/lib/ui/tool-operation.js +190 -0
- package/lib/ui/utils.js +9 -5
- package/lib/ui/web-activity.js +58 -6
- package/lib/ui/writer.js +159 -45
- package/lib/ui.js +1 -1
- package/package.json +1 -1
- package/test/anim-driver.test.js +153 -0
- package/test/ask-user-display.test.js +226 -0
- package/test/ask-user-gate.test.js +231 -0
- package/test/chat-history-nocolor.test.js +155 -0
- package/test/chat-relogin.test.js +207 -0
- package/test/defer-detail-band.test.js +403 -0
- package/test/detail-band-tab-flatten.test.js +242 -0
- package/test/exec-diff.test.js +268 -0
- package/test/executors.test.js +250 -13
- package/test/extract-tool-calls.test.js +37 -3
- package/test/file-activity.test.js +522 -0
- package/test/grep-path-target.test.js +227 -0
- package/test/harness/chat-harness.js +2 -1
- package/test/headless.test.js +146 -1
- package/test/input-field-ctrl-o.test.js +37 -0
- package/test/live-height-physical.test.js +281 -0
- package/test/max-iterations.test.js +9 -7
- package/test/md-stream.test.js +183 -0
- package/test/native-dispatch.test.js +53 -0
- package/test/native-live-narration.test.js +254 -0
- package/test/output-heredoc-leak.test.js +195 -0
- package/test/output-preview.test.js +245 -0
- package/test/permissions.test.js +199 -0
- package/test/read-paginate.test.js +1 -1
- package/test/render-operation.test.js +317 -0
- package/test/replay-descriptor-xml.test.js +216 -0
- package/test/replay-descriptor.test.js +189 -0
- package/test/replay-web-aggregate.test.js +291 -0
- package/test/replay-web-persist.test.js +241 -0
- package/test/running-glyph-anim.test.js +111 -0
- package/test/status-bar-driver.test.js +93 -0
- package/test/status-bar-resync.test.js +188 -0
- package/test/stream-parser.test.js +24 -0
- package/test/theme-palette.test.js +166 -0
- package/test/truncate-visible.test.js +78 -0
- package/test/view-image.test.js +199 -0
- package/test/web-activity-ordering.test.js +12 -3
- package/path +0 -1
package/lib/api.js
CHANGED
|
@@ -76,6 +76,21 @@ function debugDumpMessages(msgs) {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
// Strip client-only sibling keys from messages right before the wire. Today
|
|
80
|
+
// that is the Phase 6a `_display` descriptor core (persisted on native tool
|
|
81
|
+
// messages for replay fidelity). Returns the array unchanged when no message
|
|
82
|
+
// carries one, so the common path allocates nothing.
|
|
83
|
+
function stripInternalKeys(messages) {
|
|
84
|
+
if (!Array.isArray(messages) || !messages.some((m) => m && m._display !== undefined)) return messages;
|
|
85
|
+
return messages.map((m) => {
|
|
86
|
+
if (m && m._display !== undefined) {
|
|
87
|
+
const { _display, ...rest } = m;
|
|
88
|
+
return rest;
|
|
89
|
+
}
|
|
90
|
+
return m;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
79
94
|
// Fit messages into tokenBudget tokens.
|
|
80
95
|
// Uses chars/4 — aligned with estimateTokens; a deliberate under-estimate
|
|
81
96
|
// for token-dense content (code, JSON, HTML) but consistent across the
|
|
@@ -415,7 +430,7 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
415
430
|
});
|
|
416
431
|
}
|
|
417
432
|
|
|
418
|
-
async function chatStream(messages, { model, temperature, maxTokens, linePrefix = '', showThink = false, onToken = null, silent = false, signal = null, onTrim = null, nativeTools = true } = {}) {
|
|
433
|
+
async function chatStream(messages, { model, temperature, maxTokens, linePrefix = '', showThink = false, onToken = null, onReasoning = null, silent = false, signal = null, onTrim = null, nativeTools = true } = {}) {
|
|
419
434
|
// nativeTools is plumbed through for downstream use (tools param + tool_calls parsing); no behavior change yet.
|
|
420
435
|
const config = getConfig();
|
|
421
436
|
const resolvedModel = model || config.default_model;
|
|
@@ -514,8 +529,10 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
514
529
|
validateToolCallInvariant(msgs);
|
|
515
530
|
// Transform any image-bearing turn into the provider-specific multimodal
|
|
516
531
|
// content[] shape right before the wire (Task 5.4); the internal `images`
|
|
517
|
-
// field never leaves the client.
|
|
518
|
-
|
|
532
|
+
// field never leaves the client. The Phase 6a `_display` descriptor sibling
|
|
533
|
+
// (persisted on native tool messages for replay) is likewise client-only —
|
|
534
|
+
// strip it here so it is never fed to the model.
|
|
535
|
+
const wireMsgs = stripInternalKeys(imagesPresent ? buildProviderMessages(msgs, imageFormat) : msgs);
|
|
519
536
|
const reqPayload = { ...payload, messages: wireMsgs };
|
|
520
537
|
// Optional payload augmentations (Task 2.7): reasoning_effort for models
|
|
521
538
|
// that support it, and prompt-caching markers on the stable prefix when
|
|
@@ -788,6 +805,14 @@ function createApiClient({ getConfig, saveConfig, ui }) {
|
|
|
788
805
|
const uiActive = isUIActive();
|
|
789
806
|
if (!inReasoning) {
|
|
790
807
|
inReasoning = true;
|
|
808
|
+
// Live-narration safety signal (a): the model demonstrably uses
|
|
809
|
+
// the structured reasoning_content channel this turn, so any
|
|
810
|
+
// delta.content that follows is narration, not inlined reasoning.
|
|
811
|
+
// Fire once per stream so the UI can eager-open its live gate on
|
|
812
|
+
// the native rail. Failures here must never break the stream.
|
|
813
|
+
if (typeof onReasoning === 'function') {
|
|
814
|
+
try { onReasoning(); } catch { /* UI signal is best-effort */ }
|
|
815
|
+
}
|
|
791
816
|
if (showThink && !uiActive) {
|
|
792
817
|
// audit: allowed — non-TUI thinking output, interleaves with StreamRenderer sync writes.
|
|
793
818
|
process.stdout.write(`\n ${FG_DARK}${DIM}⟨thinking⟩${RST}`);
|
|
@@ -45,7 +45,12 @@ function createChatSession(ctx) {
|
|
|
45
45
|
const title = firstUserText.length > 60 ? firstUserText.slice(0, 57) + '...' : firstUserText;
|
|
46
46
|
const resp = await dashboardCreateChat(title, config.dashboard_model_id);
|
|
47
47
|
if (resp && resp.chat && resp.chat.id) ctx.currentChatId = resp.chat.id;
|
|
48
|
-
} catch {
|
|
48
|
+
} catch (err) {
|
|
49
|
+
// Surface (don't swallow) a creation failure: a silent failure here leaves
|
|
50
|
+
// currentChatId null, so saveTurnToDashboard early-returns with no warning —
|
|
51
|
+
// quiet data loss. Non-fatal to the turn; the local session save still runs.
|
|
52
|
+
msgs.sysWarn(`could not create dashboard chat: ${err && err.message ? err.message : String(err)}`);
|
|
53
|
+
}
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
async function saveTurnToDashboard() {
|
|
@@ -59,23 +64,180 @@ function createChatSession(ctx) {
|
|
|
59
64
|
msgs.sysWarn(`history save: ${resp.skipped_count} message(s) skipped by server`);
|
|
60
65
|
}
|
|
61
66
|
} catch (err) {
|
|
67
|
+
// A 404 means the chat id is stale for the CURRENT token — the chat belongs
|
|
68
|
+
// to another principal (relogin) or was deleted on the dashboard. Self-heal
|
|
69
|
+
// ONCE per turn: recreate a fresh chat under the current token and re-save
|
|
70
|
+
// the SAME pending slice. Scoped strictly to 404 — a transient network/5xx
|
|
71
|
+
// must NOT recreate the chat (that would spawn duplicates); leave savedUpTo
|
|
72
|
+
// unadvanced so a later turn retries the same slice naturally.
|
|
73
|
+
if (err && err.statusCode === 404) {
|
|
74
|
+
ctx.currentChatId = null;
|
|
75
|
+
const firstUser = ctx.messages.find((m) => m.role === 'user');
|
|
76
|
+
const title = firstUser && typeof firstUser.content === 'string' && firstUser.content
|
|
77
|
+
? firstUser.content : 'Untitled chat';
|
|
78
|
+
await createChatIfNeeded(title); // warns on its own failure (see above)
|
|
79
|
+
if (ctx.currentChatId === null) return; // recreation failed; already warned
|
|
80
|
+
try {
|
|
81
|
+
const resp = await dashboardSaveMessages(ctx.currentChatId, newMessages);
|
|
82
|
+
ctx.savedUpTo = ctx.messages.length; // advance ONLY after re-save succeeds
|
|
83
|
+
if (resp && typeof resp.skipped_count === 'number' && resp.skipped_count > 0) {
|
|
84
|
+
msgs.sysWarn(`history save: ${resp.skipped_count} message(s) skipped by server`);
|
|
85
|
+
}
|
|
86
|
+
} catch (err2) {
|
|
87
|
+
msgs.sysWarn(`history save failed: ${err2 && err2.message ? err2.message : String(err2)}`);
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
62
91
|
msgs.sysWarn(`history save failed: ${err && err.message ? err.message : String(err)}`);
|
|
63
92
|
}
|
|
64
93
|
}
|
|
65
94
|
|
|
66
95
|
function displayLoadedMessages(loadedMessages) {
|
|
67
96
|
chatHistory.clearMessages();
|
|
68
|
-
|
|
69
|
-
|
|
97
|
+
const cfg = getConfig() || {};
|
|
98
|
+
const { descriptorFromStored } = require('../ui/tool-operation');
|
|
99
|
+
const { isWebCore, aggregateWebOps, formatWebSummaryLine } = require('../ui/web-activity');
|
|
100
|
+
const {
|
|
101
|
+
isGroupableFileCore, fileSummaryState, formatFileSummaryLine,
|
|
102
|
+
} = require('../ui/file-activity');
|
|
103
|
+
|
|
104
|
+
// Phase 6c-ii — replayed web activity renders as the aggregated `✓ web · …`
|
|
105
|
+
// committed summary, byte-identical to the live committed line. A web GROUP
|
|
106
|
+
// is a maximal consecutive run of web ops, so the buffer is LOOP-LEVEL (not
|
|
107
|
+
// blob-/message-local): a group spans iterations, and those iterations live
|
|
108
|
+
// in separate {role:'tool'} messages (native rail) or separate
|
|
109
|
+
// {role:'user'} feedback blobs (XML rail). The live flush triggers
|
|
110
|
+
// (chat-turn.js) are mirrored below: a non-web tool starting, a terminal
|
|
111
|
+
// assistant message with content, and turn end (the trailing flushWeb()).
|
|
112
|
+
// flushWeb() calls ONLY the pure aggregateWebOps/formatWebSummaryLine — it
|
|
113
|
+
// never instantiates createWebActivityTracker or touches the live region.
|
|
114
|
+
let webBuf = [];
|
|
115
|
+
function flushWeb() {
|
|
116
|
+
if (!webBuf.length) return;
|
|
117
|
+
const line = formatWebSummaryLine(aggregateWebOps(webBuf), { pending: false });
|
|
118
|
+
chatHistory.addRawLine(line);
|
|
119
|
+
webBuf = [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Parallel re-grouping for consecutive file ops (read_file/list_dir),
|
|
123
|
+
// mirroring flushWeb but with the live tracker's THRESHOLD applied at the
|
|
124
|
+
// REPLAY terminal width: a buffered run of ≥3 commits one aggregated summary
|
|
125
|
+
// (formatFileSummaryLine reads getCols() at flush, so a 200-col session
|
|
126
|
+
// re-groups correctly in an 80-col terminal); a run of 1–2 commits each op as
|
|
127
|
+
// its own per-op line via the SAME `_display` render the live path uses —
|
|
128
|
+
// byte-identical to a fresh per-op commit. read_file and list_dir share ONE
|
|
129
|
+
// group (mirroring the live merged key): a mixed run re-groups into the same
|
|
130
|
+
// single summary, with fileSummaryState picking the homogeneous-vs-mixed verb.
|
|
131
|
+
let fileBuf = []; // [{ core, ts }]
|
|
132
|
+
function flushFile() {
|
|
133
|
+
if (!fileBuf.length) return;
|
|
134
|
+
const buf = fileBuf;
|
|
135
|
+
fileBuf = [];
|
|
136
|
+
if (buf.length >= 3) {
|
|
137
|
+
const line = formatFileSummaryLine(fileSummaryState(buf.map((e) => e.core)), { pending: false });
|
|
138
|
+
chatHistory.addRawLine(line);
|
|
139
|
+
} else {
|
|
140
|
+
for (const { core, ts } of buf) {
|
|
141
|
+
chatHistory.addMessage({
|
|
142
|
+
role: 'tool', tag: 'tool', content: '', ts,
|
|
143
|
+
_display: core,
|
|
144
|
+
diffMaxLines: cfg.diff_max_lines,
|
|
145
|
+
previewLines: cfg.shell_preview_lines || 5,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// pushFile is only reached for groupable file cores (the caller gates on
|
|
151
|
+
// isGroupableFileCore), and read_file + list_dir now share one group, so every
|
|
152
|
+
// buffered op belongs to the same run — no key split. A non-groupable op
|
|
153
|
+
// flushes the buffer via the flushFile() calls on the other branches.
|
|
154
|
+
function pushFile(core, ts) {
|
|
155
|
+
fileBuf.push({ core, ts });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A message that carries tool activity for an in-flight iteration: a native
|
|
159
|
+
// {role:'tool'} result, or an XML {role:'user'} feedback blob. The replay
|
|
160
|
+
// analogue of "an assistant iteration had tool calls" (live: cleanContent==='')
|
|
161
|
+
// is "the assistant is immediately followed by tool activity" — rail-agnostic,
|
|
162
|
+
// and independent of whether `tool_calls` survived the storage round-trip.
|
|
163
|
+
const isToolActivity = (msg) => !!msg && (
|
|
164
|
+
msg.role === 'tool' ||
|
|
165
|
+
(msg.role === 'user' && typeof msg.content === 'string' && msg.content.startsWith('Tool execution results:'))
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const relevant = loadedMessages.filter(
|
|
169
|
+
(m) => m.role === 'user' || m.role === 'assistant' || m.role === 'tool',
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
for (let ri = 0; ri < relevant.length; ri++) {
|
|
173
|
+
const m = relevant[ri];
|
|
70
174
|
const raw = typeof m.content === 'string' ? m.content : '';
|
|
71
175
|
const ts = m.created_at ? new Date(m.created_at) : (m.ts ? new Date(m.ts) : new Date());
|
|
72
176
|
|
|
73
177
|
if (m.role === 'tool') {
|
|
74
|
-
|
|
178
|
+
// Phase 6c-ii — a native web op persists a {v:1,kind:'web',…} core in
|
|
179
|
+
// `_display`. Buffer it into the current web group (no per-op line) and
|
|
180
|
+
// continue; the aggregated summary commits when the group flushes. A
|
|
181
|
+
// non-web tool first ENDS any open web run so its summary lands ABOVE this
|
|
182
|
+
// line (mirrors chat-turn.js:222), then renders via the 6a path.
|
|
183
|
+
if (isWebCore(m._display)) { flushFile(); webBuf.push(m._display); continue; }
|
|
184
|
+
// A groupable file core (read_file/list_dir) buffers into the file group
|
|
185
|
+
// (flushing any open web run first so its summary lands above); the
|
|
186
|
+
// aggregated/per-op commit happens when the file group flushes.
|
|
187
|
+
if (isGroupableFileCore(m._display)) { flushWeb(); pushFile(m._display, ts); continue; }
|
|
188
|
+
flushWeb(); flushFile();
|
|
189
|
+
// Phase 6a — forward the persisted display descriptor (native rail) so
|
|
190
|
+
// chat-history can replay it with full fidelity. `_display` absent →
|
|
191
|
+
// legacy summarizeToolResult fallback. The budgets match the live path
|
|
192
|
+
// (diff_max_lines for an edit diff; shell_preview_lines for an output
|
|
193
|
+
// preview) so a replayed line is byte-identical to a fresh render.
|
|
194
|
+
chatHistory.addMessage({
|
|
195
|
+
role: 'tool', tag: 'tool', content: raw, ts,
|
|
196
|
+
_display: m._display,
|
|
197
|
+
diffMaxLines: cfg.diff_max_lines,
|
|
198
|
+
previewLines: cfg.shell_preview_lines || 5,
|
|
199
|
+
});
|
|
75
200
|
continue;
|
|
76
201
|
}
|
|
77
202
|
|
|
78
203
|
if (m.role === 'user' && raw.startsWith('Tool execution results:')) {
|
|
204
|
+
// Phase 6b — XML rail per-call replay parity. The feedback blob folds all
|
|
205
|
+
// tool results of a turn into one {role:'user'} message; it cannot be
|
|
206
|
+
// split back by parsing (the \n\n separator appears freely inside result
|
|
207
|
+
// bodies). When the persisted aligned `_display[]` array is present AND
|
|
208
|
+
// EVERY slot is a valid core — a normal descriptor core OR (Phase 6c-ii)
|
|
209
|
+
// a web-op core — replay each in order. The gate stays fail-safe: a single
|
|
210
|
+
// `null` slot or an unknown core drops the WHOLE blob to the legacy
|
|
211
|
+
// whole-blob summary below (no partial render), so a web op's activity
|
|
212
|
+
// never silently vanishes on replay.
|
|
213
|
+
const displays = Array.isArray(m._display) ? m._display : null;
|
|
214
|
+
// Phase 6c-ii — flip the 6c-i gate: a web-op core ({v:1,kind:'web',…}) is
|
|
215
|
+
// now a VALID slot (it aggregates into the web summary) instead of failing
|
|
216
|
+
// the gate. Normal slots must still pass descriptorFromStored; web slots
|
|
217
|
+
// must be v:1 web-cores. Any other slot (null / unknown) still fails.
|
|
218
|
+
if (displays && displays.length > 0 && displays.every(
|
|
219
|
+
(el) => el && ((isWebCore(el) && el.v === 1) || (!isWebCore(el) && descriptorFromStored(el))),
|
|
220
|
+
)) {
|
|
221
|
+
for (const el of displays) {
|
|
222
|
+
// A web slot buffers into the open web group; a groupable file slot
|
|
223
|
+
// buffers into the file group; a normal slot first flushes any
|
|
224
|
+
// preceding grouped run (mirrors a non-grouped tool starting), then
|
|
225
|
+
// renders.
|
|
226
|
+
if (isWebCore(el)) { flushFile(); webBuf.push(el); continue; }
|
|
227
|
+
if (isGroupableFileCore(el)) { flushWeb(); pushFile(el, ts); continue; }
|
|
228
|
+
flushWeb(); flushFile();
|
|
229
|
+
chatHistory.addMessage({
|
|
230
|
+
role: 'tool', tag: 'tool', content: '', ts,
|
|
231
|
+
_display: el,
|
|
232
|
+
diffMaxLines: cfg.diff_max_lines,
|
|
233
|
+
previewLines: cfg.shell_preview_lines || 5,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
// Gate failed — a preceding grouped run (if any) ends here, then the legacy
|
|
239
|
+
// whole-blob summary renders (6b/6c-i fail-safe preserved).
|
|
240
|
+
flushWeb(); flushFile();
|
|
79
241
|
const body = raw
|
|
80
242
|
.replace(/^Tool execution results[^\n]*\n+/, '')
|
|
81
243
|
.replace(/\n+Continue with the task\.[\s\S]*$/, '')
|
|
@@ -84,9 +246,30 @@ function createChatSession(ctx) {
|
|
|
84
246
|
continue;
|
|
85
247
|
}
|
|
86
248
|
|
|
249
|
+
// Plain user / assistant message. Empty-content messages never flush (an
|
|
250
|
+
// intermediate, tool-call-only assistant message between two web iterations
|
|
251
|
+
// carries empty display content — flushing there would split a cross-
|
|
252
|
+
// iteration group into two summaries) and are not rendered.
|
|
87
253
|
if (!raw.trim()) continue;
|
|
254
|
+
if (m.role === 'assistant') {
|
|
255
|
+
// Flush ONLY on a TERMINAL assistant message — one not immediately
|
|
256
|
+
// followed by tool activity (the replay analogue of live cleanContent!==''
|
|
257
|
+
// at chat-turn.js:389-391). An intermediate assistant with content is
|
|
258
|
+
// still rendered, but does NOT flush, so the open web group survives.
|
|
259
|
+
if (!isToolActivity(relevant[ri + 1])) { flushWeb(); flushFile(); }
|
|
260
|
+
} else {
|
|
261
|
+
// A plain user message starts a NEW turn — close any grouped run left open
|
|
262
|
+
// by the prior turn (the live per-turn `finally` flush, chat-turn.js).
|
|
263
|
+
flushWeb(); flushFile();
|
|
264
|
+
}
|
|
88
265
|
chatHistory.addMessage({ role: m.role, content: raw, ts });
|
|
89
266
|
}
|
|
267
|
+
|
|
268
|
+
// Turn-end safety net: a trailing grouped run (turn ended/was interrupted
|
|
269
|
+
// right after a web or file op, with no terminal assistant) commits here —
|
|
270
|
+
// mirrors the live per-turn `finally` flush in chat-turn.js.
|
|
271
|
+
flushWeb();
|
|
272
|
+
flushFile();
|
|
90
273
|
}
|
|
91
274
|
|
|
92
275
|
function seedContextFromMessages() {
|
|
@@ -85,6 +85,18 @@ function createSlashHandlers(ctx) {
|
|
|
85
85
|
statusBar.setContextLimit(ctx.resolvedTokenLimit);
|
|
86
86
|
chatHistory.addMessage({ role: 'system', content: `✓ Model → ${picked.name} (${picked.modelId})` });
|
|
87
87
|
}
|
|
88
|
+
// Relogin is a principal change: the new token may belong to a different
|
|
89
|
+
// user, so the surviving currentChatId would otherwise suppress fresh-chat
|
|
90
|
+
// creation and make saveTurnToDashboard POST to a chat the new user can't
|
|
91
|
+
// see (404 "Chat not found"). Reset chat context AFTER the new token is
|
|
92
|
+
// saved (loginFlow) and the model resolved (ensureDefaultModel) so the next
|
|
93
|
+
// turn's createChatIfNeeded lazily creates a fresh chat under the new token.
|
|
94
|
+
// Mirror /new's FULL reset (messages + approvals too): retaining the prior
|
|
95
|
+
// user's messages/approvals across a token change is unsafe. The way back to
|
|
96
|
+
// an old chat is explicit --resume / /chats.
|
|
97
|
+
ctx.messages = [];
|
|
98
|
+
ctx.currentChatId = null; ctx.savedUpTo = 0;
|
|
99
|
+
permissionManager.clear();
|
|
88
100
|
statusBar.update('idle');
|
|
89
101
|
inputField.setDisabled(false);
|
|
90
102
|
},
|
|
@@ -111,6 +123,10 @@ function createSlashHandlers(ctx) {
|
|
|
111
123
|
if (!config.auth_token) { chatHistory.addMessage({ role: 'system', content: '✗ Not logged in.' }); return; }
|
|
112
124
|
inputField.setDisabled(true);
|
|
113
125
|
statusBar.update('thinking', 'Logging out...');
|
|
126
|
+
// Defense-in-depth: drop the chat context up front, REGARDLESS of whether
|
|
127
|
+
// the dashboardLogout HTTP call below succeeds (it early-returns on a
|
|
128
|
+
// non-401 error), so no later save can target the logged-out user's chat.
|
|
129
|
+
ctx.currentChatId = null; ctx.savedUpTo = 0;
|
|
114
130
|
try { await dashboardLogout(); } catch (err) { if (err.statusCode !== 401) { chatHistory.addMessage({ role: 'system', content: `✗ ${err.message}`, isError: true }); statusBar.update('idle'); inputField.setDisabled(false); return; } }
|
|
115
131
|
setConfig({ ...config, auth_token: '' });
|
|
116
132
|
chatHistory.addMessage({ role: 'system', content: '✓ Logged out and cleared local CLI token.' });
|