@semalt-ai/code 1.8.5 → 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.
Files changed (192) hide show
  1. package/.claude/settings.local.json +7 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/ARCHITECTURE.md +6 -95
  4. package/CLAUDE.md +196 -316
  5. package/README.md +148 -4
  6. package/docs/ARCHITECTURE.md +1321 -0
  7. package/docs/CONFIG.md +340 -0
  8. package/docs/HISTORY.md +245 -0
  9. package/examples/embed.js +74 -0
  10. package/index.js +251 -10
  11. package/lib/agent.js +856 -120
  12. package/lib/api.js +239 -50
  13. package/lib/args.js +74 -2
  14. package/lib/audit.js +23 -1
  15. package/lib/background.js +584 -0
  16. package/lib/checkpoints.js +757 -0
  17. package/lib/commands/auth.js +94 -0
  18. package/lib/commands/chat-session.js +489 -0
  19. package/lib/commands/chat-slash.js +415 -0
  20. package/lib/commands/chat-turn.js +669 -0
  21. package/lib/commands/chat.js +407 -0
  22. package/lib/commands/custom.js +157 -0
  23. package/lib/commands/history-utils.js +66 -0
  24. package/lib/commands/index.js +268 -0
  25. package/lib/commands/mcp.js +113 -0
  26. package/lib/commands/oneshot.js +193 -0
  27. package/lib/commands/registry.js +269 -0
  28. package/lib/commands/tasks.js +89 -0
  29. package/lib/compact.js +87 -0
  30. package/lib/config.js +360 -11
  31. package/lib/constants.js +401 -3
  32. package/lib/deny.js +199 -0
  33. package/lib/doctor.js +160 -0
  34. package/lib/headless.js +202 -0
  35. package/lib/hooks.js +286 -0
  36. package/lib/images.js +270 -0
  37. package/lib/internals.js +49 -0
  38. package/lib/mcp/boundary.js +131 -0
  39. package/lib/mcp/client.js +270 -0
  40. package/lib/mcp/oauth.js +134 -0
  41. package/lib/memory.js +209 -0
  42. package/lib/metrics.js +37 -2
  43. package/lib/payload.js +54 -0
  44. package/lib/permission-rules.js +401 -0
  45. package/lib/permissions.js +123 -26
  46. package/lib/pricing.js +67 -0
  47. package/lib/proc.js +62 -0
  48. package/lib/prompts.js +99 -8
  49. package/lib/sandbox.js +568 -0
  50. package/lib/sdk.js +328 -0
  51. package/lib/secrets.js +211 -0
  52. package/lib/skills.js +223 -0
  53. package/lib/subagents.js +516 -0
  54. package/lib/tool_registry.js +2862 -0
  55. package/lib/tool_specs.js +263 -9
  56. package/lib/tools.js +352 -1039
  57. package/lib/ui/anim.js +86 -0
  58. package/lib/ui/ansi.js +17 -27
  59. package/lib/ui/chat-history.js +253 -71
  60. package/lib/ui/create-ui.js +67 -24
  61. package/lib/ui/diff.js +90 -25
  62. package/lib/ui/file-activity.js +236 -0
  63. package/lib/ui/format.js +195 -29
  64. package/lib/ui/input-field.js +21 -11
  65. package/lib/ui/md-stream.js +234 -0
  66. package/lib/ui/render-operation.js +113 -0
  67. package/lib/ui/select.js +1 -4
  68. package/lib/ui/status-bar.js +146 -36
  69. package/lib/ui/stream.js +20 -13
  70. package/lib/ui/theme.js +190 -44
  71. package/lib/ui/tool-operation.js +190 -0
  72. package/lib/ui/utils.js +9 -5
  73. package/lib/ui/web-activity.js +270 -0
  74. package/lib/ui/writer.js +159 -45
  75. package/lib/ui.js +1 -1
  76. package/lib/verify.js +229 -0
  77. package/lib/web-extract.js +213 -0
  78. package/lib/web-summarize.js +68 -0
  79. package/package.json +19 -4
  80. package/scripts/lint.js +57 -0
  81. package/test/agent-loop.test.js +389 -0
  82. package/test/anim-driver.test.js +153 -0
  83. package/test/ask-user-display.test.js +226 -0
  84. package/test/ask-user-gate.test.js +231 -0
  85. package/test/background.test.js +414 -0
  86. package/test/chat-history-nocolor.test.js +155 -0
  87. package/test/chat-relogin.test.js +207 -0
  88. package/test/chat.test.js +114 -0
  89. package/test/checkpoints-agent.test.js +181 -0
  90. package/test/checkpoints.test.js +650 -0
  91. package/test/command-registry.test.js +160 -0
  92. package/test/compact.test.js +116 -0
  93. package/test/completion-lazy.test.js +52 -0
  94. package/test/config-merge.test.js +324 -0
  95. package/test/config-quarantine.test.js +128 -0
  96. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  97. package/test/config-write-guard-skip.test.js +46 -0
  98. package/test/config-write-guard.test.js +153 -0
  99. package/test/context-split.test.js +215 -0
  100. package/test/cost-doctor.test.js +142 -0
  101. package/test/custom-commands-chat.test.js +106 -0
  102. package/test/custom-commands.test.js +230 -0
  103. package/test/defer-detail-band.test.js +403 -0
  104. package/test/deny-windows.test.js +120 -0
  105. package/test/deny.test.js +83 -0
  106. package/test/detail-band-tab-flatten.test.js +242 -0
  107. package/test/download-allow-anywhere.test.js +66 -0
  108. package/test/download-confine.test.js +153 -0
  109. package/test/exec-diff.test.js +268 -0
  110. package/test/executors.test.js +599 -0
  111. package/test/extract-tool-calls.test.js +349 -0
  112. package/test/fetch-url-validation.test.js +219 -0
  113. package/test/file-activity.test.js +522 -0
  114. package/test/fixtures/tool-calls.js +57 -0
  115. package/test/fixtures/web-page.js +91 -0
  116. package/test/git-tools.test.js +384 -0
  117. package/test/grep-glob-serialize.test.js +242 -0
  118. package/test/grep-glob.test.js +268 -0
  119. package/test/grep-path-target.test.js +227 -0
  120. package/test/harness/README.md +57 -0
  121. package/test/harness/chat-harness.js +143 -0
  122. package/test/harness/memwarn-headless-child.js +65 -0
  123. package/test/harness/mock-llm.js +120 -0
  124. package/test/harness/mock-mcp-server.js +142 -0
  125. package/test/harness/sse-server.js +69 -0
  126. package/test/headless.test.js +348 -0
  127. package/test/history-utils.test.js +88 -0
  128. package/test/hooks-agent.test.js +238 -0
  129. package/test/hooks-verify-sandbox.test.js +232 -0
  130. package/test/hooks.test.js +216 -0
  131. package/test/http-get-user-agent.test.js +142 -0
  132. package/test/images-api.test.js +208 -0
  133. package/test/images.test.js +238 -0
  134. package/test/input-field-ctrl-o.test.js +37 -0
  135. package/test/live-height-physical.test.js +281 -0
  136. package/test/max-iterations.test.js +218 -0
  137. package/test/mcp-boundary.test.js +57 -0
  138. package/test/mcp-client.test.js +267 -0
  139. package/test/mcp-oauth.test.js +86 -0
  140. package/test/md-stream.test.js +183 -0
  141. package/test/memory-truncation-warning.test.js +222 -0
  142. package/test/memory.test.js +198 -0
  143. package/test/native-dispatch.test.js +409 -0
  144. package/test/native-live-narration.test.js +254 -0
  145. package/test/output-chokepoint.test.js +188 -0
  146. package/test/output-heredoc-leak.test.js +195 -0
  147. package/test/output-preview.test.js +245 -0
  148. package/test/path-guards.test.js +134 -0
  149. package/test/payload.test.js +99 -0
  150. package/test/permission-rules-agent.test.js +210 -0
  151. package/test/permission-rules.test.js +297 -0
  152. package/test/permissions.test.js +362 -0
  153. package/test/plan-mode.test.js +167 -0
  154. package/test/read-paginate.test.js +275 -0
  155. package/test/readonly-tools.test.js +177 -0
  156. package/test/render-operation.test.js +317 -0
  157. package/test/replay-descriptor-xml.test.js +216 -0
  158. package/test/replay-descriptor.test.js +189 -0
  159. package/test/replay-web-aggregate.test.js +291 -0
  160. package/test/replay-web-persist.test.js +241 -0
  161. package/test/result-cap.test.js +233 -0
  162. package/test/running-glyph-anim.test.js +111 -0
  163. package/test/sandbox-agent.test.js +147 -0
  164. package/test/sandbox-integration.test.js +216 -0
  165. package/test/sandbox.test.js +408 -0
  166. package/test/sdk.test.js +234 -0
  167. package/test/shell-output-cap.test.js +181 -0
  168. package/test/skills-chat.test.js +110 -0
  169. package/test/skills.test.js +295 -0
  170. package/test/smoke.test.js +68 -0
  171. package/test/status-bar-driver.test.js +93 -0
  172. package/test/status-bar-pause.test.js +164 -0
  173. package/test/status-bar-resync.test.js +188 -0
  174. package/test/stream-parser.test.js +171 -0
  175. package/test/subagents-agent.test.js +178 -0
  176. package/test/subagents.test.js +222 -0
  177. package/test/theme-palette.test.js +166 -0
  178. package/test/tool-registry.test.js +85 -0
  179. package/test/trim-budget.test.js +101 -0
  180. package/test/truncate-visible.test.js +78 -0
  181. package/test/verify-agent.test.js +317 -0
  182. package/test/verify.test.js +141 -0
  183. package/test/view-image.test.js +199 -0
  184. package/test/web-activity-ordering.test.js +203 -0
  185. package/test/web-activity.test.js +207 -0
  186. package/test/web-data-extraction-guidance.test.js +71 -0
  187. package/test/web-extract.test.js +185 -0
  188. package/test/web-fetch-agent.test.js +291 -0
  189. package/test/web-fetch-mode.test.js +193 -0
  190. package/test/web-search.test.js +380 -0
  191. package/lib/commands.js +0 -1438
  192. package/path +0 -1
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+
3
+ // Authentication / account CLI commands, extracted from lib/commands.js in
4
+ // Task 1.5. `shared` is the dependency bag built by createCommands; it is
5
+ // destructured once here so the command bodies below are unchanged.
6
+
7
+ function createAuthCommands(shared) {
8
+ const {
9
+ writer, getConfig, setConfig,
10
+ requestCliLogin, getCliLoginStatus, dashboardWhoAmI, dashboardLogout,
11
+ formatUserLine, keychainSet, ENV_VAR, CONFIG_PATH,
12
+ LOGIN_POLL_INTERVAL_MS, LOGIN_TIMEOUT_MS,
13
+ FG_RED, FG_GRAY, FG_TEAL, FG_DARK, FG_GREEN, FG_CYAN, FG_YELLOW, BOLD, RST,
14
+ } = shared;
15
+
16
+ async function cmdLogin() {
17
+ writer.scrollback(`\n ${FG_TEAL}${BOLD}◆ CLI Login${RST}\n ${FG_DARK}${'─'.repeat(40)}${RST}`);
18
+ let loginRequest;
19
+ try { loginRequest = await requestCliLogin(); }
20
+ catch (err) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Unable to start login via ${getConfig().dashboard_url}: ${err.message}${RST}\n`); return; }
21
+ writer.scrollback(` ${FG_GRAY}Open this URL in your browser and confirm the login:${RST}\n ${FG_CYAN}${loginRequest.verification_url}${RST}\n ${FG_DARK}Waiting for confirmation...${RST}`);
22
+ const startedAt = Date.now();
23
+ while (Date.now() - startedAt < LOGIN_TIMEOUT_MS) {
24
+ await new Promise((r) => setTimeout(r, LOGIN_POLL_INTERVAL_MS));
25
+ let status;
26
+ try { status = await getCliLoginStatus(loginRequest.id, loginRequest.hash); }
27
+ catch (err) { if (err.statusCode === 404 || err.statusCode === 410) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Login token is no longer valid.${RST}\n`); return; } continue; }
28
+ if (status.status === 'authorized') { const config = getConfig(); setConfig({ ...config, dashboard_url: config.dashboard_url, auth_token: loginRequest.token }); writer.scrollback(` ${FG_GREEN}✓${RST} ${FG_GRAY}CLI token saved to ${CONFIG_PATH}${RST}\n`); return; }
29
+ if (status.status === 'expired') { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Login token expired.${RST}\n`); return; }
30
+ }
31
+ writer.scrollback(` ${FG_YELLOW}⚠${RST} ${FG_GRAY}Login timed out.${RST}\n`);
32
+ }
33
+
34
+ async function cmdWhoAmI() {
35
+ let response;
36
+ try { response = await dashboardWhoAmI(); } catch (err) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return; }
37
+ const user = response && response.user ? response.user : null;
38
+ if (!user) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Unable to load current user.${RST}\n`); return; }
39
+ const lines = [
40
+ '',
41
+ ` ${FG_TEAL}${BOLD}◆ Current User${RST}`,
42
+ ` ${FG_DARK}${'─'.repeat(40)}${RST}`,
43
+ formatUserLine('ID', user.id),
44
+ formatUserLine('Email', user.email || '-'),
45
+ formatUserLine('Name', user.name || '-'),
46
+ formatUserLine('Provider', user.provider || '-'),
47
+ ];
48
+ if (user.avatar_url) lines.push(formatUserLine('Avatar', user.avatar_url));
49
+ lines.push('');
50
+ writer.scrollback(lines.join('\n'));
51
+ }
52
+
53
+ async function cmdLogout() {
54
+ const config = getConfig();
55
+ if (!config.auth_token) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Not logged in.${RST}\n`); return; }
56
+ try { await dashboardLogout(); } catch (err) { if (err.statusCode !== 401) { writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}${err.message}${RST}\n`); return; } }
57
+ setConfig({ ...config, auth_token: '' });
58
+ writer.scrollback(` ${FG_GREEN}✓${RST} ${FG_GRAY}Logged out and cleared local CLI token.${RST}\n`);
59
+ }
60
+
61
+ // `semalt-code auth set-key [key]` — store the API key in the OS keychain so
62
+ // it is no longer kept in plaintext in config.json. If [key] is omitted, read
63
+ // it from stdin (so it does not land in shell history / the process table).
64
+ async function cmdAuthSetKey(keyArg) {
65
+ let key = typeof keyArg === 'string' ? keyArg.trim() : '';
66
+ if (!key) {
67
+ if (process.stdin.isTTY) {
68
+ process.stdout.write(` ${FG_GRAY}Paste API key (input hidden is not available; press Enter when done):${RST}\n > `);
69
+ }
70
+ key = await new Promise((resolve) => {
71
+ let buf = '';
72
+ const onData = (d) => { buf += d; if (buf.includes('\n')) { process.stdin.pause(); process.stdin.off('data', onData); resolve(buf.split('\n')[0]); } };
73
+ try { process.stdin.resume(); process.stdin.setEncoding('utf8'); process.stdin.on('data', onData); } catch { resolve(''); }
74
+ });
75
+ key = (key || '').trim();
76
+ }
77
+ if (!key) {
78
+ writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}No key provided.${RST}\n`);
79
+ return;
80
+ }
81
+ const ok = keychainSet(key);
82
+ if (ok) {
83
+ writer.scrollback(` ${FG_GREEN}✓${RST} ${FG_GRAY}API key stored in the OS keychain. It will be used automatically (env var still takes precedence).${RST}\n`);
84
+ writer.scrollback(` ${FG_DARK}It is NOT written to config.json. Verify with 'semalt-code config' → api_key_source: keychain.${RST}\n`);
85
+ } else {
86
+ writer.scrollback(` ${FG_RED}✗${RST} ${FG_GRAY}Could not store the key in the OS keychain (native tool missing or unavailable on this platform).${RST}\n`);
87
+ writer.scrollback(` ${FG_DARK}Alternative: set the ${ENV_VAR} environment variable instead.${RST}\n`);
88
+ }
89
+ }
90
+
91
+ return { cmdLogin, cmdWhoAmI, cmdLogout, cmdAuthSetKey };
92
+ }
93
+
94
+ module.exports = { createAuthCommands };
@@ -0,0 +1,489 @@
1
+ 'use strict';
2
+
3
+ // Chat session/state management extracted from cmdChat in Task 1.5: local- and
4
+ // dashboard-history sync plus the in-chat picker (history / chats / models).
5
+ // Bodies are unchanged except that mutable session fields are read/written
6
+ // through ctx (the getter/setter hub built in cmdChat) instead of as closure
7
+ // locals; stable collaborators are destructured from ctx once below. The
8
+ // functions call each other by bare name (same module scope); other chat
9
+ // modules reach them via ctx, onto which createCommands' cmdChat assigns them.
10
+
11
+ function createChatSession(ctx) {
12
+ const {
13
+ storage, inputField, chatHistory, statusBar, writer, msgs,
14
+ getConfig, setConfig, getCols, approxTokens, PAGE_SIZE, sessionStart,
15
+ dashboardCreateChat, dashboardSaveMessages, dashboardGetChat, dashboardGetModelForCli,
16
+ cleanOrphanedToolMessages, reconstructLoadedMessage, resolveTokenLimit,
17
+ FG_GRAY, RST,
18
+ } = ctx;
19
+
20
+ function refreshInputSearchItems(extraItems) {
21
+ const sessions = storage.list();
22
+ const items = sessions.map(s => ({
23
+ type: 'session',
24
+ text: (() => {
25
+ const date = new Date(s.created_at).toISOString().slice(0, 16).replace('T', ' ');
26
+ return `${date} ${s.model || ''} (${s.message_count} msgs)`;
27
+ })(),
28
+ }));
29
+ if (extraItems) items.push(...extraItems);
30
+ inputField.setSearchItems(items);
31
+ }
32
+
33
+ function saveSession() {
34
+ ctx.session.model = ctx.currentModel;
35
+ ctx.session.messages = ctx.messages;
36
+ ctx.session.stats.duration_sec = Math.round((Date.now() - sessionStart) / 1000);
37
+ ctx.session.stats.total_tokens = ctx.messages.reduce((s, m) => s + Math.round((m.content || '').length / 4), 0);
38
+ try { storage.save(ctx.session); } catch {}
39
+ }
40
+
41
+ async function createChatIfNeeded(firstUserText) {
42
+ const config = getConfig();
43
+ if (ctx.currentChatId !== null || !config.auth_token || !config.dashboard_model_id) return;
44
+ try {
45
+ const title = firstUserText.length > 60 ? firstUserText.slice(0, 57) + '...' : firstUserText;
46
+ const resp = await dashboardCreateChat(title, config.dashboard_model_id);
47
+ if (resp && resp.chat && resp.chat.id) ctx.currentChatId = resp.chat.id;
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
+ }
54
+ }
55
+
56
+ async function saveTurnToDashboard() {
57
+ if (ctx.currentChatId === null) return;
58
+ const newMessages = ctx.messages.slice(ctx.savedUpTo).filter((m) => m.role !== 'system');
59
+ if (!newMessages.length) return;
60
+ try {
61
+ const resp = await dashboardSaveMessages(ctx.currentChatId, newMessages);
62
+ ctx.savedUpTo = ctx.messages.length;
63
+ if (resp && typeof resp.skipped_count === 'number' && resp.skipped_count > 0) {
64
+ msgs.sysWarn(`history save: ${resp.skipped_count} message(s) skipped by server`);
65
+ }
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
+ }
91
+ msgs.sysWarn(`history save failed: ${err && err.message ? err.message : String(err)}`);
92
+ }
93
+ }
94
+
95
+ function displayLoadedMessages(loadedMessages) {
96
+ chatHistory.clearMessages();
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];
174
+ const raw = typeof m.content === 'string' ? m.content : '';
175
+ const ts = m.created_at ? new Date(m.created_at) : (m.ts ? new Date(m.ts) : new Date());
176
+
177
+ if (m.role === 'tool') {
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
+ });
200
+ continue;
201
+ }
202
+
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();
241
+ const body = raw
242
+ .replace(/^Tool execution results[^\n]*\n+/, '')
243
+ .replace(/\n+Continue with the task\.[\s\S]*$/, '')
244
+ .trim();
245
+ chatHistory.addMessage({ role: 'tool', tag: 'tool', content: body || raw, ts });
246
+ continue;
247
+ }
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.
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
+ }
265
+ chatHistory.addMessage({ role: m.role, content: raw, ts });
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();
273
+ }
274
+
275
+ function seedContextFromMessages() {
276
+ let total = 0;
277
+ for (const m of ctx.messages) {
278
+ if (typeof m.content === 'string') total += approxTokens(m.content);
279
+ }
280
+ statusBar.updateMetrics({ contextTokens: total });
281
+ }
282
+
283
+ function emitCleanupWarning(cleanup) {
284
+ if (cleanup.droppedTool === 0 && cleanup.droppedAssistantCalls === 0 && cleanup.droppedAssistantMsgs === 0) return;
285
+ const parts = [];
286
+ if (cleanup.droppedTool > 0) parts.push(`${cleanup.droppedTool} orphaned tool result(s)`);
287
+ if (cleanup.droppedAssistantCalls > 0) parts.push(`${cleanup.droppedAssistantCalls} dangling tool_call(s)`);
288
+ if (cleanup.droppedAssistantMsgs > 0) parts.push(`${cleanup.droppedAssistantMsgs} empty assistant message(s)`);
289
+ chatHistory.addMessage({
290
+ role: 'system',
291
+ content: `⚠ Loaded chat had ${parts.join(', ')}, cleaned up. The chat may be missing some context.`,
292
+ });
293
+ }
294
+
295
+ // ── In-chat picker (history / chats / models) ────────────────────────────
296
+
297
+ function getNavSearchText(type, item) {
298
+ if (type === 'history') {
299
+ const date = new Date(item.created_at).toISOString().slice(0, 16);
300
+ return `${date} ${item.model || ''} ${item.message_count || ''}`;
301
+ } else if (type === 'chats') {
302
+ return `${item.title || ''} ${item.model_name || ''}`;
303
+ } else if (type === 'models') {
304
+ return `${item.name || ''} ${item.model_id || ''}`;
305
+ }
306
+ return '';
307
+ }
308
+
309
+ function buildItemDetail(type, item) {
310
+ const cfg = getConfig();
311
+ const maxDetail = Math.max(20, getCols() - 12);
312
+ let detail = '';
313
+ if (type === 'history') {
314
+ const date = new Date(item.created_at).toISOString().slice(0, 16).replace('T', ' ');
315
+ detail = `${date} ${(item.model || '').slice(0, 20)} (${item.message_count} msgs)`;
316
+ } else if (type === 'chats') {
317
+ const date = item.updated_at ? String(item.updated_at).slice(0, 10) : '';
318
+ detail = `${item.title} · ${item.model_name || ''} · ${date}`;
319
+ } else if (type === 'models') {
320
+ const active = item.base_url === cfg.api_base && item.model_id === cfg.default_model;
321
+ detail = `${active ? '●' : ' '} ${item.name} · ${item.model_id}`;
322
+ }
323
+ if (detail.length > maxDetail) detail = detail.slice(0, maxDetail - 1) + '…';
324
+ return detail;
325
+ }
326
+
327
+ function buildListContent() {
328
+ if (!ctx.pendingAction) return '';
329
+ const { type, items, displayItems: di, stepIdx, searchQuery } = ctx.pendingAction;
330
+ const items2 = di || items;
331
+ const page = Math.floor(stepIdx / PAGE_SIZE);
332
+ const pageCount = Math.ceil(items2.length / PAGE_SIZE);
333
+ const pageStart = page * PAGE_SIZE;
334
+ const pageItems = items2.slice(pageStart, pageStart + PAGE_SIZE);
335
+ const localIdx = stepIdx - pageStart;
336
+ const titleMap = { history: 'Sessions', chats: 'Chats', models: 'Models' };
337
+ const pageLabel = pageCount > 1 ? ` · Page ${page + 1}/${pageCount}` : '';
338
+ const countLabel = items2.length > 0 ? `[${stepIdx + 1}/${items2.length}]` : '[0 results]';
339
+ const searchLabel = searchQuery ? ` · filter: '${searchQuery}'` : '';
340
+ const parts = [`${titleMap[type] || type} ${countLabel}${pageLabel}${searchLabel}`, ''];
341
+ for (let i = 0; i < pageItems.length; i++) {
342
+ const item = pageItems[i];
343
+ const sel = i === localIdx;
344
+ const detail = buildItemDetail(type, item);
345
+ parts.push(sel ? `\x1b[1m\x1b[36m ► ${detail}` : ` ${detail}`);
346
+ }
347
+ // Pad to a fixed height so rerenderById always clears the same number of rows,
348
+ // regardless of how many items the current page has (last page may have fewer).
349
+ while (parts.length < PAGE_SIZE + 2) parts.push('');
350
+ return parts.join('\n');
351
+ }
352
+
353
+ function collapseListMsg(_type, _item) {
354
+ // Modal is transient — clearing it removes the picker from view; the
355
+ // selection's success line is emitted to scrollback by handlePendingSelection.
356
+ writer.clearModal();
357
+ }
358
+
359
+ function showPendingStep() {
360
+ if (!ctx.pendingAction) return;
361
+ const lines = buildListContent().split('\n');
362
+ const modalLines = lines.length > 0
363
+ ? [` ${FG_GRAY}●${RST} ${FG_GRAY}${lines[0]}${RST}`].concat(lines.slice(1).map((l) => ` ${l}`))
364
+ : [];
365
+ writer.setModal(modalLines);
366
+ }
367
+
368
+ function finalizeListMsg() {
369
+ writer.clearModal();
370
+ }
371
+
372
+ function activateNavCapture() {
373
+ inputField.captureNavigation(async (action) => {
374
+ if (!ctx.pendingAction) { inputField.releaseNavigation(); return; }
375
+ const { items, displayItems: di, stepIdx } = ctx.pendingAction;
376
+ const activeItems = di || items;
377
+
378
+ if (action.startsWith('search:')) {
379
+ const query = action.slice(7);
380
+ if (!query) {
381
+ ctx.pendingAction = { ...ctx.pendingAction, displayItems: null, searchQuery: '', stepIdx: 0 };
382
+ } else {
383
+ const filtered = items.filter(item => getNavSearchText(ctx.pendingAction.type, item).toLowerCase().includes(query.toLowerCase()));
384
+ ctx.pendingAction = { ...ctx.pendingAction, displayItems: filtered, searchQuery: query, stepIdx: 0 };
385
+ }
386
+ showPendingStep();
387
+ return;
388
+ }
389
+
390
+ if (action === 'next') {
391
+ ctx.pendingAction = { ...ctx.pendingAction, stepIdx: activeItems.length ? (stepIdx + 1) % activeItems.length : 0 };
392
+ showPendingStep();
393
+ } else if (action === 'prev') {
394
+ ctx.pendingAction = { ...ctx.pendingAction, stepIdx: activeItems.length ? (stepIdx - 1 + activeItems.length) % activeItems.length : 0 };
395
+ showPendingStep();
396
+ } else if (action === 'select') {
397
+ if (!activeItems.length) return;
398
+ inputField.releaseNavigation();
399
+ const si = ctx.pendingAction.stepIdx;
400
+ collapseListMsg(ctx.pendingAction.type, activeItems[si]);
401
+ statusBar.update('idle');
402
+ await handlePendingSelection(si);
403
+ inputField.setDisabled(false);
404
+ } else if (action === 'cancel') {
405
+ inputField.releaseNavigation();
406
+ finalizeListMsg();
407
+ chatHistory.addMessage({ role: 'system', content: 'Cancelled.' });
408
+ ctx.pendingAction = null;
409
+ statusBar.update('idle');
410
+ inputField.setDisabled(false);
411
+ }
412
+ });
413
+ }
414
+
415
+ async function handlePendingSelection(idx) {
416
+ if (!ctx.pendingAction) return;
417
+ const { type, items, displayItems: di } = ctx.pendingAction;
418
+ const activeItems = di || items;
419
+ ctx.pendingAction = null;
420
+
421
+ if (type === 'history') {
422
+ const loaded = storage.load(activeItems[idx].id);
423
+ if (loaded) {
424
+ const filtered = (loaded.messages || []).filter((m) => m.role !== 'system');
425
+ const cleanup = cleanOrphanedToolMessages(filtered);
426
+ ctx.messages = cleanup.messages;
427
+ ctx.session = { id: loaded.id, created_at: loaded.created_at, model: loaded.model, messages: ctx.messages, stats: loaded.stats || { total_tokens: 0, duration_sec: 0 } };
428
+ ctx.currentChatId = null; ctx.savedUpTo = 0;
429
+ if (loaded.model && loaded.model !== ctx.currentModel) {
430
+ ctx.currentModel = loaded.model;
431
+ ctx.resolvedTokenLimit = await resolveTokenLimit(ctx.currentModel);
432
+ statusBar.setModel(ctx.currentModel);
433
+ statusBar.setContextLimit(ctx.resolvedTokenLimit);
434
+ }
435
+ displayLoadedMessages(ctx.messages);
436
+ chatHistory.addMessage({ role: 'system', content: `✓ Session loaded. Model → ${ctx.currentModel}` });
437
+ emitCleanupWarning(cleanup);
438
+ seedContextFromMessages();
439
+ }
440
+ } else if (type === 'chats') {
441
+ const selectedChat = activeItems[idx];
442
+ try {
443
+ const chatData = await dashboardGetChat(selectedChat.id);
444
+ const loaded = chatData && chatData.messages ? chatData.messages : [];
445
+ const reconstructed = loaded.map(reconstructLoadedMessage);
446
+ const cleanup = cleanOrphanedToolMessages(reconstructed);
447
+ ctx.messages = cleanup.messages;
448
+ ctx.currentChatId = selectedChat.id; ctx.savedUpTo = ctx.messages.length;
449
+ displayLoadedMessages(loaded);
450
+ chatHistory.addMessage({ role: 'system', content: `✓ Resumed: ${selectedChat.title} (${loaded.length} messages)` });
451
+ emitCleanupWarning(cleanup);
452
+ seedContextFromMessages();
453
+ } catch (err) {
454
+ chatHistory.addMessage({ role: 'system', content: `✗ ${err.message}`, isError: true });
455
+ }
456
+ } else if (type === 'models') {
457
+ const selectedModel = activeItems[idx];
458
+ try {
459
+ const credResp = await dashboardGetModelForCli(selectedModel.id);
460
+ const model = credResp && credResp.model ? credResp.model : null;
461
+ if (!model) { chatHistory.addMessage({ role: 'system', content: '✗ Unable to load model.', isError: true }); return; }
462
+ const contextLength = (Number.isInteger(model.context_length) && model.context_length > 0 ? model.context_length : null)
463
+ || (Number.isInteger(model.max_tokens) && model.max_tokens > 0 ? model.max_tokens : null);
464
+ const config = getConfig();
465
+ const updated = { ...config, api_base: model.base_url, api_key: model.api_key, default_model: model.model_id, dashboard_model_id: model.id };
466
+ if (contextLength !== null) updated.context_length = contextLength;
467
+ setConfig(updated);
468
+ ctx.currentModel = model.model_id;
469
+ ctx.resolvedTokenLimit = await resolveTokenLimit(ctx.currentModel);
470
+ statusBar.setModel(ctx.currentModel);
471
+ statusBar.setContextLimit(ctx.resolvedTokenLimit);
472
+ ctx.currentChatId = null;
473
+ chatHistory.addMessage({ role: 'system', content: `✓ Model → ${model.name} (${model.model_id})` });
474
+ statusBar.update('idle');
475
+ } catch (err) {
476
+ chatHistory.addMessage({ role: 'system', content: `✗ ${err.message}`, isError: true });
477
+ }
478
+ }
479
+ }
480
+
481
+ return {
482
+ refreshInputSearchItems, saveSession, createChatIfNeeded, saveTurnToDashboard,
483
+ displayLoadedMessages, seedContextFromMessages, emitCleanupWarning,
484
+ getNavSearchText, buildItemDetail, buildListContent, collapseListMsg,
485
+ showPendingStep, finalizeListMsg, activateNavCapture, handlePendingSelection,
486
+ };
487
+ }
488
+
489
+ module.exports = { createChatSession };