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,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared rendering utilities used by channel-specific renderers.
|
|
3
|
+
*
|
|
4
|
+
* Contains types, pure-data helpers, and functions that are identical across platforms.
|
|
5
|
+
* Platform-specific formatting (HTML vs Markdown) stays in the respective render files.
|
|
6
|
+
*/
|
|
7
|
+
import { materializeImage } from '../agent/index.js';
|
|
8
|
+
import { fmtUptime, formatThinkingForDisplay, thinkLabel } from './bot.js';
|
|
9
|
+
import { formatActivityCommandSummary, parseActivitySummary, renderPlanForPreview, summarizeActivityForPreview } from './streaming.js';
|
|
10
|
+
import { supportsChannelCapability } from '../channels/base.js';
|
|
11
|
+
import { agentLog, agentWarn } from '../agent/index.js';
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// GFM table parsing
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
/** Parse GFM table lines into structured headers + rows. */
|
|
16
|
+
export function parseGfmTable(tableLines) {
|
|
17
|
+
if (tableLines.length < 3)
|
|
18
|
+
return null;
|
|
19
|
+
const parseRow = (line) => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim());
|
|
20
|
+
const isSep = (line) => {
|
|
21
|
+
const cells = parseRow(line);
|
|
22
|
+
return cells.length > 0 && cells.every(c => /^:?-{2,}:?$/.test(c));
|
|
23
|
+
};
|
|
24
|
+
let headerIdx = -1;
|
|
25
|
+
for (let i = 0; i < tableLines.length - 1; i++) {
|
|
26
|
+
if (isSep(tableLines[i + 1])) {
|
|
27
|
+
headerIdx = i;
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (headerIdx < 0)
|
|
32
|
+
return null;
|
|
33
|
+
const headers = parseRow(tableLines[headerIdx]);
|
|
34
|
+
const rows = [];
|
|
35
|
+
for (let i = headerIdx + 2; i < tableLines.length; i++) {
|
|
36
|
+
if (isSep(tableLines[i]))
|
|
37
|
+
continue;
|
|
38
|
+
rows.push(parseRow(tableLines[i]));
|
|
39
|
+
}
|
|
40
|
+
return rows.length ? { headers, rows } : null;
|
|
41
|
+
}
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Footer helpers
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
export function fmtCompactUptime(ms) {
|
|
46
|
+
return fmtUptime(ms).replace(/\s+/g, '');
|
|
47
|
+
}
|
|
48
|
+
export function footerStatusSymbol(status) {
|
|
49
|
+
switch (status) {
|
|
50
|
+
case 'running': return '●';
|
|
51
|
+
case 'done': return '✓';
|
|
52
|
+
case 'failed': return '✗';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Drop a leading `provider/` segment from long model ids so the footer stays
|
|
57
|
+
* readable on narrow IM clients. `anthropic/claude-sonnet-4` → `claude-sonnet-4`,
|
|
58
|
+
* `deepseek/deepseek-v4-flash` → `deepseek-v4-flash`. Already-short ids are
|
|
59
|
+
* returned unchanged.
|
|
60
|
+
*/
|
|
61
|
+
function compactModelLabel(model) {
|
|
62
|
+
const trimmed = model.trim();
|
|
63
|
+
if (trimmed.length <= 24)
|
|
64
|
+
return trimmed;
|
|
65
|
+
const slashIdx = trimmed.indexOf('/');
|
|
66
|
+
return slashIdx > 0 ? trimmed.slice(slashIdx + 1) : trimmed;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Split footer fields into a primary identity line (agent + model) and a
|
|
70
|
+
* secondary runtime line (effort, context%, elapsed). Channel renderers
|
|
71
|
+
* compose the two lines with their own visual styling so narrow IM clients
|
|
72
|
+
* never have to soft-wrap a single dense line.
|
|
73
|
+
*/
|
|
74
|
+
export function formatFooterParts(agent, elapsedMs, meta, contextPercent, decorations) {
|
|
75
|
+
const identityParts = [agent];
|
|
76
|
+
if (decorations?.model)
|
|
77
|
+
identityParts.push(compactModelLabel(decorations.model));
|
|
78
|
+
const runtimeParts = [];
|
|
79
|
+
if (decorations?.effort)
|
|
80
|
+
runtimeParts.push(decorations.effort);
|
|
81
|
+
const ctx = contextPercent ?? meta?.contextPercent ?? null;
|
|
82
|
+
if (ctx != null)
|
|
83
|
+
runtimeParts.push(`${ctx}%`);
|
|
84
|
+
runtimeParts.push(fmtCompactUptime(Math.max(0, Math.round(elapsedMs))));
|
|
85
|
+
// BYOK attribution — tells the user the turn is being routed through a
|
|
86
|
+
// third-party provider rather than the agent CLI's native auth path.
|
|
87
|
+
// Tucked at the end of the runtime line so it doesn't crowd the (often
|
|
88
|
+
// long) identity line on narrow IM clients.
|
|
89
|
+
const providerName = meta?.providerName ?? decorations?.provider ?? null;
|
|
90
|
+
if (providerName)
|
|
91
|
+
runtimeParts.push(`via ${providerName}`);
|
|
92
|
+
return {
|
|
93
|
+
identity: identityParts.join(' · '),
|
|
94
|
+
runtime: runtimeParts.join(' · '),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Activity trimming
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
/**
|
|
101
|
+
* Trim the activity narrative for a streaming preview. Keeps the **most
|
|
102
|
+
* recent** lines that fit the budget — the user is watching the turn live,
|
|
103
|
+
* what just happened matters far more than what happened 30 tool-calls ago.
|
|
104
|
+
* A leading `...` marker signals "earlier activity dropped" when truncation
|
|
105
|
+
* happens; the tail order is preserved.
|
|
106
|
+
*/
|
|
107
|
+
export function trimActivityForPreview(text, maxChars = 900) {
|
|
108
|
+
if (text.length <= maxChars)
|
|
109
|
+
return text;
|
|
110
|
+
const lines = text.split('\n').filter(line => line.trim());
|
|
111
|
+
if (lines.length <= 1) {
|
|
112
|
+
// Single very-long line — keep the trailing characters with a leading
|
|
113
|
+
// ellipsis so the freshest content is visible.
|
|
114
|
+
return '...' + text.slice(text.length - Math.max(0, maxChars - 3));
|
|
115
|
+
}
|
|
116
|
+
const ellipsis = '...';
|
|
117
|
+
const budget = Math.max(0, maxChars - ellipsis.length - 1);
|
|
118
|
+
const tail = [];
|
|
119
|
+
let used = 0;
|
|
120
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
121
|
+
const line = lines[i];
|
|
122
|
+
const extra = line.length + (tail.length ? 1 : 0);
|
|
123
|
+
if (used + extra > budget)
|
|
124
|
+
break;
|
|
125
|
+
tail.unshift(line);
|
|
126
|
+
used += extra;
|
|
127
|
+
}
|
|
128
|
+
if (!tail.length) {
|
|
129
|
+
return ellipsis + '\n' + lines[lines.length - 1].slice(-Math.max(0, maxChars - ellipsis.length - 1));
|
|
130
|
+
}
|
|
131
|
+
if (tail.length === lines.length)
|
|
132
|
+
return tail.join('\n');
|
|
133
|
+
return [ellipsis, ...tail].join('\n');
|
|
134
|
+
}
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Provider usage (plain-text builder — caller wraps as needed)
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
function rawUsageLine(parts) {
|
|
139
|
+
return parts.filter(part => !!part && String(part).trim()).join(' ');
|
|
140
|
+
}
|
|
141
|
+
export function buildProviderUsageLines(usage) {
|
|
142
|
+
const lines = [
|
|
143
|
+
{ text: '', bold: false },
|
|
144
|
+
{ text: 'Provider Usage', bold: true },
|
|
145
|
+
];
|
|
146
|
+
if (!usage.ok) {
|
|
147
|
+
lines.push({ text: ` Unavailable: ${usage.error || 'No recent usage data found.'}` });
|
|
148
|
+
return lines;
|
|
149
|
+
}
|
|
150
|
+
if (usage.capturedAt) {
|
|
151
|
+
const capturedAtMs = Date.parse(usage.capturedAt);
|
|
152
|
+
if (Number.isFinite(capturedAtMs)) {
|
|
153
|
+
lines.push({ text: ` Updated: ${fmtUptime(Math.max(0, Date.now() - capturedAtMs))} ago` });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (!usage.windows.length) {
|
|
157
|
+
lines.push({ text: ` ${usage.status ? `status=${usage.status}` : 'No window data'}` });
|
|
158
|
+
return lines;
|
|
159
|
+
}
|
|
160
|
+
for (const window of usage.windows) {
|
|
161
|
+
const details = rawUsageLine([
|
|
162
|
+
window.usedPercent != null ? `${window.usedPercent}% used` : null,
|
|
163
|
+
window.status ? `status=${window.status}` : null,
|
|
164
|
+
window.resetAfterSeconds != null ? `resetAfterSeconds=${window.resetAfterSeconds}` : null,
|
|
165
|
+
]);
|
|
166
|
+
lines.push({ text: ` ${window.label}: ${details || 'No details'}` });
|
|
167
|
+
}
|
|
168
|
+
return lines;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Iterate an assistant turn's image MessageBlocks and dispatch each through
|
|
172
|
+
* the channel's `sendImage` capability. No-op when the channel doesn't claim
|
|
173
|
+
* `sendImage`. Errors per image are logged but don't block the rest of the
|
|
174
|
+
* dispatch — the text reply path is responsible for the user-visible summary.
|
|
175
|
+
*
|
|
176
|
+
* Returns the list of `{messageId, caption}` entries so the caller can register
|
|
177
|
+
* them with the session for "reply to continue" linkage.
|
|
178
|
+
*/
|
|
179
|
+
export async function dispatchImageBlocks(channel, blocks, opts) {
|
|
180
|
+
if (!blocks?.length)
|
|
181
|
+
return [];
|
|
182
|
+
if (!supportsChannelCapability(channel, 'sendImage'))
|
|
183
|
+
return [];
|
|
184
|
+
const out = [];
|
|
185
|
+
let index = 0;
|
|
186
|
+
for (const block of blocks) {
|
|
187
|
+
if (block.type !== 'image')
|
|
188
|
+
continue;
|
|
189
|
+
index++;
|
|
190
|
+
const materialized = materializeImage(block);
|
|
191
|
+
if (!materialized) {
|
|
192
|
+
(opts.log || agentLog)(`[image-dispatch] skipped block #${index}: could not materialize bytes`);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const messageId = await channel.sendImage(opts.chatId, materialized.bytes, {
|
|
197
|
+
mime: materialized.mime,
|
|
198
|
+
caption: materialized.caption,
|
|
199
|
+
replyTo: opts.replyTo,
|
|
200
|
+
messageThreadId: opts.messageThreadId,
|
|
201
|
+
});
|
|
202
|
+
out.push({ messageId, caption: materialized.caption });
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
(opts.log || agentWarn)(`[image-dispatch] send failed #${index}: ${err?.message || err}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
export function extractFinalReplyData(agent, result) {
|
|
211
|
+
const footerStatus = result.incomplete || !result.ok ? 'failed' : 'done';
|
|
212
|
+
const elapsedMs = result.elapsedS * 1000;
|
|
213
|
+
let activityNarrative = null;
|
|
214
|
+
let activityCommandSummary = null;
|
|
215
|
+
if (result.activity) {
|
|
216
|
+
const summary = parseActivitySummary(result.activity);
|
|
217
|
+
const narrative = summary.narrative.join('\n');
|
|
218
|
+
if (narrative) {
|
|
219
|
+
activityNarrative = narrative.length > 1600 ? '...\n' + narrative.slice(-1600) : narrative;
|
|
220
|
+
}
|
|
221
|
+
const cmdSummary = formatActivityCommandSummary(summary.completedCommands, summary.activeCommands, summary.failedCommands);
|
|
222
|
+
if (cmdSummary)
|
|
223
|
+
activityCommandSummary = cmdSummary;
|
|
224
|
+
}
|
|
225
|
+
let thinkingDisplay = null;
|
|
226
|
+
if (result.thinking) {
|
|
227
|
+
thinkingDisplay = formatThinkingForDisplay(result.thinking, 1600);
|
|
228
|
+
}
|
|
229
|
+
let statusLines = null;
|
|
230
|
+
if (result.incomplete) {
|
|
231
|
+
statusLines = [];
|
|
232
|
+
if (result.stopReason === 'max_tokens')
|
|
233
|
+
statusLines.push('Output limit reached. Response may be truncated.');
|
|
234
|
+
if (result.stopReason === 'timeout') {
|
|
235
|
+
statusLines.push(`Timed out after ${fmtUptime(Math.max(0, Math.round(elapsedMs)))} before the agent reported completion.`);
|
|
236
|
+
}
|
|
237
|
+
if (!result.ok) {
|
|
238
|
+
const detail = result.error?.trim();
|
|
239
|
+
if (detail && detail !== result.message.trim() && !statusLines.includes(detail))
|
|
240
|
+
statusLines.push(detail);
|
|
241
|
+
else if (result.stopReason !== 'timeout')
|
|
242
|
+
statusLines.push('Agent exited before reporting completion.');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
footerStatus,
|
|
247
|
+
activityNarrative,
|
|
248
|
+
activityCommandSummary,
|
|
249
|
+
thinkingDisplay,
|
|
250
|
+
thinkLabel: thinkLabel(agent),
|
|
251
|
+
statusLines,
|
|
252
|
+
bodyMessage: result.message,
|
|
253
|
+
elapsedMs,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Build the sub-agent block for the streaming preview. Sub-agents are
|
|
258
|
+
* deliberately isolated from parent activity (their tool list lives on each
|
|
259
|
+
* StreamSubAgent record), so we render a separate compact section showing each
|
|
260
|
+
* sub-agent's purpose + latest tool. Completed sub-agents are hidden — the
|
|
261
|
+
* parent's activity already reflects the Task `done` line.
|
|
262
|
+
*/
|
|
263
|
+
function renderSubAgentsForPreview(meta) {
|
|
264
|
+
const subs = meta?.subAgents;
|
|
265
|
+
if (!subs?.length)
|
|
266
|
+
return '';
|
|
267
|
+
const lines = [];
|
|
268
|
+
for (const sub of subs) {
|
|
269
|
+
if (sub.status !== 'running')
|
|
270
|
+
continue;
|
|
271
|
+
const label = (sub.description || sub.kind || 'sub-agent').trim().slice(0, 80);
|
|
272
|
+
const lastTool = sub.tools.length ? sub.tools[sub.tools.length - 1].summary : 'starting…';
|
|
273
|
+
const modelTag = sub.model ? ` · ${sub.model}` : '';
|
|
274
|
+
lines.push(`↳ ${label}${modelTag}`);
|
|
275
|
+
lines.push(` · ${lastTool}`);
|
|
276
|
+
}
|
|
277
|
+
return lines.join('\n');
|
|
278
|
+
}
|
|
279
|
+
export function extractStreamPreviewData(input) {
|
|
280
|
+
const maxBody = 2400;
|
|
281
|
+
const display = input.bodyText.trim();
|
|
282
|
+
const rawThinking = input.thinking.trim();
|
|
283
|
+
const thinkDisplay = formatThinkingForDisplay(input.thinking, maxBody);
|
|
284
|
+
const planDisplay = renderPlanForPreview(input.plan ?? null);
|
|
285
|
+
const activityDisplay = summarizeActivityForPreview(input.activity);
|
|
286
|
+
const subAgentsDisplay = renderSubAgentsForPreview(input.meta);
|
|
287
|
+
const maxActivity = !display && !thinkDisplay && !planDisplay ? 2400 : 1400;
|
|
288
|
+
const label = thinkLabel(input.agent);
|
|
289
|
+
const thinkSnippet = rawThinking ? formatThinkingForDisplay(input.thinking, 600) : '';
|
|
290
|
+
const preview = display.length > maxBody ? '(...truncated)\n' + display.slice(-maxBody) : display;
|
|
291
|
+
// Elapsed time is the only monotonic progress signal available during the
|
|
292
|
+
// thinking phase (see thinkingProgressText). Hidden in the first second so a
|
|
293
|
+
// freshly-opened card doesn't flash "0s".
|
|
294
|
+
const elapsedMs = Math.max(0, input.elapsedMs);
|
|
295
|
+
const thinkingProgressText = elapsedMs >= 1000 ? fmtCompactUptime(elapsedMs) : null;
|
|
296
|
+
return {
|
|
297
|
+
display,
|
|
298
|
+
rawThinking,
|
|
299
|
+
thinkDisplay,
|
|
300
|
+
planDisplay,
|
|
301
|
+
activityDisplay,
|
|
302
|
+
subAgentsDisplay,
|
|
303
|
+
maxActivity,
|
|
304
|
+
label,
|
|
305
|
+
thinkSnippet,
|
|
306
|
+
preview,
|
|
307
|
+
thinkingProgressText,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-hub.ts — Unified session management service.
|
|
3
|
+
*
|
|
4
|
+
* THE canonical interface for all session operations across pikiloop.
|
|
5
|
+
* Upper-layer code (bot, dashboard, CLI) should import session functions
|
|
6
|
+
* from here, not from code-agent.ts directly.
|
|
7
|
+
*
|
|
8
|
+
* Responsibilities:
|
|
9
|
+
* - Cross-agent / workspace-scoped session queries
|
|
10
|
+
* - Session metadata management (status, notes, links, classification)
|
|
11
|
+
* - Migration, export/import orchestration
|
|
12
|
+
* - Workspace registry (delegates to user-config)
|
|
13
|
+
*/
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { getSessions as _getSessions, getSessionTail as _getSessionTail, getSessionMessages as _getSessionMessages, classifySession as _classifySession, deriveUserStatus as _deriveStatusFromOutcome, exportSession as _exportSession, importSession as _importSession, findPikiloopSession, updateSessionMeta, deleteAgentSession as _deleteAgentSession, collapseSkillPrompt, } from '../agent/index.js';
|
|
16
|
+
import { allDriverIds, hasDriver } from '../agent/driver.js';
|
|
17
|
+
import { loadWorkspaces, addWorkspace, removeWorkspace, renameWorkspace, reorderWorkspaces, updateWorkspace, findWorkspace, } from '../core/config/user-config.js';
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Resolve user status
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
/**
|
|
22
|
+
* Compute the effective user status for a session.
|
|
23
|
+
* Priority: explicit userStatus > derived from classification > inbox.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveUserStatus(session) {
|
|
26
|
+
if (session.userStatus)
|
|
27
|
+
return session.userStatus;
|
|
28
|
+
if (session.classification)
|
|
29
|
+
return _deriveStatusFromOutcome(session.classification.outcome);
|
|
30
|
+
return 'inbox';
|
|
31
|
+
}
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Unified session query
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
function normalizeAgents(agent) {
|
|
36
|
+
if (!agent)
|
|
37
|
+
return allDriverIds().filter(a => hasDriver(a));
|
|
38
|
+
const list = Array.isArray(agent) ? agent : [agent];
|
|
39
|
+
return list.filter(a => hasDriver(a));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Query sessions — the single entry point for all session listing.
|
|
43
|
+
*
|
|
44
|
+
* Handles single-agent, multi-agent, and all-agent queries with optional
|
|
45
|
+
* status filtering and limits. Returns workspace-enriched results.
|
|
46
|
+
*/
|
|
47
|
+
export async function querySessions(opts) {
|
|
48
|
+
const resolvedWorkdir = path.resolve(opts.workdir);
|
|
49
|
+
const ws = findWorkspace(resolvedWorkdir);
|
|
50
|
+
const workspaceName = ws?.name || path.basename(resolvedWorkdir);
|
|
51
|
+
const agents = normalizeAgents(opts.agent);
|
|
52
|
+
const results = await Promise.all(agents.map(agent => _getSessions({ agent, workdir: resolvedWorkdir }).catch(() => ({
|
|
53
|
+
ok: false, sessions: [], error: `Failed to fetch ${agent} sessions`,
|
|
54
|
+
}))));
|
|
55
|
+
let allSessions = [];
|
|
56
|
+
const errors = [];
|
|
57
|
+
let anyOk = false;
|
|
58
|
+
for (const result of results) {
|
|
59
|
+
if (result.ok)
|
|
60
|
+
anyOk = true;
|
|
61
|
+
if (result.error)
|
|
62
|
+
errors.push(result.error);
|
|
63
|
+
for (const session of result.sessions) {
|
|
64
|
+
allSessions.push({ ...session, workspaceName });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Sort by most recent activity
|
|
68
|
+
allSessions.sort((a, b) => {
|
|
69
|
+
const aTime = a.runUpdatedAt || a.createdAt || '';
|
|
70
|
+
const bTime = b.runUpdatedAt || b.createdAt || '';
|
|
71
|
+
return Date.parse(bTime) - Date.parse(aTime);
|
|
72
|
+
});
|
|
73
|
+
// Filter by userStatus
|
|
74
|
+
if (opts.userStatus?.length) {
|
|
75
|
+
const allowed = new Set(opts.userStatus);
|
|
76
|
+
allSessions = allSessions.filter(s => allowed.has(resolveUserStatus(s)));
|
|
77
|
+
}
|
|
78
|
+
// Apply limit
|
|
79
|
+
if (opts.limit && opts.limit > 0) {
|
|
80
|
+
allSessions = allSessions.slice(0, opts.limit);
|
|
81
|
+
}
|
|
82
|
+
// Count statuses
|
|
83
|
+
const statusCounts = { inbox: 0, active: 0, review: 0, done: 0, parked: 0, unknown: 0 };
|
|
84
|
+
for (const s of allSessions) {
|
|
85
|
+
const status = resolveUserStatus(s);
|
|
86
|
+
statusCounts[status] = (statusCounts[status] || 0) + 1;
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
ok: anyOk || agents.length === 0,
|
|
90
|
+
workdir: resolvedWorkdir,
|
|
91
|
+
workspaceName,
|
|
92
|
+
sessions: allSessions,
|
|
93
|
+
statusCounts: statusCounts,
|
|
94
|
+
total: allSessions.length,
|
|
95
|
+
errors,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Session detail queries
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
|
|
102
|
+
const MIME_BY_EXT = {
|
|
103
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
104
|
+
'.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp', '.svg': 'image/svg+xml',
|
|
105
|
+
};
|
|
106
|
+
/** Build image MessageBlocks from a session record's `lastUserAttachments`
|
|
107
|
+
* (relative paths under `workspacePath`). Used by fallback paths so the
|
|
108
|
+
* dashboard can still render the user's image bubble while the agent CLI
|
|
109
|
+
* has not yet flushed the turn to its own session file. Non-image
|
|
110
|
+
* attachments are skipped — the fallback is text-first and doesn't try to
|
|
111
|
+
* reconstruct generic file references. */
|
|
112
|
+
function imageBlocksFromManagedRecord(record) {
|
|
113
|
+
const attachments = record.lastUserAttachments;
|
|
114
|
+
if (!attachments?.length)
|
|
115
|
+
return [];
|
|
116
|
+
const blocks = [];
|
|
117
|
+
for (const rel of attachments) {
|
|
118
|
+
const ext = path.extname(rel).toLowerCase();
|
|
119
|
+
if (!IMAGE_EXTENSIONS.has(ext))
|
|
120
|
+
continue;
|
|
121
|
+
const abs = path.isAbsolute(rel) ? rel : path.join(record.workspacePath, rel);
|
|
122
|
+
blocks.push({
|
|
123
|
+
type: 'image',
|
|
124
|
+
// `file://` sentinel — `rewriteImageBlocksForTransport` (dashboard
|
|
125
|
+
// response layer) converts it to a proper /attachment URL.
|
|
126
|
+
content: `file://${abs}`,
|
|
127
|
+
imagePath: abs,
|
|
128
|
+
imageMime: MIME_BY_EXT[ext] || 'application/octet-stream',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return blocks;
|
|
132
|
+
}
|
|
133
|
+
function tailFallbackFromManagedRecord(opts) {
|
|
134
|
+
const fb = managedFallbackContent(opts);
|
|
135
|
+
if (!fb)
|
|
136
|
+
return null;
|
|
137
|
+
const limit = Math.max(1, opts.limit ?? fb.messages.length);
|
|
138
|
+
return { ok: true, messages: fb.messages.slice(-limit), error: null };
|
|
139
|
+
}
|
|
140
|
+
function managedFallbackContent(opts) {
|
|
141
|
+
const record = findPikiloopSession(opts.workdir, opts.agent, opts.sessionId);
|
|
142
|
+
if (!record)
|
|
143
|
+
return null;
|
|
144
|
+
const messages = [];
|
|
145
|
+
const richMessages = [];
|
|
146
|
+
if (record.lastQuestion) {
|
|
147
|
+
const text = record.lastQuestion;
|
|
148
|
+
messages.push({ role: 'user', text });
|
|
149
|
+
const blocks = text ? [{ type: 'text', content: text }] : [];
|
|
150
|
+
blocks.push(...imageBlocksFromManagedRecord(record));
|
|
151
|
+
if (blocks.length)
|
|
152
|
+
richMessages.push({ role: 'user', text, blocks, usage: null });
|
|
153
|
+
}
|
|
154
|
+
const failureText = record.lastAnswer
|
|
155
|
+
|| (record.runState === 'incomplete' ? record.runDetail : null);
|
|
156
|
+
if (failureText) {
|
|
157
|
+
messages.push({ role: 'assistant', text: failureText });
|
|
158
|
+
richMessages.push({
|
|
159
|
+
role: 'assistant',
|
|
160
|
+
text: failureText,
|
|
161
|
+
blocks: [{ type: 'text', content: failureText }],
|
|
162
|
+
usage: null,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (!messages.length)
|
|
166
|
+
return null;
|
|
167
|
+
return { messages, richMessages };
|
|
168
|
+
}
|
|
169
|
+
/** Get recent messages from a session (tail). */
|
|
170
|
+
export async function querySessionTail(opts) {
|
|
171
|
+
const result = await _getSessionTail(opts);
|
|
172
|
+
if (!result.ok || !result.messages.length) {
|
|
173
|
+
const fallback = tailFallbackFromManagedRecord(opts);
|
|
174
|
+
if (fallback)
|
|
175
|
+
return fallback;
|
|
176
|
+
}
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Replace canonical skill-execution expansions in a user message with the
|
|
181
|
+
* `/skillname` shorthand the user originally typed. The expanded text is what
|
|
182
|
+
* the agent CLI consumed and persisted; we collapse on read so the dashboard
|
|
183
|
+
* chat shows the slash command instead of the long instruction we synthesized
|
|
184
|
+
* for dispatch. Non-user messages and non-skill prompts pass through unchanged.
|
|
185
|
+
*/
|
|
186
|
+
function collapseSkillPromptsInResult(result) {
|
|
187
|
+
if (!result.ok)
|
|
188
|
+
return result;
|
|
189
|
+
const messages = result.messages.map(m => {
|
|
190
|
+
if (m.role !== 'user')
|
|
191
|
+
return m;
|
|
192
|
+
const collapsed = collapseSkillPrompt(m.text);
|
|
193
|
+
return collapsed ? { ...m, text: collapsed } : m;
|
|
194
|
+
});
|
|
195
|
+
const richMessages = result.richMessages?.map(m => {
|
|
196
|
+
if (m.role !== 'user')
|
|
197
|
+
return m;
|
|
198
|
+
const collapsed = collapseSkillPrompt(m.text);
|
|
199
|
+
if (!collapsed)
|
|
200
|
+
return m;
|
|
201
|
+
// The user's text content lives in one or more `text` blocks; collapse any
|
|
202
|
+
// whose individual content also matches the expansion. Non-text blocks
|
|
203
|
+
// (images, attachments) pass through untouched.
|
|
204
|
+
const blocks = m.blocks.map(b => {
|
|
205
|
+
if (b.type !== 'text')
|
|
206
|
+
return b;
|
|
207
|
+
const blockCollapsed = collapseSkillPrompt(b.content);
|
|
208
|
+
return blockCollapsed ? { ...b, content: blockCollapsed } : b;
|
|
209
|
+
});
|
|
210
|
+
return { ...m, text: collapsed, blocks };
|
|
211
|
+
});
|
|
212
|
+
return { ...result, messages, richMessages };
|
|
213
|
+
}
|
|
214
|
+
/** Get full session messages (with optional turn filtering). */
|
|
215
|
+
export async function querySessionMessages(opts) {
|
|
216
|
+
const result = await _getSessionMessages(opts);
|
|
217
|
+
if (!result.ok || !result.messages.length) {
|
|
218
|
+
const fb = managedFallbackContent({
|
|
219
|
+
agent: opts.agent,
|
|
220
|
+
sessionId: opts.sessionId,
|
|
221
|
+
workdir: opts.workdir,
|
|
222
|
+
});
|
|
223
|
+
if (fb) {
|
|
224
|
+
const totalTurns = fb.messages.filter(m => m.role === 'user').length;
|
|
225
|
+
return collapseSkillPromptsInResult({
|
|
226
|
+
ok: true,
|
|
227
|
+
messages: fb.messages.map(m => ({ role: m.role, text: m.text })),
|
|
228
|
+
// Always emit richMessages so the dashboard can render image blocks
|
|
229
|
+
// for the first user turn while the agent CLI is still spinning up.
|
|
230
|
+
richMessages: fb.richMessages,
|
|
231
|
+
totalTurns,
|
|
232
|
+
error: null,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return collapseSkillPromptsInResult(result);
|
|
237
|
+
}
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Workspace overviews
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
/** Overview of all registered workspaces — designed for dashboard sidebar. */
|
|
242
|
+
export async function getWorkspaceOverviews() {
|
|
243
|
+
const workspaces = loadWorkspaces();
|
|
244
|
+
const agents = allDriverIds().filter(a => hasDriver(a));
|
|
245
|
+
return Promise.all(workspaces.map(async (ws) => {
|
|
246
|
+
// Fan the agents out in parallel — each _getSessions is independent I/O, and
|
|
247
|
+
// running them serially made one slow agent stall the whole workspace card.
|
|
248
|
+
const summaries = await Promise.all(agents.map(async (agent) => {
|
|
249
|
+
try {
|
|
250
|
+
const result = await _getSessions({ agent, workdir: ws.path });
|
|
251
|
+
let active = 0;
|
|
252
|
+
let review = 0;
|
|
253
|
+
let lastTs = null;
|
|
254
|
+
for (const session of result.sessions) {
|
|
255
|
+
const status = resolveUserStatus(session);
|
|
256
|
+
if (status === 'active' || session.running)
|
|
257
|
+
active++;
|
|
258
|
+
else if (status === 'review')
|
|
259
|
+
review++;
|
|
260
|
+
const ts = session.runUpdatedAt || session.createdAt || '';
|
|
261
|
+
if (ts && (!lastTs || ts > lastTs))
|
|
262
|
+
lastTs = ts;
|
|
263
|
+
}
|
|
264
|
+
return { agent, active, review, total: result.sessions.length, lastTs };
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return { agent, active: 0, review: 0, total: 0, lastTs: null };
|
|
268
|
+
}
|
|
269
|
+
}));
|
|
270
|
+
const agentSummary = [];
|
|
271
|
+
let attentionCount = 0;
|
|
272
|
+
let lastActivityAt = null;
|
|
273
|
+
for (const s of summaries) {
|
|
274
|
+
agentSummary.push({ agent: s.agent, active: s.active, review: s.review, total: s.total });
|
|
275
|
+
attentionCount += s.active + s.review;
|
|
276
|
+
if (s.lastTs && (!lastActivityAt || s.lastTs > lastActivityAt))
|
|
277
|
+
lastActivityAt = s.lastTs;
|
|
278
|
+
}
|
|
279
|
+
return { workspace: ws, attentionCount, agentSummary, lastActivityAt };
|
|
280
|
+
}));
|
|
281
|
+
}
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// Session metadata
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
/** Update session metadata (status, note, classification, migration links). */
|
|
286
|
+
export function updateSession(workdir, agent, sessionId, patch) {
|
|
287
|
+
return updateSessionMeta(workdir, agent, sessionId, patch);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Delete a session. Re-exports the agent-layer primitive so dashboard routes
|
|
291
|
+
* stay in the bot/ layer for layering consistency. See
|
|
292
|
+
* {@link DeleteAgentSessionOpts}.
|
|
293
|
+
*/
|
|
294
|
+
export function deleteSession(opts) {
|
|
295
|
+
return _deleteAgentSession(opts);
|
|
296
|
+
}
|
|
297
|
+
/** Link two sessions together (bidirectional). */
|
|
298
|
+
export function linkSessions(workdir, a, b) {
|
|
299
|
+
const updatedA = updateSessionMeta(workdir, a.agent, a.sessionId, {
|
|
300
|
+
addLink: { agent: b.agent, sessionId: b.sessionId },
|
|
301
|
+
});
|
|
302
|
+
const updatedB = updateSessionMeta(workdir, b.agent, b.sessionId, {
|
|
303
|
+
addLink: { agent: a.agent, sessionId: a.sessionId },
|
|
304
|
+
});
|
|
305
|
+
return updatedA || updatedB;
|
|
306
|
+
}
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// Classification
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
/** Auto-classify a session based on stream result. */
|
|
311
|
+
export function classifySession(result) {
|
|
312
|
+
return _classifySession(result);
|
|
313
|
+
}
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// Export / Import / Migration
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
export function exportSession(opts) {
|
|
318
|
+
return _exportSession(opts);
|
|
319
|
+
}
|
|
320
|
+
export function importSession(opts) {
|
|
321
|
+
return _importSession(opts);
|
|
322
|
+
}
|
|
323
|
+
/** Build migration context from source session for injection into target agent. */
|
|
324
|
+
export async function buildMigrationContext(opts) {
|
|
325
|
+
try {
|
|
326
|
+
const messagesResult = await _getSessionMessages({
|
|
327
|
+
agent: opts.source.agent,
|
|
328
|
+
sessionId: opts.source.sessionId,
|
|
329
|
+
workdir: opts.source.workdir,
|
|
330
|
+
lastNTurns: opts.lastNTurns,
|
|
331
|
+
});
|
|
332
|
+
if (!messagesResult.ok) {
|
|
333
|
+
return { ok: false, contextInjected: '', messageCount: 0, error: messagesResult.error };
|
|
334
|
+
}
|
|
335
|
+
const messages = messagesResult.messages;
|
|
336
|
+
if (!messages.length) {
|
|
337
|
+
return { ok: false, contextInjected: '', messageCount: 0, error: 'No messages to migrate' };
|
|
338
|
+
}
|
|
339
|
+
const contextLines = [
|
|
340
|
+
`[Migrated from ${opts.source.agent} session, ${messages.length} messages]`,
|
|
341
|
+
'',
|
|
342
|
+
];
|
|
343
|
+
for (const msg of messages) {
|
|
344
|
+
contextLines.push(`[${msg.role === 'user' ? 'User' : 'Assistant'}]:`);
|
|
345
|
+
contextLines.push(msg.text);
|
|
346
|
+
contextLines.push('');
|
|
347
|
+
}
|
|
348
|
+
const contextInjected = contextLines.join('\n');
|
|
349
|
+
updateSessionMeta(opts.source.workdir, opts.source.agent, opts.source.sessionId, {
|
|
350
|
+
migratedTo: { agent: opts.target.agent, sessionId: '' },
|
|
351
|
+
});
|
|
352
|
+
return { ok: true, contextInjected, messageCount: messages.length, error: null };
|
|
353
|
+
}
|
|
354
|
+
catch (e) {
|
|
355
|
+
return { ok: false, contextInjected: '', messageCount: 0, error: e.message };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
// Workspace registry (delegates to user-config)
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
export { loadWorkspaces, addWorkspace, removeWorkspace, renameWorkspace, reorderWorkspaces, updateWorkspace, findWorkspace };
|