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.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +353 -0
  3. package/README.v2.md +287 -0
  4. package/README.zh-CN.md +352 -0
  5. package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
  6. package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
  7. package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
  8. package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
  9. package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
  10. package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
  11. package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
  12. package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
  13. package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
  14. package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
  15. package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
  16. package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
  17. package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
  18. package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
  19. package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
  20. package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
  21. package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
  22. package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
  23. package/dashboard/dist/assets/index-reSbuley.css +1 -0
  24. package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
  25. package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
  26. package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
  27. package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
  28. package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
  29. package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
  30. package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
  31. package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
  32. package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
  33. package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
  34. package/dashboard/dist/favicon.svg +28 -0
  35. package/dashboard/dist/index.html +17 -0
  36. package/dist/agent/acp-client.js +261 -0
  37. package/dist/agent/auto-update.js +432 -0
  38. package/dist/agent/await-resume.js +50 -0
  39. package/dist/agent/cli/auth.js +325 -0
  40. package/dist/agent/cli/catalog.js +40 -0
  41. package/dist/agent/cli/detector.js +136 -0
  42. package/dist/agent/cli/index.js +7 -0
  43. package/dist/agent/cli/registry.js +33 -0
  44. package/dist/agent/driver.js +39 -0
  45. package/dist/agent/drivers/claude-tui.js +2297 -0
  46. package/dist/agent/drivers/claude.js +2689 -0
  47. package/dist/agent/drivers/codex.js +2210 -0
  48. package/dist/agent/drivers/gemini.js +1059 -0
  49. package/dist/agent/drivers/hermes.js +795 -0
  50. package/dist/agent/goal.js +274 -0
  51. package/dist/agent/handover.js +130 -0
  52. package/dist/agent/images.js +355 -0
  53. package/dist/agent/index.js +50 -0
  54. package/dist/agent/mcp/bridge.js +791 -0
  55. package/dist/agent/mcp/extensions.js +637 -0
  56. package/dist/agent/mcp/oauth.js +353 -0
  57. package/dist/agent/mcp/registry.js +119 -0
  58. package/dist/agent/mcp/session-server.js +229 -0
  59. package/dist/agent/mcp/tools/ask-user.js +113 -0
  60. package/dist/agent/mcp/tools/await-resume.js +77 -0
  61. package/dist/agent/mcp/tools/goal.js +144 -0
  62. package/dist/agent/mcp/tools/types.js +12 -0
  63. package/dist/agent/mcp/tools/workspace.js +212 -0
  64. package/dist/agent/npm.js +31 -0
  65. package/dist/agent/session.js +1206 -0
  66. package/dist/agent/skill-installer.js +160 -0
  67. package/dist/agent/skills.js +257 -0
  68. package/dist/agent/stream.js +743 -0
  69. package/dist/agent/types.js +13 -0
  70. package/dist/agent/utils.js +687 -0
  71. package/dist/bot/bot.js +2499 -0
  72. package/dist/bot/command-ui.js +633 -0
  73. package/dist/bot/commands.js +513 -0
  74. package/dist/bot/headless-bot.js +36 -0
  75. package/dist/bot/host.js +192 -0
  76. package/dist/bot/human-loop.js +168 -0
  77. package/dist/bot/menu.js +48 -0
  78. package/dist/bot/orchestration.js +79 -0
  79. package/dist/bot/render-shared.js +309 -0
  80. package/dist/bot/session-hub.js +361 -0
  81. package/dist/bot/session-status.js +55 -0
  82. package/dist/bot/streaming.js +309 -0
  83. package/dist/browser-profile.js +579 -0
  84. package/dist/browser-supervisor.js +249 -0
  85. package/dist/catalog/cli-tools.js +421 -0
  86. package/dist/catalog/index.js +21 -0
  87. package/dist/catalog/local-models.js +94 -0
  88. package/dist/catalog/mcp-servers.js +315 -0
  89. package/dist/catalog/skill-repos.js +173 -0
  90. package/dist/channels/base.js +55 -0
  91. package/dist/channels/dingtalk/bot.js +549 -0
  92. package/dist/channels/dingtalk/channel.js +268 -0
  93. package/dist/channels/discord/bot.js +552 -0
  94. package/dist/channels/discord/channel.js +245 -0
  95. package/dist/channels/feishu/bot.js +1275 -0
  96. package/dist/channels/feishu/channel.js +911 -0
  97. package/dist/channels/feishu/markdown.js +91 -0
  98. package/dist/channels/feishu/render.js +619 -0
  99. package/dist/channels/health.js +109 -0
  100. package/dist/channels/slack/bot.js +554 -0
  101. package/dist/channels/slack/channel.js +283 -0
  102. package/dist/channels/states.js +6 -0
  103. package/dist/channels/telegram/bot.js +1310 -0
  104. package/dist/channels/telegram/channel.js +820 -0
  105. package/dist/channels/telegram/directory.js +111 -0
  106. package/dist/channels/telegram/live-preview.js +220 -0
  107. package/dist/channels/telegram/render.js +384 -0
  108. package/dist/channels/wecom/bot.js +558 -0
  109. package/dist/channels/wecom/channel.js +479 -0
  110. package/dist/channels/weixin/api.js +520 -0
  111. package/dist/channels/weixin/bot.js +1000 -0
  112. package/dist/channels/weixin/channel.js +222 -0
  113. package/dist/cli/autostart.js +262 -0
  114. package/dist/cli/channel-supervisor.js +313 -0
  115. package/dist/cli/channels.js +54 -0
  116. package/dist/cli/main.js +726 -0
  117. package/dist/cli/onboarding.js +227 -0
  118. package/dist/cli/run.js +308 -0
  119. package/dist/cli/setup-wizard.js +235 -0
  120. package/dist/core/config/runtime-config.js +201 -0
  121. package/dist/core/config/user-config.js +510 -0
  122. package/dist/core/config/validation.js +521 -0
  123. package/dist/core/constants.js +400 -0
  124. package/dist/core/git.js +145 -0
  125. package/dist/core/legacy-compat.js +60 -0
  126. package/dist/core/logging.js +101 -0
  127. package/dist/core/platform.js +59 -0
  128. package/dist/core/process-control.js +315 -0
  129. package/dist/core/secrets/index.js +42 -0
  130. package/dist/core/secrets/inline-seal.js +60 -0
  131. package/dist/core/secrets/ref.js +33 -0
  132. package/dist/core/secrets/resolver.js +65 -0
  133. package/dist/core/secrets/store.js +63 -0
  134. package/dist/core/utils.js +233 -0
  135. package/dist/core/version.js +15 -0
  136. package/dist/dashboard/platform.js +219 -0
  137. package/dist/dashboard/routes/agents.js +450 -0
  138. package/dist/dashboard/routes/cli.js +174 -0
  139. package/dist/dashboard/routes/config.js +523 -0
  140. package/dist/dashboard/routes/extensions.js +745 -0
  141. package/dist/dashboard/routes/local-models.js +290 -0
  142. package/dist/dashboard/routes/models.js +324 -0
  143. package/dist/dashboard/routes/sessions.js +838 -0
  144. package/dist/dashboard/runtime.js +410 -0
  145. package/dist/dashboard/server.js +237 -0
  146. package/dist/dashboard/session-control.js +347 -0
  147. package/dist/model/catalog.js +104 -0
  148. package/dist/model/index.js +20 -0
  149. package/dist/model/injector.js +272 -0
  150. package/dist/model/provider-models.js +112 -0
  151. package/dist/model/store.js +212 -0
  152. package/dist/model/types.js +13 -0
  153. package/dist/model/validation.js +203 -0
  154. package/package.json +82 -0
@@ -0,0 +1,633 @@
1
+ /**
2
+ * Shared selection UI models and action executor for interactive commands.
3
+ */
4
+ import { normalizeAgent } from './bot.js';
5
+ import { getDriverCapabilities } from '../agent/driver.js';
6
+ import { getSessionStatusForChat } from './session-status.js';
7
+ import { getAgentsListData, getModelsListData, getSessionsPageData, getSkillsListData, summarizeSessionRun, modelMatchesSelection, resolveSkillPrompt, } from './commands.js';
8
+ function chunkRows(items, columns) {
9
+ const rows = [];
10
+ const size = Math.max(1, columns);
11
+ for (let i = 0; i < items.length; i += size)
12
+ rows.push(items.slice(i, i + size));
13
+ return rows;
14
+ }
15
+ function buttonStateFromFlags(opts) {
16
+ if (opts.unavailable)
17
+ return 'unavailable';
18
+ if (opts.isRunning)
19
+ return 'running';
20
+ if (opts.isCurrent)
21
+ return 'current';
22
+ return 'default';
23
+ }
24
+ export function encodeCommandAction(action) {
25
+ switch (action.kind) {
26
+ case 'sessions.page':
27
+ return `sp:${Math.max(0, action.page)}`;
28
+ case 'session.new':
29
+ return 'sess:new';
30
+ case 'session.switch':
31
+ return `sess:${action.sessionId}`;
32
+ case 'agent.switch':
33
+ return `ag:${action.agent}`;
34
+ case 'model.switch':
35
+ return `mod:${action.modelId}`;
36
+ case 'effort.set':
37
+ return `eff:${action.effort}`;
38
+ case 'models.select.model':
39
+ // Encode native vs profile selection on the wire so decode is unambiguous.
40
+ // `md:n:<modelId>` = native; `md:p:<profileId>:<modelId>` = BYOK profile.
41
+ return action.profileId
42
+ ? `md:p:${action.profileId}:${action.modelId}`
43
+ : `md:n:${action.modelId}`;
44
+ case 'models.select.effort':
45
+ return `ed:${action.effort}`;
46
+ case 'models.confirm':
47
+ return 'mc';
48
+ case 'skill.run':
49
+ return `skr:${action.command}`;
50
+ case 'mode.switch':
51
+ return `pm:${action.mode}`;
52
+ case 'workflow.toggle':
53
+ return `wf:${action.enabled ? 1 : 0}`;
54
+ }
55
+ }
56
+ export function decodeCommandAction(data) {
57
+ if (data === 'sess:new')
58
+ return { kind: 'session.new' };
59
+ if (data.startsWith('sp:')) {
60
+ const page = Number.parseInt(data.slice(3), 10);
61
+ if (!Number.isFinite(page) || page < 0)
62
+ return null;
63
+ return { kind: 'sessions.page', page };
64
+ }
65
+ if (data.startsWith('sess:')) {
66
+ const sessionId = data.slice(5);
67
+ if (!sessionId)
68
+ return null;
69
+ return { kind: 'session.switch', sessionId };
70
+ }
71
+ if (data.startsWith('ag:')) {
72
+ try {
73
+ return { kind: 'agent.switch', agent: normalizeAgent(data.slice(3)) };
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
79
+ if (data.startsWith('mod:')) {
80
+ const modelId = data.slice(4);
81
+ if (!modelId)
82
+ return null;
83
+ return { kind: 'model.switch', modelId };
84
+ }
85
+ if (data.startsWith('eff:')) {
86
+ const effort = data.slice(4);
87
+ if (!effort)
88
+ return null;
89
+ return { kind: 'effort.set', effort };
90
+ }
91
+ if (data.startsWith('md:')) {
92
+ const rest = data.slice(3);
93
+ if (rest.startsWith('n:')) {
94
+ const modelId = rest.slice(2);
95
+ if (!modelId)
96
+ return null;
97
+ return { kind: 'models.select.model', modelId, profileId: null };
98
+ }
99
+ if (rest.startsWith('p:')) {
100
+ // p:<profileId>:<modelId> — profileId is a uuid (no colons), so split on
101
+ // the first remaining colon yields a clean (profileId, modelId) pair.
102
+ const sep = rest.indexOf(':', 2);
103
+ if (sep < 0)
104
+ return null;
105
+ const profileId = rest.slice(2, sep);
106
+ const modelId = rest.slice(sep + 1);
107
+ if (!profileId || !modelId)
108
+ return null;
109
+ return { kind: 'models.select.model', modelId, profileId };
110
+ }
111
+ // Legacy callback payloads (pre-union) carried `md:<modelId>` directly.
112
+ // Treat them as native selections so old buttons in the wild still work.
113
+ if (!rest)
114
+ return null;
115
+ return { kind: 'models.select.model', modelId: rest, profileId: null };
116
+ }
117
+ if (data.startsWith('ed:')) {
118
+ const effort = data.slice(3);
119
+ if (!effort)
120
+ return null;
121
+ return { kind: 'models.select.effort', effort };
122
+ }
123
+ if (data === 'mc')
124
+ return { kind: 'models.confirm' };
125
+ if (data.startsWith('skr:')) {
126
+ const command = data.slice(4);
127
+ if (!command)
128
+ return null;
129
+ return { kind: 'skill.run', command };
130
+ }
131
+ if (data.startsWith('pm:')) {
132
+ const mode = data.slice(3);
133
+ if (!mode)
134
+ return null;
135
+ return { kind: 'mode.switch', mode };
136
+ }
137
+ if (data.startsWith('wf:')) {
138
+ const flag = data.slice(3);
139
+ if (flag !== '0' && flag !== '1')
140
+ return null;
141
+ return { kind: 'workflow.toggle', enabled: flag === '1' };
142
+ }
143
+ return null;
144
+ }
145
+ export async function buildSessionsCommandView(bot, chatId, page, pageSize = 5) {
146
+ const data = await getSessionsPageData(bot, chatId, page, pageSize);
147
+ // Multi-row: one button per session on its own line, prefixed with the
148
+ // agent badge so a mixed workspace list reads cleanly. Avoid cramming
149
+ // multiple buttons onto one row (some IM clients truncate).
150
+ const sessionButtons = data.sessions.map(session => [{
151
+ label: `[${session.agent}] ${session.title} · ${session.time}`,
152
+ action: { kind: 'session.switch', sessionId: session.key },
153
+ state: buttonStateFromFlags({ isCurrent: session.isCurrent, isRunning: session.isRunning }),
154
+ primary: session.isCurrent,
155
+ }]);
156
+ const navRow = [];
157
+ if (data.page > 0)
158
+ navRow.push({ label: `◀ p${data.page}`, action: { kind: 'sessions.page', page: data.page - 1 } });
159
+ navRow.push({ label: '+ New', action: { kind: 'session.new' } });
160
+ if (data.page < data.totalPages - 1)
161
+ navRow.push({ label: `p${data.page + 2} ▶`, action: { kind: 'sessions.page', page: data.page + 1 } });
162
+ const agentChips = Object.entries(data.agentTotals)
163
+ .sort((a, b) => b[1] - a[1])
164
+ .map(([agent, count]) => `${agent}:${count}`)
165
+ .join(' · ');
166
+ const headerDetail = data.workspaceName
167
+ ? (agentChips ? `${data.workspaceName} · ${agentChips}` : data.workspaceName)
168
+ : (agentChips || null);
169
+ return {
170
+ kind: 'sessions',
171
+ title: 'Sessions',
172
+ detail: headerDetail,
173
+ metaLines: [`${data.total} total · p${data.page + 1}/${data.totalPages}`],
174
+ items: data.sessions.map(session => ({
175
+ label: `[${session.agent}] ${session.title}`,
176
+ detail: session.time,
177
+ state: buttonStateFromFlags({ isCurrent: session.isCurrent, isRunning: session.isRunning }),
178
+ })),
179
+ emptyText: 'No sessions found in this workspace.',
180
+ helperText: data.totalPages > 1
181
+ ? `Pick a row to resume (agent/model/effort restore automatically).`
182
+ : 'Pick a row to resume, or start a new session.',
183
+ rows: navRow.length ? [...sessionButtons, navRow] : sessionButtons,
184
+ };
185
+ }
186
+ export function buildAgentsCommandView(bot, chatId) {
187
+ const data = getAgentsListData(bot, chatId);
188
+ const installed = data.agents.filter(a => a.installed);
189
+ // Buttons stay short — friendly label + optional ✓ marker — so they don't
190
+ // truncate on narrow IM clients.
191
+ const actions = installed.map(agent => ({
192
+ label: agent.label,
193
+ action: { kind: 'agent.switch', agent: agent.agent },
194
+ state: buttonStateFromFlags({ isCurrent: agent.isCurrent }),
195
+ primary: agent.isCurrent,
196
+ }));
197
+ // Details (version + bound provider) live in the items list above the
198
+ // buttons, where the renderer can show them in full without truncation.
199
+ const items = installed.map(agent => {
200
+ const main = agent.versionShort
201
+ ? `${agent.label} · v${agent.versionShort}`
202
+ : agent.label;
203
+ const detail = agent.boundProvider && agent.boundModel
204
+ ? `${agent.boundProvider} / ${agent.boundModel}`
205
+ : null;
206
+ return {
207
+ label: main,
208
+ detail,
209
+ state: buttonStateFromFlags({ isCurrent: agent.isCurrent }),
210
+ };
211
+ });
212
+ const current = installed.find(a => a.isCurrent);
213
+ return {
214
+ kind: 'agents',
215
+ title: 'Agents',
216
+ detail: current ? current.label : undefined,
217
+ metaLines: current ? [`Current: ${current.label}`] : [],
218
+ items,
219
+ emptyText: actions.length ? undefined : 'No installed agents.',
220
+ helperText: actions.length ? 'Tap an agent to switch.' : undefined,
221
+ rows: actions.map(action => [action]),
222
+ };
223
+ }
224
+ const modelsDrafts = new Map();
225
+ async function initModelsDraft(bot, chatId) {
226
+ const cs = bot.chat(chatId);
227
+ const data = await getModelsListData(bot, chatId);
228
+ const activeProfileId = bot.activeProfileIdForAgent(cs.agent);
229
+ const draft = {
230
+ modelId: data.currentModel,
231
+ profileId: activeProfileId,
232
+ effort: data.effort?.current ?? null,
233
+ };
234
+ modelsDrafts.set(String(chatId), draft);
235
+ return draft;
236
+ }
237
+ /**
238
+ * Section headings for the unified picker. The trailing em-dash padding gives
239
+ * the row a "label" look in Telegram/Feishu — clicking it falls through to a
240
+ * harmless confirm which is a no-op when nothing has changed.
241
+ */
242
+ const MODEL_GROUP_LABELS = {
243
+ native: '— Native —',
244
+ cloud: '— Cloud Profiles —',
245
+ local: '— Local Profiles —',
246
+ };
247
+ /** Match a picker row against the live draft so the "current" pip is unambiguous. */
248
+ function modelRowMatchesDraft(agent, row, draft) {
249
+ if (draft.profileId)
250
+ return !!row.profileId && row.profileId === draft.profileId;
251
+ const isNativeRow = (row.group ?? 'native') === 'native';
252
+ return isNativeRow && modelMatchesSelection(agent, row.id, draft.modelId);
253
+ }
254
+ export async function buildModelsCommandView(bot, chatId, draft) {
255
+ const data = await getModelsListData(bot, chatId);
256
+ const d = draft ?? {
257
+ modelId: data.currentModel,
258
+ profileId: bot.activeProfileIdForAgent(data.agent),
259
+ effort: data.effort?.current ?? null,
260
+ };
261
+ modelsDrafts.set(String(chatId), d);
262
+ // Bucket rows by group while preserving the order resolveAgentModels gave us
263
+ // (native → cloud → local). We render each non-empty group with a header.
264
+ const groups = {
265
+ native: [],
266
+ cloud: [],
267
+ local: [],
268
+ };
269
+ for (const model of data.models) {
270
+ const g = (model.group ?? 'native');
271
+ groups[g].push(model);
272
+ }
273
+ const rows = [];
274
+ for (const group of ['native', 'cloud', 'local']) {
275
+ const rawItems = groups[group];
276
+ if (!rawItems.length)
277
+ continue;
278
+ // Promote the currently-selected row to the top of its group so the user
279
+ // sees their active pick first without having to scroll. This matches the
280
+ // pre-union picker's behaviour, just scoped per-group instead of globally.
281
+ const items = [...rawItems].sort((a, b) => Number(modelRowMatchesDraft(data.agent, b, d)) - Number(modelRowMatchesDraft(data.agent, a, d)));
282
+ rows.push([{
283
+ label: MODEL_GROUP_LABELS[group],
284
+ action: { kind: 'models.confirm' },
285
+ state: 'default',
286
+ primary: false,
287
+ }]);
288
+ for (const model of items) {
289
+ const selected = modelRowMatchesDraft(data.agent, model, d);
290
+ const labelBase = model.alias || model.id;
291
+ // For BYOK rows the alias is the Profile name — include provider tag so
292
+ // two Profiles sharing a model id (e.g. "Sonnet 4.6 · OpenRouter" vs
293
+ // "Sonnet 4.6 · Anthropic Direct") stay distinguishable in 1-column mode.
294
+ const label = group === 'native' || !model.providerName
295
+ ? labelBase
296
+ : `${labelBase} · ${model.providerName}`;
297
+ rows.push([{
298
+ label,
299
+ action: {
300
+ kind: 'models.select.model',
301
+ modelId: model.id,
302
+ profileId: model.profileId ?? null,
303
+ },
304
+ state: buttonStateFromFlags({ isCurrent: selected }),
305
+ primary: selected,
306
+ }]);
307
+ }
308
+ }
309
+ if (data.effort) {
310
+ const effortButtons = data.effort.levels.map(level => ({
311
+ label: level.label,
312
+ action: { kind: 'models.select.effort', effort: level.id },
313
+ state: buttonStateFromFlags({ isCurrent: level.id === d.effort }),
314
+ primary: level.id === d.effort,
315
+ }));
316
+ rows.push([{
317
+ label: '— Thinking Effort —',
318
+ action: { kind: 'models.confirm' },
319
+ state: 'default',
320
+ primary: false,
321
+ }]);
322
+ rows.push(...chunkRows(effortButtons, effortButtons.length <= 3 ? effortButtons.length : 2));
323
+ }
324
+ const currentProfileId = bot.activeProfileIdForAgent(data.agent);
325
+ const profileChanged = (d.profileId || null) !== (currentProfileId || null);
326
+ const modelChanged = !d.profileId && !currentProfileId
327
+ && !modelMatchesSelection(data.agent, d.modelId, data.currentModel);
328
+ const effortChanged = !!(data.effort && d.effort !== data.effort.current);
329
+ const hasChanges = profileChanged || modelChanged || effortChanged;
330
+ rows.push([{
331
+ label: hasChanges ? '✓ Apply' : '✓ OK',
332
+ action: { kind: 'models.confirm' },
333
+ state: 'default',
334
+ primary: hasChanges,
335
+ }]);
336
+ return {
337
+ kind: 'models',
338
+ title: 'Models',
339
+ detail: data.agent,
340
+ metaLines: [
341
+ ...(data.sources.length ? [`Source: ${data.sources.join(', ')}`] : []),
342
+ ...(data.note ? [data.note] : []),
343
+ ...(data.effort ? [`Thinking Effort: ${d.effort}`] : []),
344
+ ],
345
+ items: data.models.map(model => ({
346
+ label: model.alias || model.id,
347
+ detail: model.providerName || (model.alias ? model.id : null),
348
+ state: buttonStateFromFlags({ isCurrent: modelRowMatchesDraft(data.agent, model, d) }),
349
+ })),
350
+ emptyText: 'No discoverable models found.',
351
+ helperText: data.models.length ? 'Pick a model (native or BYOK), then tap Apply.' : null,
352
+ rows,
353
+ };
354
+ }
355
+ export function buildSkillsCommandView(bot, chatId) {
356
+ const data = getSkillsListData(bot, chatId);
357
+ const buttons = data.skills.map(skill => ({
358
+ label: skill.label,
359
+ action: { kind: 'skill.run', command: skill.command },
360
+ }));
361
+ return {
362
+ kind: 'skills',
363
+ title: 'Skills',
364
+ detail: data.agent,
365
+ metaLines: [`Workdir: ${data.workdir}`],
366
+ items: data.skills.map(skill => ({
367
+ label: skill.label,
368
+ detail: skill.description || `/${skill.command}`,
369
+ })),
370
+ emptyText: 'No project skills found.',
371
+ helperText: data.skills.length ? 'Use the controls below to run a skill.' : null,
372
+ rows: chunkRows(buttons, buttons.some(button => button.label.length > 14) ? 1 : 2),
373
+ };
374
+ }
375
+ export function buildModeCommandView(bot, chatId) {
376
+ const cs = bot.chat(chatId);
377
+ const isClaude = cs.agent === 'claude';
378
+ const isPlanMode = isClaude && bot.claudePermissionMode === 'plan';
379
+ const supportsWorkflow = getDriverCapabilities(cs.agent).workflow;
380
+ const workflowOn = supportsWorkflow && bot.workflowEnabledForAgent(cs.agent);
381
+ const rows = [
382
+ [
383
+ { label: 'Code', action: { kind: 'mode.switch', mode: 'bypassPermissions' },
384
+ state: isPlanMode ? 'default' : 'current', primary: !isPlanMode },
385
+ { label: 'Plan', action: { kind: 'mode.switch', mode: 'plan' },
386
+ state: isPlanMode ? 'current' : 'default', primary: isPlanMode },
387
+ ],
388
+ ];
389
+ const metaLines = [];
390
+ if (!isClaude)
391
+ metaLines.push('Permission mode is only available for Claude.');
392
+ // Workflow orchestration is no longer a standalone toggle here — it folded
393
+ // into the effort picker as the top "Ultra" rung (max depth + multi-agent
394
+ // fan-out). Surface its state and point users to /models so the capability
395
+ // stays discoverable.
396
+ if (supportsWorkflow) {
397
+ metaLines.push(`Workflow orchestration: ${workflowOn ? 'On (Ultra effort)' : 'Off'} — pick the Ultra rung in /models to toggle.`);
398
+ }
399
+ return {
400
+ kind: 'mode',
401
+ title: 'Agent Mode',
402
+ detail: `Current: ${isPlanMode ? 'Plan (read-only)' : 'Code (full access)'}`
403
+ + (supportsWorkflow && workflowOn ? ' · Ultra (workflow)' : ''),
404
+ metaLines,
405
+ items: [],
406
+ rows,
407
+ };
408
+ }
409
+ export async function executeCommandAction(bot, chatId, action, opts = {}) {
410
+ const sessionsPageSize = opts.sessionsPageSize ?? 5;
411
+ switch (action.kind) {
412
+ case 'sessions.page':
413
+ return {
414
+ kind: 'view',
415
+ view: await buildSessionsCommandView(bot, chatId, action.page, sessionsPageSize),
416
+ callbackText: '',
417
+ };
418
+ case 'session.new': {
419
+ bot.resetConversationForChat(chatId);
420
+ return {
421
+ kind: 'notice',
422
+ callbackText: 'New session',
423
+ notice: { title: 'New Session', detail: 'Send a message to start.' },
424
+ };
425
+ }
426
+ case 'session.switch': {
427
+ const chat = bot.chat(chatId);
428
+ // Workspace-wide lookup (no agent filter) so a row from any agent can be
429
+ // resumed directly from a single mixed list.
430
+ const result = await bot.fetchSessions(undefined, bot.chatWorkdir(chatId));
431
+ if (!result.ok)
432
+ return { kind: 'noop', message: 'Failed to load sessions' };
433
+ const session = result.sessions.find(entry => entry.sessionId === action.sessionId);
434
+ if (!session)
435
+ return { kind: 'noop', message: 'Session not found' };
436
+ const prevAgent = chat.agent;
437
+ const runtime = bot.adoptExistingSessionForChat(chatId, session);
438
+ // Restore the agent's persistent model / effort / Profile binding so the
439
+ // next stream — and the IM picker chips — match the resumed session.
440
+ if (session.model) {
441
+ bot.switchModelForChat(chatId, session.model, session.profileId ?? null);
442
+ }
443
+ else if (session.profileId !== undefined) {
444
+ // Session was native (profileId === null) — explicitly clear any
445
+ // active Profile so we don't run with a stale BYOK binding.
446
+ bot.switchModelForChat(chatId, bot.modelForAgent(session.agent), null);
447
+ }
448
+ if (session.thinkingEffort) {
449
+ bot.switchEffortForChat(chatId, session.thinkingEffort);
450
+ }
451
+ const displayId = session.sessionId || action.sessionId;
452
+ const sessionStatus = getSessionStatusForChat(bot, chat, session);
453
+ const runDetail = summarizeSessionRun({ ...session, running: sessionStatus.isRunning }).noticeDetail;
454
+ const restoreParts = [];
455
+ if (prevAgent !== session.agent)
456
+ restoreParts.push(`agent → ${session.agent}`);
457
+ if (session.model)
458
+ restoreParts.push(`model → ${session.model}`);
459
+ if (session.thinkingEffort)
460
+ restoreParts.push(`effort → ${session.thinkingEffort}`);
461
+ const detail = restoreParts.length ? `${runDetail} · ${restoreParts.join(' · ')}` : runDetail;
462
+ return {
463
+ kind: 'notice',
464
+ callbackText: `Switched: ${displayId.slice(0, 12)}`,
465
+ notice: {
466
+ title: 'Session Switched',
467
+ value: displayId,
468
+ detail,
469
+ valueMode: 'code',
470
+ },
471
+ session: runtime,
472
+ previewSession: { agent: session.agent, sessionId: session.sessionId },
473
+ };
474
+ }
475
+ case 'agent.switch': {
476
+ const chat = bot.chat(chatId);
477
+ if (chat.agent === action.agent)
478
+ return { kind: 'noop', message: `Already using ${action.agent}` };
479
+ bot.switchAgentForChat(chatId, action.agent);
480
+ const resumed = bot.selectedSession(chatId);
481
+ return {
482
+ kind: 'notice',
483
+ callbackText: `Switched to ${action.agent}`,
484
+ notice: {
485
+ title: 'Agent',
486
+ value: action.agent,
487
+ detail: resumed?.agent === action.agent && resumed.sessionId ? 'Resumed previous session' : 'Session reset',
488
+ valueMode: 'plain',
489
+ },
490
+ session: resumed?.agent === action.agent ? resumed : undefined,
491
+ previewSession: resumed?.agent === action.agent
492
+ ? { agent: resumed.agent, sessionId: resumed.sessionId }
493
+ : null,
494
+ };
495
+ }
496
+ case 'model.switch': {
497
+ const chat = bot.chat(chatId);
498
+ const currentModel = bot.modelForAgent(chat.agent);
499
+ if (modelMatchesSelection(chat.agent, action.modelId, currentModel)) {
500
+ return { kind: 'noop', message: `Already using ${action.modelId}` };
501
+ }
502
+ bot.switchModelForChat(chatId, action.modelId);
503
+ return {
504
+ kind: 'notice',
505
+ callbackText: `Switched to ${action.modelId}`,
506
+ notice: {
507
+ title: 'Model',
508
+ value: action.modelId,
509
+ detail: `${chat.agent} · session reset`,
510
+ valueMode: 'code',
511
+ },
512
+ };
513
+ }
514
+ case 'effort.set': {
515
+ const chat = bot.chat(chatId);
516
+ const currentEffort = bot.effortSelectionForAgent(chat.agent);
517
+ if (action.effort === currentEffort) {
518
+ return { kind: 'noop', message: `Already using ${action.effort} effort` };
519
+ }
520
+ bot.switchEffortForChat(chatId, action.effort);
521
+ return {
522
+ kind: 'notice',
523
+ callbackText: `Effort set to ${action.effort}`,
524
+ notice: {
525
+ title: 'Thinking Effort',
526
+ value: action.effort,
527
+ detail: `${chat.agent} · takes effect on next message`,
528
+ valueMode: 'code',
529
+ },
530
+ };
531
+ }
532
+ case 'models.select.model': {
533
+ const draft = modelsDrafts.get(String(chatId)) ?? await initModelsDraft(bot, chatId);
534
+ draft.modelId = action.modelId;
535
+ // profileId can be null (native pick) or a uuid (BYOK pick). Treating
536
+ // `undefined` as "leave alone" preserves the action shape's old contract.
537
+ if (action.profileId !== undefined)
538
+ draft.profileId = action.profileId;
539
+ return { kind: 'view', view: await buildModelsCommandView(bot, chatId, draft), callbackText: '' };
540
+ }
541
+ case 'models.select.effort': {
542
+ const draft = modelsDrafts.get(String(chatId)) ?? await initModelsDraft(bot, chatId);
543
+ draft.effort = action.effort;
544
+ return { kind: 'view', view: await buildModelsCommandView(bot, chatId, draft), callbackText: '' };
545
+ }
546
+ case 'models.confirm': {
547
+ const chat = bot.chat(chatId);
548
+ const draft = modelsDrafts.get(String(chatId));
549
+ modelsDrafts.delete(String(chatId));
550
+ if (!draft)
551
+ return { kind: 'noop', message: 'No changes' };
552
+ const currentModel = bot.modelForAgent(chat.agent);
553
+ const currentEffort = bot.effortSelectionForAgent(chat.agent);
554
+ const currentProfileId = bot.activeProfileIdForAgent(chat.agent);
555
+ const profileChanged = (draft.profileId || null) !== (currentProfileId || null);
556
+ const modelChanged = profileChanged
557
+ || (!draft.profileId && !modelMatchesSelection(chat.agent, draft.modelId, currentModel));
558
+ const effortChanged = draft.effort != null && draft.effort !== currentEffort;
559
+ if (!modelChanged && !effortChanged) {
560
+ return { kind: 'noop', message: 'No changes' };
561
+ }
562
+ const parts = [];
563
+ if (modelChanged) {
564
+ // Pass profileId explicitly: a null clears any active Profile binding,
565
+ // a uuid binds the picked Profile. switchModelForChat handles both
566
+ // sides of the dual path so call sites stay agnostic.
567
+ bot.switchModelForChat(chatId, draft.modelId, draft.profileId ?? null);
568
+ parts.push(draft.profileId ? `Profile: ${draft.modelId}` : `Model: ${draft.modelId}`);
569
+ }
570
+ if (effortChanged) {
571
+ bot.switchEffortForChat(chatId, draft.effort);
572
+ parts.push(`Effort: ${draft.effort}`);
573
+ }
574
+ return {
575
+ kind: 'notice',
576
+ callbackText: parts.join(', '),
577
+ notice: {
578
+ title: 'Configuration Updated',
579
+ value: parts.join('\n'),
580
+ detail: modelChanged
581
+ ? `${chat.agent} · session reset`
582
+ : `${chat.agent} · takes effect on next message`,
583
+ valueMode: 'plain',
584
+ },
585
+ };
586
+ }
587
+ case 'skill.run': {
588
+ const resolved = resolveSkillPrompt(bot, chatId, action.command, '');
589
+ if (!resolved)
590
+ return { kind: 'noop', message: 'Skill not found' };
591
+ return {
592
+ kind: 'skill',
593
+ prompt: resolved.prompt,
594
+ skillName: resolved.skillName,
595
+ callbackText: `Run ${resolved.skillName}`,
596
+ };
597
+ }
598
+ case 'mode.switch': {
599
+ const cs = bot.chat(chatId);
600
+ if (cs.agent !== 'claude') {
601
+ return { kind: 'noop', message: 'Mode toggle is only available for Claude agent' };
602
+ }
603
+ bot.switchPermissionModeForChat(chatId, action.mode);
604
+ const label = action.mode === 'plan' ? 'Plan (read-only)' : 'Code (full access)';
605
+ return {
606
+ kind: 'notice',
607
+ callbackText: `Mode: ${label}`,
608
+ notice: { title: 'Agent Mode', value: label },
609
+ };
610
+ }
611
+ case 'workflow.toggle': {
612
+ const cs = bot.chat(chatId);
613
+ if (!getDriverCapabilities(cs.agent).workflow) {
614
+ return { kind: 'noop', message: `${cs.agent} does not support workflow orchestration` };
615
+ }
616
+ if (bot.workflowEnabledForAgent(cs.agent) === action.enabled) {
617
+ return { kind: 'noop', message: `Workflow already ${action.enabled ? 'on' : 'off'}` };
618
+ }
619
+ bot.switchWorkflowForChat(chatId, action.enabled);
620
+ return {
621
+ kind: 'notice',
622
+ callbackText: `Workflow ${action.enabled ? 'On' : 'Off'}`,
623
+ notice: {
624
+ title: 'Workflow Orchestration',
625
+ value: action.enabled ? 'On' : 'Off',
626
+ detail: action.enabled
627
+ ? `${cs.agent} · multi-agent fan-out enabled · takes effect next message`
628
+ : `${cs.agent} · Workflow tool disabled · takes effect next message`,
629
+ },
630
+ };
631
+ }
632
+ }
633
+ }