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,55 @@
1
+ /**
2
+ * Runtime session status helpers for dashboard polling.
3
+ */
4
+ import { isRunningSessionStale } from '../agent/index.js';
5
+ /** Age threshold used when the owning PID is unknown or unverifiable. */
6
+ const STALE_RUNNING_AGE_MS = 30 * 60_000; // 30 minutes
7
+ function getSessionRuntime(bot, session) {
8
+ const sessionId = session.sessionId || null;
9
+ if (!sessionId)
10
+ return null;
11
+ return bot.sessionStates.get(`${session.agent}:${sessionId}`) || null;
12
+ }
13
+ function resolveIsRunning(session, runtime) {
14
+ if (runtime?.runningTaskIds.size)
15
+ return { isRunning: true, isStale: false };
16
+ if (!session.running)
17
+ return { isRunning: false, isStale: false };
18
+ const stale = isRunningSessionStale({
19
+ runState: session.runState ?? 'running',
20
+ runPid: session.runPid ?? null,
21
+ runUpdatedAt: session.runUpdatedAt ?? null,
22
+ }, STALE_RUNNING_AGE_MS);
23
+ return stale ? { isRunning: false, isStale: true } : { isRunning: true, isStale: false };
24
+ }
25
+ export function getSessionStatusForChat(bot, chat, session) {
26
+ const runtime = getSessionRuntime(bot, session);
27
+ const sessionId = session.sessionId || null;
28
+ const isCurrent = !!sessionId && (runtime
29
+ ? chat.activeSessionKey === runtime.key
30
+ : chat.agent === session.agent && chat.sessionId === sessionId);
31
+ const { isRunning, isStale } = resolveIsRunning(session, runtime);
32
+ return { runtime, isCurrent, isRunning, isStale };
33
+ }
34
+ export function getSessionStatusForBot(bot, session) {
35
+ const runtime = getSessionRuntime(bot, session);
36
+ const sessionId = session.sessionId || null;
37
+ let isCurrent = false;
38
+ if (sessionId) {
39
+ for (const [, chat] of bot.chats) {
40
+ if (runtime) {
41
+ if (chat.activeSessionKey === runtime.key) {
42
+ isCurrent = true;
43
+ break;
44
+ }
45
+ continue;
46
+ }
47
+ if (chat.agent === session.agent && chat.sessionId === sessionId) {
48
+ isCurrent = true;
49
+ break;
50
+ }
51
+ }
52
+ }
53
+ const { isRunning, isStale } = resolveIsRunning(session, runtime);
54
+ return { runtime, isCurrent, isRunning, isStale };
55
+ }
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Stream preview parsing helpers for live message updates.
3
+ *
4
+ * Used by IM channels (Telegram / Feishu / WeChat). The dashboard reads the
5
+ * raw `StreamSnapshot.activity` string directly and renders independently —
6
+ * none of the compaction in this file flows to it. Keep changes that improve
7
+ * IM legibility free to touch this surface; if a change would also alter what
8
+ * the dashboard ultimately shows, do it in the dashboard layer instead.
9
+ */
10
+ /**
11
+ * Shrink absolute paths that bloat IM cards on small screens. Any absolute
12
+ * path with 4+ segments collapses to `…/<last-two-segments>` so directory
13
+ * context is kept while the leading `/Users/…/long/project/root/` noise is
14
+ * dropped. Length-based gating made the output inconsistent — borderline
15
+ * paths (47 chars) sat next to compacted ones (52 chars), making the activity
16
+ * list look broken. Relative paths and short paths (<4 segments, e.g.
17
+ * `~/foo`, `/tmp/x.log`) are passed through unchanged.
18
+ */
19
+ function compactActivityPath(token) {
20
+ if (!token.includes('/'))
21
+ return token;
22
+ const segments = token.split('/').filter(Boolean);
23
+ if (segments.length < 4)
24
+ return token;
25
+ const tail = segments.slice(-2).join('/');
26
+ return `…/${tail}`;
27
+ }
28
+ function compactPathsInActivityLine(line) {
29
+ // Conservative: only target obviously absolute paths starting with `/` or
30
+ // `~/`. Inline file:line references (`foo/bar.ts:42`) keep their structure
31
+ // since the prefix is short anyway.
32
+ return line.replace(/(^|\s)([~/][^\s]+)/g, (_match, lead, raw) => {
33
+ const trailingPunct = raw.match(/[)\],.;!?]+$/)?.[0] ?? '';
34
+ const path = trailingPunct ? raw.slice(0, -trailingPunct.length) : raw;
35
+ return `${lead}${compactActivityPath(path)}${trailingPunct}`;
36
+ });
37
+ }
38
+ const TOOL_DONE_RE = /^(.+?)\s+(done|failed)$/;
39
+ /** "X -> Y" pattern produced by `summarizeClaudeToolResult` for tools whose
40
+ * result has body text (im_ask_user, ToolSearch, MCP tools, …). The Y half
41
+ * is the tool's response — capturing it lets us collapse the pre-event
42
+ * (`Ask user: q`) and the post-event (`Ask user: q -> A: …`) into a single
43
+ * line in the narrative instead of leaving both sitting around. */
44
+ const TOOL_ARROW_RE = /^(.+?)\s*->\s*(.+)$/;
45
+ const INJECTED_PROMPT_MARKERS = [
46
+ '\n[Session Workspace]',
47
+ '\n[Telegram Artifact Return]',
48
+ '\n[Artifact Return]',
49
+ ];
50
+ export function stripInjectedPrompts(text) {
51
+ for (const marker of INJECTED_PROMPT_MARKERS) {
52
+ const idx = text.indexOf(marker);
53
+ if (idx >= 0)
54
+ return text.slice(0, idx).trim();
55
+ }
56
+ return text.trim();
57
+ }
58
+ export function summarizePromptForStatus(prompt, maxLen = 50) {
59
+ const clean = stripInjectedPrompts(prompt).replace(/\s+/g, ' ').trim();
60
+ if (!clean)
61
+ return '';
62
+ if (clean.length <= maxLen)
63
+ return clean;
64
+ return clean.slice(0, Math.max(0, maxLen - 3)).trimEnd() + '...';
65
+ }
66
+ function parseClaudeShellActivity(line) {
67
+ const prefix = 'Run shell: ';
68
+ if (!line.startsWith(prefix))
69
+ return null;
70
+ const detail = line.slice(prefix.length).trim();
71
+ if (!detail)
72
+ return { key: prefix.trim(), status: 'active' };
73
+ const doneIdx = detail.indexOf(' -> ');
74
+ if (doneIdx > 0) {
75
+ return {
76
+ key: detail.slice(0, doneIdx).trim(),
77
+ status: 'done',
78
+ };
79
+ }
80
+ const failed = detail.match(/^(.*)\sfailed(?::.*)?$/);
81
+ if (failed?.[1]?.trim()) {
82
+ return {
83
+ key: failed[1].trim(),
84
+ status: 'failed',
85
+ };
86
+ }
87
+ if (detail.endsWith(' done')) {
88
+ const key = detail.slice(0, -' done'.length).trim();
89
+ return { key: key || detail, status: 'done' };
90
+ }
91
+ return { key: detail, status: 'active' };
92
+ }
93
+ export function parseActivitySummary(activity) {
94
+ const narrative = [];
95
+ let failedCommands = 0;
96
+ let activeCommands = 0;
97
+ let completedCommands = 0;
98
+ const activeClaudeShells = new Map();
99
+ // Track narrative indices keyed by their normalized start text so a later
100
+ // "X done" / "X failed" line collapses the prior "X" entry instead of
101
+ // appending. Avoids the double-line spam in IM cards where each tool call
102
+ // shows both its in-progress and completed line.
103
+ const pendingNarrative = new Map();
104
+ const pushPending = (key, index) => {
105
+ const slot = pendingNarrative.get(key);
106
+ if (slot)
107
+ slot.push(index);
108
+ else
109
+ pendingNarrative.set(key, [index]);
110
+ };
111
+ const popPending = (key) => {
112
+ const slot = pendingNarrative.get(key);
113
+ if (!slot || !slot.length)
114
+ return null;
115
+ const idx = slot.shift();
116
+ if (!slot.length)
117
+ pendingNarrative.delete(key);
118
+ return idx;
119
+ };
120
+ for (const rawLine of activity.split('\n')) {
121
+ const line = compactPathsInActivityLine(rawLine.replace(/\s+/g, ' ').trim());
122
+ if (!line)
123
+ continue;
124
+ const claudeShell = parseClaudeShellActivity(line);
125
+ if (claudeShell) {
126
+ const key = claudeShell.key || 'Run shell';
127
+ const current = activeClaudeShells.get(key) || 0;
128
+ if (claudeShell.status === 'active') {
129
+ activeClaudeShells.set(key, current + 1);
130
+ }
131
+ else {
132
+ if (current > 0)
133
+ activeClaudeShells.set(key, current - 1);
134
+ if (claudeShell.status === 'done')
135
+ completedCommands++;
136
+ else
137
+ failedCommands++;
138
+ }
139
+ continue;
140
+ }
141
+ if (line.startsWith('$ ')) {
142
+ activeCommands++;
143
+ continue;
144
+ }
145
+ if (line.startsWith('Ran: ')) {
146
+ completedCommands++;
147
+ continue;
148
+ }
149
+ const executed = line.match(/^Executed (\d+) command(?:s)?\.$/);
150
+ if (executed) {
151
+ completedCommands = Math.max(completedCommands, parseInt(executed[1], 10) || 0);
152
+ continue;
153
+ }
154
+ const running = line.match(/^Running (\d+) command(?:s)?\.\.\.$/);
155
+ if (running) {
156
+ activeCommands = Math.max(activeCommands, parseInt(running[1], 10) || 0);
157
+ continue;
158
+ }
159
+ const failed = line.match(/^Command failed \((\d+)\):/);
160
+ if (failed) {
161
+ failedCommands++;
162
+ continue;
163
+ }
164
+ if (/^Command failed \(\d+\)$/.test(line)) {
165
+ failedCommands++;
166
+ continue;
167
+ }
168
+ // Pair "X" → "X done"/"X failed": rewrite the prior in-progress entry in
169
+ // place rather than appending a second line. Falls back to a plain append
170
+ // when no matching start exists (e.g. the start line was trimmed off by a
171
+ // history window earlier in the run).
172
+ const doneMatch = line.match(TOOL_DONE_RE);
173
+ if (doneMatch) {
174
+ const baseKey = doneMatch[1].trim();
175
+ const status = doneMatch[2];
176
+ const idx = popPending(baseKey);
177
+ if (idx != null) {
178
+ narrative[idx] = status === 'failed' ? `${baseKey} failed` : baseKey;
179
+ continue;
180
+ }
181
+ narrative.push(status === 'failed' ? `${baseKey} failed` : baseKey);
182
+ continue;
183
+ }
184
+ // Pair "X" → "X -> Y" (im_ask_user, ToolSearch, MCP tools, … — any tool
185
+ // whose summarizeClaudeToolResult fell into the arrow branch). Without
186
+ // this, the IM card shows the question and the answered form side by
187
+ // side. We replace the pending entry with the full arrow form so the
188
+ // narrative carries the answer in a single line.
189
+ const arrowMatch = line.match(TOOL_ARROW_RE);
190
+ if (arrowMatch) {
191
+ const baseKey = arrowMatch[1].trim();
192
+ const idx = popPending(baseKey);
193
+ if (idx != null) {
194
+ narrative[idx] = line;
195
+ continue;
196
+ }
197
+ }
198
+ pushPending(line, narrative.length);
199
+ narrative.push(line);
200
+ }
201
+ for (const pending of activeClaudeShells.values()) {
202
+ activeCommands += pending;
203
+ }
204
+ return { narrative: collapseConsecutiveDuplicates(narrative), failedCommands, completedCommands, activeCommands };
205
+ }
206
+ /**
207
+ * Walk the narrative and collapse runs of identical lines into `X ×N`. The
208
+ * input narrative often contains repeats when the model calls the same tool
209
+ * multiple times in a row (two consecutive `Edit README.md`, three `Read X`,
210
+ * …) — listing them N times wastes IM card real estate without adding
211
+ * information. Non-adjacent duplicates are preserved to keep the temporal
212
+ * order intact.
213
+ */
214
+ function collapseConsecutiveDuplicates(narrative) {
215
+ const out = [];
216
+ let runStart = -1;
217
+ let runCount = 0;
218
+ const flush = () => {
219
+ if (runStart < 0)
220
+ return;
221
+ out.push(runCount > 1 ? `${narrative[runStart]} ×${runCount}` : narrative[runStart]);
222
+ runStart = -1;
223
+ runCount = 0;
224
+ };
225
+ for (let i = 0; i < narrative.length; i++) {
226
+ if (runStart >= 0 && narrative[i] === narrative[runStart]) {
227
+ runCount++;
228
+ continue;
229
+ }
230
+ flush();
231
+ runStart = i;
232
+ runCount = 1;
233
+ }
234
+ flush();
235
+ return out;
236
+ }
237
+ export function formatActivityCommandSummary(completedCommands, activeCommands, failedCommands = 0) {
238
+ const parts = [];
239
+ if (failedCommands > 0)
240
+ parts.push(`${failedCommands} failed`);
241
+ if (completedCommands > 0)
242
+ parts.push(`${completedCommands} done`);
243
+ if (activeCommands > 0)
244
+ parts.push(`${activeCommands} running`);
245
+ return parts.length ? `commands: ${parts.join(', ')}` : '';
246
+ }
247
+ export function summarizeActivityForPreview(activity) {
248
+ const summary = parseActivitySummary(activity);
249
+ const lines = [...summary.narrative];
250
+ const commandSummary = formatActivityCommandSummary(summary.completedCommands, summary.activeCommands, summary.failedCommands);
251
+ if (commandSummary)
252
+ lines.push(commandSummary);
253
+ return lines.join('\n');
254
+ }
255
+ export function hasPreviewMeta(meta) {
256
+ return meta?.contextPercent != null;
257
+ }
258
+ export function samePreviewMeta(a, b) {
259
+ return (a?.contextPercent ?? null) === (b?.contextPercent ?? null);
260
+ }
261
+ export function samePreviewPlan(a, b) {
262
+ if ((a?.explanation ?? null) !== (b?.explanation ?? null))
263
+ return false;
264
+ const aSteps = a?.steps ?? [];
265
+ const bSteps = b?.steps ?? [];
266
+ if (aSteps.length !== bSteps.length)
267
+ return false;
268
+ for (let i = 0; i < aSteps.length; i++) {
269
+ if (aSteps[i].status !== bSteps[i].status)
270
+ return false;
271
+ if (aSteps[i].step !== bSteps[i].step)
272
+ return false;
273
+ }
274
+ return true;
275
+ }
276
+ function normalizePlanStep(step) {
277
+ return step.replace(/\s+/g, ' ').trim();
278
+ }
279
+ export function renderPlanForPreview(plan) {
280
+ if (!plan?.steps.length)
281
+ return '';
282
+ const total = plan.steps.length;
283
+ const completed = plan.steps.filter(step => step.status === 'completed').length;
284
+ const lines = [`Plan ${completed}/${total}`];
285
+ // Show the most recent / currently-active slice of the plan. Live viewers
286
+ // care about the in-progress + upcoming steps; the dozen already-completed
287
+ // ones at the top of the list are just visual ballast (the `completed/total`
288
+ // header already conveys the overall progress).
289
+ const WINDOW = 4;
290
+ let startIdx = 0;
291
+ if (total > WINDOW) {
292
+ // Center the window on the in-progress step when one exists; otherwise
293
+ // anchor to the tail so the next pending steps are visible.
294
+ const inProgressIdx = plan.steps.findIndex(step => step.status === 'inProgress');
295
+ const anchor = inProgressIdx >= 0 ? inProgressIdx : total - 1;
296
+ startIdx = Math.max(0, Math.min(total - WINDOW, anchor - Math.floor(WINDOW / 2)));
297
+ }
298
+ const dropped = startIdx;
299
+ if (dropped > 0)
300
+ lines.push(`... +${dropped} earlier`);
301
+ for (const step of plan.steps.slice(startIdx, startIdx + WINDOW)) {
302
+ const prefix = step.status === 'completed' ? '[x]' : step.status === 'inProgress' ? '[>]' : '[ ]';
303
+ lines.push(`${prefix} ${normalizePlanStep(step.step)}`);
304
+ }
305
+ const remaining = total - (startIdx + WINDOW);
306
+ if (remaining > 0)
307
+ lines.push(`... +${remaining} more`);
308
+ return lines.join('\n');
309
+ }