pikiloop 0.4.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/LICENSE +21 -0
- package/README.md +353 -0
- package/README.v2.md +287 -0
- package/README.zh-CN.md +352 -0
- package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
- package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
- package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
- package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
- package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
- package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
- package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
- package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
- package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
- package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
- package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
- package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
- package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
- package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
- package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
- package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
- package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
- package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
- package/dashboard/dist/assets/index-reSbuley.css +1 -0
- package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
- package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
- package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
- package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
- package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
- package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
- package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
- package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
- package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
- package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
- package/dashboard/dist/favicon.svg +28 -0
- package/dashboard/dist/index.html +17 -0
- package/dist/agent/acp-client.js +261 -0
- package/dist/agent/auto-update.js +432 -0
- package/dist/agent/await-resume.js +50 -0
- package/dist/agent/cli/auth.js +325 -0
- package/dist/agent/cli/catalog.js +40 -0
- package/dist/agent/cli/detector.js +136 -0
- package/dist/agent/cli/index.js +7 -0
- package/dist/agent/cli/registry.js +33 -0
- package/dist/agent/driver.js +39 -0
- package/dist/agent/drivers/claude-tui.js +2297 -0
- package/dist/agent/drivers/claude.js +2689 -0
- package/dist/agent/drivers/codex.js +2210 -0
- package/dist/agent/drivers/gemini.js +1059 -0
- package/dist/agent/drivers/hermes.js +795 -0
- package/dist/agent/goal.js +274 -0
- package/dist/agent/handover.js +130 -0
- package/dist/agent/images.js +355 -0
- package/dist/agent/index.js +50 -0
- package/dist/agent/mcp/bridge.js +791 -0
- package/dist/agent/mcp/extensions.js +637 -0
- package/dist/agent/mcp/oauth.js +353 -0
- package/dist/agent/mcp/registry.js +119 -0
- package/dist/agent/mcp/session-server.js +229 -0
- package/dist/agent/mcp/tools/ask-user.js +113 -0
- package/dist/agent/mcp/tools/await-resume.js +77 -0
- package/dist/agent/mcp/tools/goal.js +144 -0
- package/dist/agent/mcp/tools/types.js +12 -0
- package/dist/agent/mcp/tools/workspace.js +212 -0
- package/dist/agent/npm.js +31 -0
- package/dist/agent/session.js +1206 -0
- package/dist/agent/skill-installer.js +160 -0
- package/dist/agent/skills.js +257 -0
- package/dist/agent/stream.js +743 -0
- package/dist/agent/types.js +13 -0
- package/dist/agent/utils.js +687 -0
- package/dist/bot/bot.js +2499 -0
- package/dist/bot/command-ui.js +633 -0
- package/dist/bot/commands.js +513 -0
- package/dist/bot/headless-bot.js +36 -0
- package/dist/bot/host.js +192 -0
- package/dist/bot/human-loop.js +168 -0
- package/dist/bot/menu.js +48 -0
- package/dist/bot/orchestration.js +79 -0
- package/dist/bot/render-shared.js +309 -0
- package/dist/bot/session-hub.js +361 -0
- package/dist/bot/session-status.js +55 -0
- package/dist/bot/streaming.js +309 -0
- package/dist/browser-profile.js +579 -0
- package/dist/browser-supervisor.js +249 -0
- package/dist/catalog/cli-tools.js +421 -0
- package/dist/catalog/index.js +21 -0
- package/dist/catalog/local-models.js +94 -0
- package/dist/catalog/mcp-servers.js +315 -0
- package/dist/catalog/skill-repos.js +173 -0
- package/dist/channels/base.js +55 -0
- package/dist/channels/dingtalk/bot.js +549 -0
- package/dist/channels/dingtalk/channel.js +268 -0
- package/dist/channels/discord/bot.js +552 -0
- package/dist/channels/discord/channel.js +245 -0
- package/dist/channels/feishu/bot.js +1275 -0
- package/dist/channels/feishu/channel.js +911 -0
- package/dist/channels/feishu/markdown.js +91 -0
- package/dist/channels/feishu/render.js +619 -0
- package/dist/channels/health.js +109 -0
- package/dist/channels/slack/bot.js +554 -0
- package/dist/channels/slack/channel.js +283 -0
- package/dist/channels/states.js +6 -0
- package/dist/channels/telegram/bot.js +1310 -0
- package/dist/channels/telegram/channel.js +820 -0
- package/dist/channels/telegram/directory.js +111 -0
- package/dist/channels/telegram/live-preview.js +220 -0
- package/dist/channels/telegram/render.js +384 -0
- package/dist/channels/wecom/bot.js +558 -0
- package/dist/channels/wecom/channel.js +479 -0
- package/dist/channels/weixin/api.js +520 -0
- package/dist/channels/weixin/bot.js +1000 -0
- package/dist/channels/weixin/channel.js +222 -0
- package/dist/cli/autostart.js +262 -0
- package/dist/cli/channel-supervisor.js +313 -0
- package/dist/cli/channels.js +54 -0
- package/dist/cli/main.js +726 -0
- package/dist/cli/onboarding.js +227 -0
- package/dist/cli/run.js +308 -0
- package/dist/cli/setup-wizard.js +235 -0
- package/dist/core/config/runtime-config.js +201 -0
- package/dist/core/config/user-config.js +510 -0
- package/dist/core/config/validation.js +521 -0
- package/dist/core/constants.js +400 -0
- package/dist/core/git.js +145 -0
- package/dist/core/legacy-compat.js +60 -0
- package/dist/core/logging.js +101 -0
- package/dist/core/platform.js +59 -0
- package/dist/core/process-control.js +315 -0
- package/dist/core/secrets/index.js +42 -0
- package/dist/core/secrets/inline-seal.js +60 -0
- package/dist/core/secrets/ref.js +33 -0
- package/dist/core/secrets/resolver.js +65 -0
- package/dist/core/secrets/store.js +63 -0
- package/dist/core/utils.js +233 -0
- package/dist/core/version.js +15 -0
- package/dist/dashboard/platform.js +219 -0
- package/dist/dashboard/routes/agents.js +450 -0
- package/dist/dashboard/routes/cli.js +174 -0
- package/dist/dashboard/routes/config.js +523 -0
- package/dist/dashboard/routes/extensions.js +745 -0
- package/dist/dashboard/routes/local-models.js +290 -0
- package/dist/dashboard/routes/models.js +324 -0
- package/dist/dashboard/routes/sessions.js +838 -0
- package/dist/dashboard/runtime.js +410 -0
- package/dist/dashboard/server.js +237 -0
- package/dist/dashboard/session-control.js +347 -0
- package/dist/model/catalog.js +104 -0
- package/dist/model/index.js +20 -0
- package/dist/model/injector.js +272 -0
- package/dist/model/provider-models.js +112 -0
- package/dist/model/store.js +212 -0
- package/dist/model/types.js +13 -0
- package/dist/model/validation.js +203 -0
- package/package.json +82 -0
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure utility functions shared across the agent layer.
|
|
3
|
+
* No filesystem or session state side effects.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import { writeScopedLog } from '../core/logging.js';
|
|
7
|
+
export const Q = (a) => {
|
|
8
|
+
if (/[^a-zA-Z0-9_./:=@-]/.test(a)) {
|
|
9
|
+
return process.platform === 'win32'
|
|
10
|
+
? `"${a.replace(/"/g, '""')}"`
|
|
11
|
+
: `'${a.replace(/'/g, "'\\''")}'`;
|
|
12
|
+
}
|
|
13
|
+
return a;
|
|
14
|
+
};
|
|
15
|
+
export function agentLog(msg, level = 'debug') {
|
|
16
|
+
writeScopedLog('agent', msg, { level });
|
|
17
|
+
}
|
|
18
|
+
export function agentWarn(msg) {
|
|
19
|
+
agentLog(msg, 'warn');
|
|
20
|
+
}
|
|
21
|
+
export function agentError(msg) {
|
|
22
|
+
agentLog(msg, 'error');
|
|
23
|
+
}
|
|
24
|
+
export function dedupeStrings(values) {
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
const deduped = [];
|
|
27
|
+
for (const value of values) {
|
|
28
|
+
const item = String(value || '').trim();
|
|
29
|
+
if (!item || seen.has(item))
|
|
30
|
+
continue;
|
|
31
|
+
seen.add(item);
|
|
32
|
+
deduped.push(item);
|
|
33
|
+
}
|
|
34
|
+
return deduped;
|
|
35
|
+
}
|
|
36
|
+
export function numberOrNull(...values) {
|
|
37
|
+
for (const value of values) {
|
|
38
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
export function normalizeStreamPreviewPlan(value) {
|
|
44
|
+
if (!value || typeof value !== 'object')
|
|
45
|
+
return null;
|
|
46
|
+
const record = value;
|
|
47
|
+
const rawSteps = Array.isArray(record.steps)
|
|
48
|
+
? record.steps
|
|
49
|
+
: Array.isArray(record.plan)
|
|
50
|
+
? record.plan
|
|
51
|
+
: [];
|
|
52
|
+
const steps = rawSteps
|
|
53
|
+
.map((entry) => {
|
|
54
|
+
if (!entry || typeof entry !== 'object')
|
|
55
|
+
return null;
|
|
56
|
+
const step = typeof entry.step === 'string' ? entry.step.trim() : '';
|
|
57
|
+
if (!step)
|
|
58
|
+
return null;
|
|
59
|
+
const rawStatus = typeof entry.status === 'string' ? entry.status : 'pending';
|
|
60
|
+
const status = rawStatus === 'completed' || rawStatus === 'inProgress' || rawStatus === 'pending'
|
|
61
|
+
? rawStatus
|
|
62
|
+
: 'pending';
|
|
63
|
+
return { step, status };
|
|
64
|
+
})
|
|
65
|
+
.filter((entry) => !!entry);
|
|
66
|
+
if (!steps.length)
|
|
67
|
+
return null;
|
|
68
|
+
return {
|
|
69
|
+
explanation: typeof record.explanation === 'string' && record.explanation.trim() ? record.explanation.trim() : null,
|
|
70
|
+
steps,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** Parse a TodoWrite tool input into a StreamPreviewPlan. */
|
|
74
|
+
export function parseTodoWriteAsPlan(input) {
|
|
75
|
+
if (!input || typeof input !== 'object')
|
|
76
|
+
return null;
|
|
77
|
+
const rawTodos = Array.isArray(input.todos) ? input.todos : [];
|
|
78
|
+
if (!rawTodos.length)
|
|
79
|
+
return null;
|
|
80
|
+
const steps = [];
|
|
81
|
+
for (const todo of rawTodos) {
|
|
82
|
+
if (!todo || typeof todo !== 'object')
|
|
83
|
+
continue;
|
|
84
|
+
const content = typeof todo.content === 'string' ? todo.content.trim() : '';
|
|
85
|
+
if (!content)
|
|
86
|
+
continue;
|
|
87
|
+
const rawStatus = typeof todo.status === 'string' ? todo.status : 'pending';
|
|
88
|
+
const status = rawStatus === 'completed' ? 'completed'
|
|
89
|
+
: rawStatus === 'in_progress' ? 'inProgress'
|
|
90
|
+
: 'pending';
|
|
91
|
+
steps.push({ step: content, status });
|
|
92
|
+
}
|
|
93
|
+
if (!steps.length)
|
|
94
|
+
return null;
|
|
95
|
+
return { explanation: null, steps };
|
|
96
|
+
}
|
|
97
|
+
export function normalizeActivityLine(text) { return text.replace(/\s+/g, ' ').trim(); }
|
|
98
|
+
// The activity feed is only ever rendered as a tail — downstream previews trim
|
|
99
|
+
// it to ~900 chars (trimActivityForPreview) and the final reply to ~1600 — yet
|
|
100
|
+
// every tool event rebuilds `s.activity = recentActivity.join('\n')`. A 500-line
|
|
101
|
+
// cap made that rejoin effectively O(n²) over a tool-heavy turn for history no
|
|
102
|
+
// view ever shows; 80 lines comfortably covers the largest consumer.
|
|
103
|
+
export function pushRecentActivity(lines, line, maxLines = 80) {
|
|
104
|
+
const cleaned = normalizeActivityLine(line);
|
|
105
|
+
if (!cleaned)
|
|
106
|
+
return;
|
|
107
|
+
if (lines[lines.length - 1] === cleaned)
|
|
108
|
+
return;
|
|
109
|
+
lines.push(cleaned);
|
|
110
|
+
if (lines.length > maxLines)
|
|
111
|
+
lines.splice(0, lines.length - maxLines);
|
|
112
|
+
}
|
|
113
|
+
export function firstNonEmptyLine(text) {
|
|
114
|
+
for (const line of String(text || '').split('\n')) {
|
|
115
|
+
const trimmed = line.trim();
|
|
116
|
+
if (trimmed)
|
|
117
|
+
return trimmed;
|
|
118
|
+
}
|
|
119
|
+
return '';
|
|
120
|
+
}
|
|
121
|
+
// MCP tool results carry structured content blocks (e.g. screenshot returns
|
|
122
|
+
// `[{type:'image',...}, {type:'text', text:'Saved as ...'}]`). Coerce that to
|
|
123
|
+
// plain text by keeping only the `type:'text'` blocks; otherwise `String([{…}])`
|
|
124
|
+
// silently becomes the literal "[object Object]" in activity summaries.
|
|
125
|
+
export function coerceToolResultText(value) {
|
|
126
|
+
if (typeof value === 'string')
|
|
127
|
+
return value;
|
|
128
|
+
if (Array.isArray(value)) {
|
|
129
|
+
return value
|
|
130
|
+
.filter((c) => c && c.type === 'text' && typeof c.text === 'string')
|
|
131
|
+
.map((c) => c.text)
|
|
132
|
+
.join('\n');
|
|
133
|
+
}
|
|
134
|
+
return '';
|
|
135
|
+
}
|
|
136
|
+
export function shortValue(value, max = 90) {
|
|
137
|
+
const text = typeof value === 'string' ? value.trim() : value == null ? '' : String(value).trim();
|
|
138
|
+
if (!text)
|
|
139
|
+
return '';
|
|
140
|
+
if (text.length <= max)
|
|
141
|
+
return text;
|
|
142
|
+
return `${text.slice(0, Math.max(0, max - 3)).trimEnd()}...`;
|
|
143
|
+
}
|
|
144
|
+
export function normalizeErrorMessage(value) {
|
|
145
|
+
if (typeof value === 'string')
|
|
146
|
+
return value.trim();
|
|
147
|
+
if (value instanceof Error)
|
|
148
|
+
return value.message.trim();
|
|
149
|
+
if (Array.isArray(value)) {
|
|
150
|
+
return value.map(item => normalizeErrorMessage(item)).filter(Boolean).join('; ').trim();
|
|
151
|
+
}
|
|
152
|
+
if (value && typeof value === 'object') {
|
|
153
|
+
const record = value;
|
|
154
|
+
const preferred = normalizeErrorMessage(record.message)
|
|
155
|
+
|| normalizeErrorMessage(record.error)
|
|
156
|
+
|| normalizeErrorMessage(record.detail)
|
|
157
|
+
|| normalizeErrorMessage(record.type)
|
|
158
|
+
|| normalizeErrorMessage(record.code)
|
|
159
|
+
|| normalizeErrorMessage(record.status);
|
|
160
|
+
if (preferred)
|
|
161
|
+
return preferred;
|
|
162
|
+
try {
|
|
163
|
+
return JSON.stringify(value).trim();
|
|
164
|
+
}
|
|
165
|
+
catch { }
|
|
166
|
+
}
|
|
167
|
+
return value == null ? '' : String(value).trim();
|
|
168
|
+
}
|
|
169
|
+
export function joinErrorMessages(errors) {
|
|
170
|
+
if (!errors?.length)
|
|
171
|
+
return '';
|
|
172
|
+
return errors.map(error => normalizeErrorMessage(error)).filter(Boolean).join('; ').trim();
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Detect Claude Code's synthetic "API Error: …" assistant message. When the
|
|
176
|
+
* upstream Anthropic API returns a transient error (529 Overloaded, 5xx, gateway
|
|
177
|
+
* timeouts, …), the Claude CLI swallows it and replaces the assistant turn with
|
|
178
|
+
* a single `text` block whose body is literally `API Error: <reason>`. The
|
|
179
|
+
* turn's stop_reason still claims `end_turn`, so the driver can't distinguish
|
|
180
|
+
* it from a normal short reply without inspecting the text.
|
|
181
|
+
*
|
|
182
|
+
* Heuristics — keep them tight so real prose mentioning "API Error" doesn't
|
|
183
|
+
* trip the detector:
|
|
184
|
+
* - exact prefix "API Error: "
|
|
185
|
+
* - total length ≤ 200 chars (the synthetic line is always short)
|
|
186
|
+
* - no newlines (legit prose containing "API Error" virtually always wraps)
|
|
187
|
+
*
|
|
188
|
+
* Returns the trimmed reason (e.g. "Overloaded", "Internal server error") when
|
|
189
|
+
* matched, otherwise null. Callers decide whether the reason is retryable —
|
|
190
|
+
* `looksRetryable` answers that.
|
|
191
|
+
*/
|
|
192
|
+
export function detectClaudeApiError(text) {
|
|
193
|
+
if (!text)
|
|
194
|
+
return null;
|
|
195
|
+
const trimmed = text.trim();
|
|
196
|
+
if (trimmed.length > 200 || trimmed.includes('\n'))
|
|
197
|
+
return null;
|
|
198
|
+
const m = trimmed.match(/^API Error:\s*(.+)$/i);
|
|
199
|
+
return m ? m[1].trim() : null;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Retryable Claude Code API errors — transient upstream conditions that
|
|
203
|
+
* usually clear within seconds. Non-retryable conditions (auth, quota,
|
|
204
|
+
* context length) fall through and surface to the user immediately.
|
|
205
|
+
*/
|
|
206
|
+
export function isRetryableClaudeApiError(reason) {
|
|
207
|
+
const r = reason.toLowerCase();
|
|
208
|
+
if (/rate limit|rate limited|quota|usage limit|session limit/i.test(r))
|
|
209
|
+
return false;
|
|
210
|
+
return /overloaded|overload|timeout|timed out|500|502|503|504|529|temporar|gateway|connection|network|internal (server )?error/i.test(r);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Detect Claude Code's "selected model is unavailable" notice — emitted when
|
|
214
|
+
* the requested `--model` id is disabled / not provisioned for the account (a
|
|
215
|
+
* 404 model_not_found). Its delivery differs by mode:
|
|
216
|
+
* - `-p`/stream-json: a `<synthetic>` assistant event carrying
|
|
217
|
+
* `error:"model_not_found"` plus the banner text, and a `result` event with
|
|
218
|
+
* `is_error` — both reach the parser.
|
|
219
|
+
* - TUI: the banner is *only* painted to the PTY screen. It is never written
|
|
220
|
+
* to the transcript JSONL and fires no Stop hook (verified 2026-06-13), so
|
|
221
|
+
* the screen scrape is the sole signal and the turn would otherwise hang
|
|
222
|
+
* until the stall watchdog (3–10 min).
|
|
223
|
+
*
|
|
224
|
+
* Matching is whitespace-insensitive on purpose: the TUI renders the banner
|
|
225
|
+
* character-by-character with cursor positioning, so after ANSI stripping the
|
|
226
|
+
* words lose their spaces and wrap arbitrarily ("issuewiththeselectedmodel").
|
|
227
|
+
* Collapsing whitespace on both sides makes the match survive that rendering.
|
|
228
|
+
* Callers compose the user-facing message via `claudeModelErrorMessage` with
|
|
229
|
+
* the concrete model id they hold.
|
|
230
|
+
*/
|
|
231
|
+
export function detectClaudeModelError(text) {
|
|
232
|
+
if (!text)
|
|
233
|
+
return false;
|
|
234
|
+
const collapsed = text.replace(/\s+/g, '').toLowerCase();
|
|
235
|
+
return collapsed.includes('issuewiththeselectedmodel')
|
|
236
|
+
|| collapsed.includes('maynotexistoryoumaynothaveaccess');
|
|
237
|
+
}
|
|
238
|
+
/** User-facing message for an unavailable / no-access model (see {@link detectClaudeModelError}). */
|
|
239
|
+
export function claudeModelErrorMessage(model) {
|
|
240
|
+
const id = (model || '').trim();
|
|
241
|
+
return `The selected model${id ? ` (${id})` : ''} is unavailable — it may not exist, or this account doesn't have access to it. Switch to a different model in pikiloop settings.`;
|
|
242
|
+
}
|
|
243
|
+
export function appendSystemPrompt(base, extra) {
|
|
244
|
+
const lhs = String(base || '').trim();
|
|
245
|
+
const rhs = String(extra || '').trim();
|
|
246
|
+
if (!lhs)
|
|
247
|
+
return rhs;
|
|
248
|
+
if (!rhs)
|
|
249
|
+
return lhs;
|
|
250
|
+
return `${lhs}\n\n${rhs}`;
|
|
251
|
+
}
|
|
252
|
+
export function mimeForExt(ext) {
|
|
253
|
+
switch (ext) {
|
|
254
|
+
case '.jpg':
|
|
255
|
+
case '.jpeg': return 'image/jpeg';
|
|
256
|
+
case '.png': return 'image/png';
|
|
257
|
+
case '.gif': return 'image/gif';
|
|
258
|
+
case '.webp': return 'image/webp';
|
|
259
|
+
default: return 'application/octet-stream';
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
export function computeContext(s) {
|
|
263
|
+
const fallbackTotal = (s.inputTokens ?? 0) + (s.cachedInputTokens ?? 0) + (s.cacheCreationInputTokens ?? 0);
|
|
264
|
+
const used = s.contextUsedTokens ?? (fallbackTotal > 0 ? fallbackTotal : null);
|
|
265
|
+
const pct = used != null && s.contextWindow
|
|
266
|
+
? Math.min(99.9, Math.round(used / s.contextWindow * 1000) / 10)
|
|
267
|
+
: null;
|
|
268
|
+
return { contextUsedTokens: used, contextPercent: pct };
|
|
269
|
+
}
|
|
270
|
+
/** Max structured tool calls carried per preview emit (most recent win). */
|
|
271
|
+
const PREVIEW_TOOL_CALLS_MAX = 40;
|
|
272
|
+
export function buildStreamPreviewMeta(s) {
|
|
273
|
+
const ctx = computeContext(s);
|
|
274
|
+
const meta = {
|
|
275
|
+
inputTokens: s.inputTokens, outputTokens: s.outputTokens,
|
|
276
|
+
cachedInputTokens: s.cachedInputTokens,
|
|
277
|
+
contextUsedTokens: ctx.contextUsedTokens, contextPercent: ctx.contextPercent,
|
|
278
|
+
};
|
|
279
|
+
// Turn-cumulative output: finished calls' total + the in-flight call.
|
|
280
|
+
const turnOutput = (s.turnOutputTokensBase ?? 0) + (s.outputTokens ?? 0);
|
|
281
|
+
if (turnOutput > 0)
|
|
282
|
+
meta.turnOutputTokens = turnOutput;
|
|
283
|
+
if (s.byokProviderName)
|
|
284
|
+
meta.providerName = s.byokProviderName;
|
|
285
|
+
if (s.subAgents && s.subAgents.size > 0)
|
|
286
|
+
meta.subAgents = Array.from(s.subAgents.values());
|
|
287
|
+
if (s.generatingImages && s.generatingImages > 0)
|
|
288
|
+
meta.generatingImages = s.generatingImages;
|
|
289
|
+
if (s.claudeToolCallOrder?.length && s.claudeToolsById) {
|
|
290
|
+
const calls = [];
|
|
291
|
+
for (const id of s.claudeToolCallOrder.slice(-PREVIEW_TOOL_CALLS_MAX)) {
|
|
292
|
+
const tool = s.claudeToolsById.get(id);
|
|
293
|
+
if (!tool)
|
|
294
|
+
continue;
|
|
295
|
+
calls.push({
|
|
296
|
+
id,
|
|
297
|
+
name: tool.name,
|
|
298
|
+
summary: tool.summary,
|
|
299
|
+
input: tool.input ?? null,
|
|
300
|
+
result: tool.result ?? null,
|
|
301
|
+
status: tool.status ?? 'running',
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
if (calls.length)
|
|
305
|
+
meta.toolCalls = calls;
|
|
306
|
+
}
|
|
307
|
+
return meta;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Bounded, human-readable input detail for a live tool-call row. Bash shows
|
|
311
|
+
* the raw command (the summary already carries the description); everything
|
|
312
|
+
* else gets compact JSON. Returns null when there's nothing beyond the
|
|
313
|
+
* summary worth expanding.
|
|
314
|
+
*/
|
|
315
|
+
export function previewToolCallInput(name, input, max = 500) {
|
|
316
|
+
if (input == null)
|
|
317
|
+
return null;
|
|
318
|
+
if (String(name) === 'Bash') {
|
|
319
|
+
const cmd = typeof input.command === 'string' ? input.command.trim() : '';
|
|
320
|
+
return cmd ? clipText(cmd, max) : null;
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
const json = JSON.stringify(input, null, 1);
|
|
324
|
+
if (!json || json === '{}' || json === 'null')
|
|
325
|
+
return null;
|
|
326
|
+
return clipText(json, max);
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Bounded text preview of a tool result. Accepts the JSONL tool_result
|
|
334
|
+
* `content` (string | block array) or a hook `tool_response` (string |
|
|
335
|
+
* object). Extracts text blocks where present, falls back to compact JSON.
|
|
336
|
+
*/
|
|
337
|
+
export function previewToolCallResult(content, max = 500) {
|
|
338
|
+
if (content == null)
|
|
339
|
+
return null;
|
|
340
|
+
if (typeof content === 'string')
|
|
341
|
+
return clipText(content.trim(), max) || null;
|
|
342
|
+
if (Array.isArray(content)) {
|
|
343
|
+
const text = content
|
|
344
|
+
.filter((b) => b?.type === 'text' && typeof b.text === 'string')
|
|
345
|
+
.map((b) => b.text)
|
|
346
|
+
.join('\n')
|
|
347
|
+
.trim();
|
|
348
|
+
return text ? clipText(text, max) : null;
|
|
349
|
+
}
|
|
350
|
+
if (typeof content === 'object') {
|
|
351
|
+
// Hook tool_response commonly nests the payload under `content` / `result`.
|
|
352
|
+
if (content.content != null && content.content !== content) {
|
|
353
|
+
const nested = previewToolCallResult(content.content, max);
|
|
354
|
+
if (nested)
|
|
355
|
+
return nested;
|
|
356
|
+
}
|
|
357
|
+
if (typeof content.result === 'string')
|
|
358
|
+
return clipText(content.result.trim(), max) || null;
|
|
359
|
+
try {
|
|
360
|
+
const json = JSON.stringify(content, null, 1);
|
|
361
|
+
if (!json || json === '{}')
|
|
362
|
+
return null;
|
|
363
|
+
return clipText(json, max);
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
function clipText(text, max) {
|
|
372
|
+
if (text.length <= max)
|
|
373
|
+
return text;
|
|
374
|
+
return `${text.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
|
|
375
|
+
}
|
|
376
|
+
// Claude tool use helpers (used by driver-claude.ts)
|
|
377
|
+
export function summarizeClaudeToolUse(name, input) {
|
|
378
|
+
const tool = String(name || '').trim() || 'Tool';
|
|
379
|
+
const description = shortValue(input?.description, 120);
|
|
380
|
+
switch (tool) {
|
|
381
|
+
case 'Read': {
|
|
382
|
+
const t = shortValue(input?.file_path || input?.path, 140);
|
|
383
|
+
return t ? `Read ${t}` : 'Read file';
|
|
384
|
+
}
|
|
385
|
+
case 'Edit': {
|
|
386
|
+
const t = shortValue(input?.file_path || input?.path, 140);
|
|
387
|
+
return t ? `Edit ${t}` : 'Edit file';
|
|
388
|
+
}
|
|
389
|
+
case 'Write': {
|
|
390
|
+
const t = shortValue(input?.file_path || input?.path, 140);
|
|
391
|
+
return t ? `Write ${t}` : 'Write file';
|
|
392
|
+
}
|
|
393
|
+
case 'Glob': {
|
|
394
|
+
const p = shortValue(input?.pattern || input?.glob, 120);
|
|
395
|
+
return p ? `List files: ${p}` : 'List files';
|
|
396
|
+
}
|
|
397
|
+
case 'Grep': {
|
|
398
|
+
const p = shortValue(input?.pattern || input?.query, 120);
|
|
399
|
+
return p ? `Search text: ${p}` : 'Search text';
|
|
400
|
+
}
|
|
401
|
+
case 'WebFetch': {
|
|
402
|
+
const u = shortValue(input?.url, 120);
|
|
403
|
+
return u ? `Fetch ${u}` : 'Fetch web page';
|
|
404
|
+
}
|
|
405
|
+
case 'WebSearch': {
|
|
406
|
+
const q = shortValue(input?.query, 120);
|
|
407
|
+
return q ? `Search web: ${q}` : 'Search web';
|
|
408
|
+
}
|
|
409
|
+
case 'TodoWrite': return 'Update plan';
|
|
410
|
+
case 'AskUserQuestion': {
|
|
411
|
+
// Claude's built-in clarify tool. The CLI in `-p` mode self-resolves it
|
|
412
|
+
// with an error and degrades to a plain-text question in the same turn —
|
|
413
|
+
// we just surface the question text in the activity panel so users see
|
|
414
|
+
// what was asked.
|
|
415
|
+
const qs = Array.isArray(input?.questions) ? input.questions : [];
|
|
416
|
+
const first = qs[0];
|
|
417
|
+
const q = shortValue(first?.question || input?.question, 120);
|
|
418
|
+
return q ? `Ask user: ${q}` : 'Ask user';
|
|
419
|
+
}
|
|
420
|
+
case 'Task': {
|
|
421
|
+
const p = shortValue(input?.description || input?.prompt, 120);
|
|
422
|
+
return p ? `Run task: ${p}` : 'Run task';
|
|
423
|
+
}
|
|
424
|
+
case 'Bash': {
|
|
425
|
+
if (description)
|
|
426
|
+
return `Run shell: ${description}`;
|
|
427
|
+
const c = shortValue(input?.command, 120);
|
|
428
|
+
return c ? `Run shell: ${c}` : 'Run shell command';
|
|
429
|
+
}
|
|
430
|
+
default: {
|
|
431
|
+
// MCP tools come through as `mcp__<server>__<tool>` — unwrap common pikiloop tools
|
|
432
|
+
const mcpMatch = tool.match(/^mcp__[^_]+__(.+)$/);
|
|
433
|
+
const bare = mcpMatch ? mcpMatch[1] : tool;
|
|
434
|
+
if (bare === 'im_send_file') {
|
|
435
|
+
const p = shortValue(input?.path, 120);
|
|
436
|
+
return p ? `Send file: ${p}` : 'Send file';
|
|
437
|
+
}
|
|
438
|
+
if (bare === 'im_list_files')
|
|
439
|
+
return 'List workspace files';
|
|
440
|
+
if (bare === 'im_ask_user') {
|
|
441
|
+
const q = shortValue(input?.question, 120);
|
|
442
|
+
return q ? `Ask user: ${q}` : 'Ask user';
|
|
443
|
+
}
|
|
444
|
+
if (description)
|
|
445
|
+
return `${tool}: ${description}`;
|
|
446
|
+
const d = shortValue(input?.file_path || input?.path || input?.command || input?.query || input?.pattern || input?.url, 120);
|
|
447
|
+
return d ? `${tool}: ${d}` : tool;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
export function summarizeClaudeToolResult(tool, block, toolUseResult) {
|
|
452
|
+
const summary = tool?.summary || shortValue(tool?.name || 'Tool', 120) || 'Tool';
|
|
453
|
+
const isError = !!block?.is_error;
|
|
454
|
+
const contentText = coerceToolResultText(block?.content);
|
|
455
|
+
if (isError) {
|
|
456
|
+
const detail = firstNonEmptyLine(toolUseResult?.stderr || toolUseResult?.stdout || contentText);
|
|
457
|
+
return detail ? `${summary} failed: ${shortValue(detail, 120)}` : `${summary} failed`;
|
|
458
|
+
}
|
|
459
|
+
const toolName = tool?.name || '';
|
|
460
|
+
if (toolName === 'Read' || toolName === 'Edit' || toolName === 'Write' || toolName === 'TodoWrite')
|
|
461
|
+
return `${summary} done`;
|
|
462
|
+
const detail = firstNonEmptyLine(toolUseResult?.stdout || contentText || toolUseResult?.stderr || '');
|
|
463
|
+
if (!detail)
|
|
464
|
+
return `${summary} done`;
|
|
465
|
+
return `${summary} -> ${shortValue(detail, 120)}`;
|
|
466
|
+
}
|
|
467
|
+
// Usage helpers (used by drivers)
|
|
468
|
+
export function roundPercent(value) {
|
|
469
|
+
const n = Number(value);
|
|
470
|
+
if (!Number.isFinite(n))
|
|
471
|
+
return null;
|
|
472
|
+
return Math.max(0, Math.min(100, Math.round(n * 10) / 10));
|
|
473
|
+
}
|
|
474
|
+
export function toIsoFromEpochSeconds(value) {
|
|
475
|
+
const n = Number(value);
|
|
476
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
477
|
+
return null;
|
|
478
|
+
return new Date(n * 1000).toISOString();
|
|
479
|
+
}
|
|
480
|
+
export function normalizeUsageStatus(value) {
|
|
481
|
+
const raw = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
482
|
+
if (!raw)
|
|
483
|
+
return null;
|
|
484
|
+
const normalized = raw.replace(/[\s-]+/g, '_');
|
|
485
|
+
if (normalized === 'limit_reached' || normalized === 'warning' || normalized === 'allowed')
|
|
486
|
+
return normalized;
|
|
487
|
+
if (normalized.includes('limit') || normalized.includes('exceeded') || normalized.includes('denied'))
|
|
488
|
+
return 'limit_reached';
|
|
489
|
+
if (normalized.includes('warning') || normalized.includes('warn'))
|
|
490
|
+
return 'warning';
|
|
491
|
+
if (normalized.includes('allowed') || normalized === 'ok' || normalized === 'healthy' || normalized === 'ready')
|
|
492
|
+
return 'allowed';
|
|
493
|
+
return normalized;
|
|
494
|
+
}
|
|
495
|
+
export function labelFromWindowMinutes(value, fallback) {
|
|
496
|
+
const minutes = Number(value);
|
|
497
|
+
if (!Number.isFinite(minutes) || minutes <= 0)
|
|
498
|
+
return fallback;
|
|
499
|
+
const roundedMinutes = Math.round(minutes);
|
|
500
|
+
if (Math.abs(roundedMinutes - 300) <= 2)
|
|
501
|
+
return '5h';
|
|
502
|
+
if (Math.abs(roundedMinutes - 10080) <= 5)
|
|
503
|
+
return '7d';
|
|
504
|
+
const roundedDays = Math.round(roundedMinutes / 1440);
|
|
505
|
+
if (roundedDays >= 1 && Math.abs(roundedMinutes - roundedDays * 1440) <= 5)
|
|
506
|
+
return `${roundedDays}d`;
|
|
507
|
+
const roundedHours = Math.round(roundedMinutes / 60);
|
|
508
|
+
if (roundedHours >= 1 && Math.abs(roundedMinutes - roundedHours * 60) <= 2)
|
|
509
|
+
return `${roundedHours}h`;
|
|
510
|
+
return `${roundedMinutes}m`;
|
|
511
|
+
}
|
|
512
|
+
export function usageWindowFromRateLimit(fallback, limit) {
|
|
513
|
+
if (!limit || typeof limit !== 'object')
|
|
514
|
+
return null;
|
|
515
|
+
const usedPercent = roundPercent(limit.used_percent);
|
|
516
|
+
const remainingPercent = usedPercent == null ? null : Math.max(0, Math.round((100 - usedPercent) * 10) / 10);
|
|
517
|
+
const resetAt = toIsoFromEpochSeconds(limit.reset_at ?? limit.resets_at);
|
|
518
|
+
let resetAfterSeconds = null;
|
|
519
|
+
const directResetAfter = Number(limit.reset_after_seconds);
|
|
520
|
+
if (Number.isFinite(directResetAfter) && directResetAfter >= 0)
|
|
521
|
+
resetAfterSeconds = Math.round(directResetAfter);
|
|
522
|
+
else if (resetAt) {
|
|
523
|
+
const resetAtMs = Date.parse(resetAt);
|
|
524
|
+
if (Number.isFinite(resetAtMs))
|
|
525
|
+
resetAfterSeconds = Math.max(0, Math.round((resetAtMs - Date.now()) / 1000));
|
|
526
|
+
}
|
|
527
|
+
return {
|
|
528
|
+
label: labelFromWindowMinutes(limit.window_minutes, fallback),
|
|
529
|
+
usedPercent, remainingPercent, resetAt, resetAfterSeconds,
|
|
530
|
+
status: normalizeUsageStatus(limit.status),
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
export function parseJsonTail(raw) {
|
|
534
|
+
const start = raw.indexOf('{');
|
|
535
|
+
if (start < 0)
|
|
536
|
+
return null;
|
|
537
|
+
try {
|
|
538
|
+
return JSON.parse(raw.slice(start));
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
export function modelFamily(model) {
|
|
545
|
+
const lower = model?.toLowerCase() || '';
|
|
546
|
+
if (!lower)
|
|
547
|
+
return null;
|
|
548
|
+
if (lower.includes('fable'))
|
|
549
|
+
return 'fable';
|
|
550
|
+
if (lower.includes('opus'))
|
|
551
|
+
return 'opus';
|
|
552
|
+
if (lower.includes('sonnet'))
|
|
553
|
+
return 'sonnet';
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
export function normalizeClaudeModelId(model) {
|
|
557
|
+
return typeof model === 'string' ? model.trim() : '';
|
|
558
|
+
}
|
|
559
|
+
export function emptyUsage(agent, error) {
|
|
560
|
+
return { ok: false, agent, source: null, capturedAt: null, status: null, windows: [], error };
|
|
561
|
+
}
|
|
562
|
+
export function readTailLines(filePath, maxBytes = 256 * 1024) {
|
|
563
|
+
try {
|
|
564
|
+
const stat = fs.statSync(filePath);
|
|
565
|
+
const readSize = Math.min(maxBytes, stat.size);
|
|
566
|
+
const fd = fs.openSync(filePath, 'r');
|
|
567
|
+
const buf = Buffer.alloc(readSize);
|
|
568
|
+
fs.readSync(fd, buf, 0, readSize, stat.size - readSize);
|
|
569
|
+
fs.closeSync(fd);
|
|
570
|
+
return buf.toString('utf-8').split('\n').filter(l => l.trim());
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
return [];
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
export function stripInjectedPrompts(text) {
|
|
577
|
+
const markers = ['\n[Session Workspace]'];
|
|
578
|
+
for (const m of markers) {
|
|
579
|
+
const idx = text.indexOf(m);
|
|
580
|
+
if (idx >= 0)
|
|
581
|
+
text = text.slice(0, idx).trim();
|
|
582
|
+
}
|
|
583
|
+
// Strip Codex IDE context prefix ("# Context from my IDE setup: ...")
|
|
584
|
+
if (text.startsWith('# Context from')) {
|
|
585
|
+
const tag = '## My request for Codex:\n';
|
|
586
|
+
const idx = text.indexOf(tag);
|
|
587
|
+
if (idx >= 0)
|
|
588
|
+
return text.slice(idx + tag.length).trim();
|
|
589
|
+
return '';
|
|
590
|
+
}
|
|
591
|
+
return text;
|
|
592
|
+
}
|
|
593
|
+
export const SESSION_PREVIEW_IGNORED_USER_PATTERNS = [
|
|
594
|
+
/^\[Request interrupted by user(?: for tool use)?\]$/i,
|
|
595
|
+
];
|
|
596
|
+
export const SESSION_PREVIEW_IMAGE_PLACEHOLDER_RE = /\[Image:[^\]]+\]/gi;
|
|
597
|
+
export const SESSION_PREVIEW_FILE_PLACEHOLDER_RE = /\[Attached file:[^\]]+\]/gi;
|
|
598
|
+
/**
|
|
599
|
+
* Claude TUI mode prepends `@/abs/path/file.ext` mentions to the prompt as
|
|
600
|
+
* positional argv (see `src/agent/drivers/claude-tui.ts`) — that's how the TUI
|
|
601
|
+
* ingests local image files. The mentions end up baked into the JSONL user
|
|
602
|
+
* `content` string verbatim. This regex captures them so:
|
|
603
|
+
* - the dashboard's user bubble (via `getClaudeSessionMessages`) can lift
|
|
604
|
+
* them into structured `image` blocks for thumbnail rendering;
|
|
605
|
+
* - session-list previews don't surface a long absolute path.
|
|
606
|
+
* Whitespace-free paths only — matches what `claude-tui.ts` emits.
|
|
607
|
+
*/
|
|
608
|
+
export const CLAUDE_AT_MENTION_IMAGE_RE = /(^|\s)@(\/[^\s@\n]+\.(?:png|jpe?g|gif|webp|svg))(?=\s|$)/gi;
|
|
609
|
+
/** Pull the absolute paths out of every image-mention in `text`. */
|
|
610
|
+
export function extractClaudeAtMentionImagePaths(text) {
|
|
611
|
+
if (!text)
|
|
612
|
+
return [];
|
|
613
|
+
const out = [];
|
|
614
|
+
for (const m of text.matchAll(CLAUDE_AT_MENTION_IMAGE_RE))
|
|
615
|
+
out.push(m[2]);
|
|
616
|
+
return out;
|
|
617
|
+
}
|
|
618
|
+
/** Remove image @-mentions from `text` while preserving the leading boundary
|
|
619
|
+
* character (start-of-string or whitespace) so adjacent content stays joinable. */
|
|
620
|
+
export function stripClaudeAtMentionImages(text) {
|
|
621
|
+
if (!text)
|
|
622
|
+
return text;
|
|
623
|
+
return text.replace(CLAUDE_AT_MENTION_IMAGE_RE, (_full, leading) => leading || '');
|
|
624
|
+
}
|
|
625
|
+
export function sanitizeSessionUserPreviewText(text) {
|
|
626
|
+
const cleaned = stripInjectedPrompts(text)
|
|
627
|
+
.replace(SESSION_PREVIEW_IMAGE_PLACEHOLDER_RE, ' ')
|
|
628
|
+
.replace(SESSION_PREVIEW_FILE_PLACEHOLDER_RE, ' ')
|
|
629
|
+
.replace(CLAUDE_AT_MENTION_IMAGE_RE, ' ')
|
|
630
|
+
.replace(/\s+/g, ' ')
|
|
631
|
+
.trim();
|
|
632
|
+
if (!cleaned)
|
|
633
|
+
return '';
|
|
634
|
+
if (SESSION_PREVIEW_IGNORED_USER_PATTERNS.some(pattern => pattern.test(cleaned)))
|
|
635
|
+
return '';
|
|
636
|
+
return cleaned;
|
|
637
|
+
}
|
|
638
|
+
export function isPendingSessionId(sessionId) {
|
|
639
|
+
return typeof sessionId === 'string' && sessionId.startsWith('pending_');
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Update a stream-state's session id and notify the caller in one step.
|
|
643
|
+
*
|
|
644
|
+
* Drivers used to assign `s.sessionId = ev.session_id ?? s.sessionId` at every
|
|
645
|
+
* place the CLI surfaced an id, then leave promotion until `finalizeStreamResult`
|
|
646
|
+
* at end-of-stream. That meant an early abort (before the result line) or a
|
|
647
|
+
* mid-stream rotation (Claude `--resume` rewriting the session id) was invisible
|
|
648
|
+
* to the bot runtime — leaving the runtime stuck on a pending id, or letting a
|
|
649
|
+
* later insertion land on a phantom session. Routing through this helper makes
|
|
650
|
+
* every observed id change propagate immediately to `opts.onSessionId`, which
|
|
651
|
+
* in bot.ts wires straight into `promoteSessionRuntime`.
|
|
652
|
+
*/
|
|
653
|
+
export function emitSessionIdUpdate(s, rawId) {
|
|
654
|
+
if (typeof rawId !== 'string')
|
|
655
|
+
return;
|
|
656
|
+
const trimmed = rawId.trim();
|
|
657
|
+
if (!trimmed || trimmed === s.sessionId)
|
|
658
|
+
return;
|
|
659
|
+
s.sessionId = trimmed;
|
|
660
|
+
try {
|
|
661
|
+
s._emitSessionId?.(trimmed);
|
|
662
|
+
}
|
|
663
|
+
catch { /* listeners must not break the stream loop */ }
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Canonical session-list display title used by *every* surface (IM channels
|
|
667
|
+
* + dashboard). The order is intentional:
|
|
668
|
+
*
|
|
669
|
+
* 1. `title` — set ONCE from the original user prompt that started the
|
|
670
|
+
* session. Stable; never overwritten by sub-agent or tool prompts.
|
|
671
|
+
* 2. `lastQuestion` — most recent user message. Fallback only, because for
|
|
672
|
+
* Claude this can be a Task-tool sub-agent prompt and we don't want
|
|
673
|
+
* sub-agent text leaking into the title.
|
|
674
|
+
* 3. `sessionId` — last-resort identifier.
|
|
675
|
+
*
|
|
676
|
+
* The dashboard frontend (`dashboard/src/utils.ts`) mirrors this order — keep
|
|
677
|
+
* the two in sync.
|
|
678
|
+
*/
|
|
679
|
+
export function sessionListDisplayTitle(session) {
|
|
680
|
+
const title = sanitizeSessionUserPreviewText(String(session.title || ''));
|
|
681
|
+
if (title)
|
|
682
|
+
return title;
|
|
683
|
+
const question = sanitizeSessionUserPreviewText(String(session.lastQuestion || ''));
|
|
684
|
+
if (question)
|
|
685
|
+
return question;
|
|
686
|
+
return session.sessionId || '';
|
|
687
|
+
}
|