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,1206 @@
1
+ /**
2
+ * Session workspace management, metadata persistence, classification, and export/import.
3
+ */
4
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { dedupeStrings, shortValue, firstNonEmptyLine, normalizeErrorMessage, normalizeStreamPreviewPlan, isPendingSessionId, agentLog, } from './utils.js';
8
+ import { getDriver } from './driver.js';
9
+ import { collapseSkillPrompt } from './skills.js';
10
+ import { SESSION_RUNNING_THRESHOLD_MS } from '../core/constants.js';
11
+ // ---------------------------------------------------------------------------
12
+ // Private helpers
13
+ // ---------------------------------------------------------------------------
14
+ function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); }
15
+ function readJsonFile(filePath, fallback) {
16
+ try {
17
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
18
+ }
19
+ catch {
20
+ return fallback;
21
+ }
22
+ }
23
+ function writeJsonFile(filePath, value) {
24
+ ensureDir(path.dirname(filePath));
25
+ const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
26
+ fs.writeFileSync(tmpPath, JSON.stringify(value, null, 2));
27
+ fs.renameSync(tmpPath, filePath);
28
+ }
29
+ function removeFileIfExists(filePath) { try {
30
+ fs.rmSync(filePath, { force: true });
31
+ }
32
+ catch { } }
33
+ function trimSessionText(value, max = 24_000) {
34
+ const text = typeof value === 'string' ? value.trim() : '';
35
+ if (!text)
36
+ return null;
37
+ if (text.length <= max)
38
+ return text;
39
+ return `${text.slice(0, Math.max(0, max - 3)).trimEnd()}...`;
40
+ }
41
+ // ---------------------------------------------------------------------------
42
+ // Constants
43
+ // ---------------------------------------------------------------------------
44
+ const PIKILOOP_DIR = '.pikiloop';
45
+ const PIKILOOP_SESSIONS_DIR = path.join(PIKILOOP_DIR, 'sessions');
46
+ const PIKILOOP_SESSION_INDEX = path.join(PIKILOOP_SESSIONS_DIR, 'index.json');
47
+ const PIKILOOP_LEGACY_WORKSPACES_DIR = path.join(PIKILOOP_DIR, 'workspaces');
48
+ const SESSION_WORKSPACE_DIR = 'workspace';
49
+ const SESSION_META_FILE = 'session.json';
50
+ // return.json and artifact constants removed — file return is now handled by MCP bridge
51
+ // ---------------------------------------------------------------------------
52
+ // Path helpers
53
+ // ---------------------------------------------------------------------------
54
+ function sessionIndexPath(workdir) { return path.join(workdir, PIKILOOP_SESSION_INDEX); }
55
+ function sessionDirPath(workdir, agent, sessionId) { return path.join(workdir, PIKILOOP_SESSIONS_DIR, agent, sessionId); }
56
+ function legacySessionWorkspacePath(workdir, agent, sessionId) { return path.join(workdir, PIKILOOP_LEGACY_WORKSPACES_DIR, agent, sessionId); }
57
+ function sessionWorkspacePath(workdir, agent, sessionId) { return path.join(sessionDirPath(workdir, agent, sessionId), SESSION_WORKSPACE_DIR); }
58
+ function sessionRootFromWorkspacePath(workspacePath) {
59
+ const resolved = path.resolve(workspacePath);
60
+ return path.basename(resolved) === SESSION_WORKSPACE_DIR ? path.dirname(resolved) : resolved;
61
+ }
62
+ function sessionMetaPath(workspacePath) { return path.join(sessionRootFromWorkspacePath(workspacePath), SESSION_META_FILE); }
63
+ function legacySessionMetaPath(workspacePath) { return path.join(workspacePath, PIKILOOP_DIR, SESSION_META_FILE); }
64
+ // ---------------------------------------------------------------------------
65
+ // ID helpers
66
+ // ---------------------------------------------------------------------------
67
+ /** Generate a temporary session ID for new sessions before the agent assigns one. */
68
+ function nextPendingSessionId() { return `pending_${crypto.randomBytes(6).toString('hex')}`; }
69
+ function nextThreadId() { return `thread_${crypto.randomBytes(6).toString('hex')}`; }
70
+ function legacyThreadId(agent, sessionId) { return `legacy:${agent}:${sessionId}`; }
71
+ function normalizeThreadId(value) {
72
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
73
+ }
74
+ // ---------------------------------------------------------------------------
75
+ // Run state helpers
76
+ // ---------------------------------------------------------------------------
77
+ function normalizeSessionRunState(rawState) {
78
+ const state = typeof rawState === 'string' ? rawState.trim().toLowerCase() : '';
79
+ if (state === 'completed' || state === 'incomplete' || state === 'running')
80
+ return state;
81
+ return 'completed';
82
+ }
83
+ function normalizeSessionRunDetail(_rawState, rawDetail) {
84
+ const detail = typeof rawDetail === 'string' ? rawDetail.trim() : '';
85
+ if (detail)
86
+ return shortValue(detail, 180);
87
+ return null;
88
+ }
89
+ function normalizeSessionRunUpdatedAt(rawUpdatedAt, fallback) {
90
+ return typeof rawUpdatedAt === 'string' && rawUpdatedAt.trim() ? rawUpdatedAt : fallback;
91
+ }
92
+ export function setSessionRunState(record, runState, runDetail, runUpdatedAt) {
93
+ record.runState = runState;
94
+ record.runDetail = runDetail ? shortValue(runDetail, 180) : null;
95
+ record.runUpdatedAt = runUpdatedAt || new Date().toISOString();
96
+ record.runPid = runState === 'running' ? process.pid : null;
97
+ }
98
+ function incompleteRunDetail(result) {
99
+ if (result.stopReason === 'interrupted')
100
+ return 'Interrupted by user.';
101
+ if (result.stopReason === 'timeout')
102
+ return 'Timed out before completion.';
103
+ if (result.stopReason === 'max_tokens')
104
+ return 'Stopped before completion: max tokens reached.';
105
+ const error = normalizeErrorMessage(result.error);
106
+ if (error)
107
+ return shortValue(error, 180);
108
+ const stopReason = normalizeErrorMessage(result.stopReason);
109
+ if (stopReason)
110
+ return `Stopped before completion: ${shortValue(stopReason, 120)}`;
111
+ const message = firstNonEmptyLine(result.message || '');
112
+ return message ? shortValue(message, 180) : 'Last run did not complete.';
113
+ }
114
+ /**
115
+ * Check whether a process is still alive. Returns true when the PID exists and we can
116
+ * signal it, false when the process is definitively gone, and null when we cannot tell
117
+ * (e.g. owned by a different user — permission denied).
118
+ */
119
+ export function isProcessAlive(pid) {
120
+ if (!pid || !Number.isFinite(pid) || pid <= 0)
121
+ return null;
122
+ try {
123
+ process.kill(pid, 0);
124
+ return true;
125
+ }
126
+ catch (err) {
127
+ if (err?.code === 'ESRCH')
128
+ return false;
129
+ if (err?.code === 'EPERM')
130
+ return true;
131
+ return null;
132
+ }
133
+ }
134
+ /**
135
+ * Heuristic staleness check for a session record marked 'running'. Returns true when
136
+ * the record should be downgraded to 'incomplete' — i.e. the owning process is gone,
137
+ * or (if PID is missing) the last update is older than `ageThresholdMs`.
138
+ *
139
+ * Returns false if the session might still be live and should be left alone.
140
+ */
141
+ export function isRunningSessionStale(record, ageThresholdMs) {
142
+ if (record.runState !== 'running')
143
+ return false;
144
+ const alive = isProcessAlive(record.runPid ?? null);
145
+ if (alive === false)
146
+ return true;
147
+ if (alive === true)
148
+ return false;
149
+ const age = record.runUpdatedAt ? Date.now() - Date.parse(record.runUpdatedAt) : Infinity;
150
+ return age > ageThresholdMs;
151
+ }
152
+ /**
153
+ * Scan the session index for a workdir and downgrade any 'running' record whose
154
+ * owning process is no longer alive (or that has gone stale past `ageThresholdMs`).
155
+ * Returns the number of records downgraded. Safe to call at startup and periodically.
156
+ */
157
+ export function reconcileOrphanedRunningSessions(workdir, ageThresholdMs = 30 * 60_000) {
158
+ const resolvedWorkdir = path.resolve(workdir);
159
+ const index = loadSessionIndex(resolvedWorkdir);
160
+ const downgraded = [];
161
+ for (const record of index.sessions) {
162
+ if (!isRunningSessionStale(record, ageThresholdMs))
163
+ continue;
164
+ setSessionRunState(record, 'incomplete', 'Process exited before reporting completion.');
165
+ downgraded.push(record);
166
+ }
167
+ if (downgraded.length > 0) {
168
+ writeSessionIndex(resolvedWorkdir, index.sessions);
169
+ for (const record of downgraded) {
170
+ try {
171
+ writeSessionMeta(record);
172
+ }
173
+ catch { }
174
+ }
175
+ agentLog(`[sessions] reconciled ${downgraded.length} orphaned running session(s) in ${resolvedWorkdir}`);
176
+ }
177
+ return downgraded.length;
178
+ }
179
+ export function applySessionRunResult(record, result) {
180
+ if (result.ok && !result.incomplete) {
181
+ setSessionRunState(record, 'completed', null);
182
+ }
183
+ else {
184
+ setSessionRunState(record, 'incomplete', incompleteRunDetail(result));
185
+ }
186
+ // Auto-classify the stream result
187
+ const classification = classifySession({ ...result, activity: result.activity ?? null });
188
+ record.classification = classification;
189
+ // Only set userStatus if not manually overridden by the user
190
+ if (!record.userStatus) {
191
+ record.userStatus = deriveUserStatus(classification.outcome);
192
+ }
193
+ }
194
+ function normalizeHandoverRef(value) {
195
+ if (!value || typeof value !== 'object')
196
+ return null;
197
+ const v = value;
198
+ const agent = typeof v.agent === 'string' ? v.agent.trim() : '';
199
+ const sessionId = typeof v.sessionId === 'string' ? v.sessionId.trim() : '';
200
+ if (!agent || !sessionId)
201
+ return null;
202
+ return { agent: agent, sessionId };
203
+ }
204
+ function normalizeSessionRecord(raw, workdir) {
205
+ // Support both new format (sessionId) and legacy format (localSessionId + engineSessionId)
206
+ const sessionId = typeof raw?.sessionId === 'string' ? raw.sessionId.trim()
207
+ : typeof raw?.engineSessionId === 'string' && raw.engineSessionId.trim() ? raw.engineSessionId.trim()
208
+ : typeof raw?.localSessionId === 'string' ? raw.localSessionId.trim()
209
+ : '';
210
+ const agent = typeof raw?.agent === 'string' ? raw.agent.trim() : null;
211
+ if (!sessionId || !agent)
212
+ return null;
213
+ const workspacePath = typeof raw?.workspacePath === 'string' && raw.workspacePath.trim()
214
+ ? path.resolve(raw.workspacePath)
215
+ : sessionWorkspacePath(workdir, agent, sessionId);
216
+ return {
217
+ sessionId, agent, workdir,
218
+ workspacePath,
219
+ threadId: normalizeThreadId(raw?.threadId) || legacyThreadId(agent, sessionId),
220
+ createdAt: typeof raw?.createdAt === 'string' && raw.createdAt.trim() ? raw.createdAt : new Date().toISOString(),
221
+ updatedAt: typeof raw?.updatedAt === 'string' && raw.updatedAt.trim() ? raw.updatedAt : new Date().toISOString(),
222
+ title: typeof raw?.title === 'string' && raw.title.trim() ? raw.title.trim() : null,
223
+ model: typeof raw?.model === 'string' && raw.model.trim() ? raw.model.trim() : null,
224
+ thinkingEffort: typeof raw?.thinkingEffort === 'string' && raw.thinkingEffort.trim() ? raw.thinkingEffort.trim() : null,
225
+ profileId: typeof raw?.profileId === 'string' && raw.profileId.trim() ? raw.profileId.trim() : null,
226
+ stagedFiles: Array.isArray(raw?.stagedFiles) ? dedupeStrings(raw.stagedFiles.filter((v) => typeof v === 'string')) : [],
227
+ lastUserAttachments: Array.isArray(raw?.lastUserAttachments)
228
+ ? dedupeStrings(raw.lastUserAttachments.filter((v) => typeof v === 'string'))
229
+ : [],
230
+ runState: normalizeSessionRunState(raw?.runState),
231
+ runDetail: normalizeSessionRunDetail(raw?.runState, raw?.runDetail),
232
+ runUpdatedAt: normalizeSessionRunUpdatedAt(raw?.runUpdatedAt, typeof raw?.updatedAt === 'string' && raw.updatedAt.trim() ? raw.updatedAt : new Date().toISOString()),
233
+ runPid: typeof raw?.runPid === 'number' && Number.isFinite(raw.runPid) ? raw.runPid : null,
234
+ classification: raw?.classification ?? null,
235
+ userStatus: raw?.userStatus ?? null,
236
+ userNote: typeof raw?.userNote === 'string' ? raw.userNote : null,
237
+ lastQuestion: typeof raw?.lastQuestion === 'string' ? raw.lastQuestion : null,
238
+ lastAnswer: typeof raw?.lastAnswer === 'string' ? raw.lastAnswer : null,
239
+ lastMessageText: typeof raw?.lastMessageText === 'string' ? raw.lastMessageText : null,
240
+ lastThinking: trimSessionText(raw?.lastThinking),
241
+ lastPlan: normalizeStreamPreviewPlan(raw?.lastPlan),
242
+ migratedFrom: raw?.migratedFrom ?? null,
243
+ migratedTo: raw?.migratedTo ?? null,
244
+ linkedSessions: Array.isArray(raw?.linkedSessions) ? raw.linkedSessions : [],
245
+ handoverFrom: normalizeHandoverRef(raw?.handoverFrom),
246
+ };
247
+ }
248
+ // ---------------------------------------------------------------------------
249
+ // Index persistence
250
+ // ---------------------------------------------------------------------------
251
+ /**
252
+ * Parsed-index cache keyed by index-file identity (mtime + size). loadSessionIndex
253
+ * sits on the per-turn read path (getSessionStoredConfig), every dashboard session
254
+ * read, and is hit several times within a single save flow — each call otherwise
255
+ * does readFileSync + JSON.parse + a per-record normalize pass. A cache hit costs
256
+ * one statSync. writeSessionIndex invalidates the entry, so a write is always
257
+ * re-read fresh; every writer mutates records then writes, so the shared cache is
258
+ * never left serving a half-mutated record.
259
+ */
260
+ const sessionIndexCache = new Map();
261
+ /** Sort session records newest-first, parsing each `updatedAt` only once. */
262
+ function sortByUpdatedAtDesc(records) {
263
+ const at = new Map(records.map(r => [r, Date.parse(r.updatedAt) || 0]));
264
+ return records.sort((a, b) => at.get(b) - at.get(a));
265
+ }
266
+ function loadSessionIndex(workdir) {
267
+ const filePath = sessionIndexPath(workdir);
268
+ let stat = null;
269
+ try {
270
+ stat = fs.statSync(filePath);
271
+ }
272
+ catch { }
273
+ if (stat) {
274
+ const cached = sessionIndexCache.get(filePath);
275
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size)
276
+ return cached.data;
277
+ }
278
+ const parsed = readJsonFile(filePath, { version: 1, sessions: [] });
279
+ const sessions = Array.isArray(parsed?.sessions) ? parsed.sessions : [];
280
+ const data = {
281
+ version: 1,
282
+ sessions: sessions
283
+ .map((entry) => normalizeSessionRecord(entry, workdir))
284
+ .filter((entry) => !!entry)
285
+ .filter((entry) => !isPendingSessionId(entry.sessionId) || fs.existsSync(sessionRootFromWorkspacePath(entry.workspacePath))),
286
+ };
287
+ if (stat)
288
+ sessionIndexCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, data });
289
+ return data;
290
+ }
291
+ function writeSessionIndex(workdir, sessions) {
292
+ const filePath = sessionIndexPath(workdir);
293
+ writeJsonFile(filePath, { version: 1, sessions });
294
+ sessionIndexCache.delete(filePath);
295
+ }
296
+ function writeSessionMeta(record) {
297
+ writeJsonFile(sessionMetaPath(record.workspacePath), {
298
+ sessionId: record.sessionId, agent: record.agent, workdir: record.workdir,
299
+ workspacePath: record.workspacePath,
300
+ threadId: record.threadId,
301
+ createdAt: record.createdAt, updatedAt: record.updatedAt,
302
+ title: record.title, model: record.model, thinkingEffort: record.thinkingEffort, stagedFiles: record.stagedFiles,
303
+ runState: record.runState, runDetail: record.runDetail, runUpdatedAt: record.runUpdatedAt,
304
+ runPid: record.runPid,
305
+ classification: record.classification,
306
+ userStatus: record.userStatus,
307
+ userNote: record.userNote,
308
+ lastQuestion: record.lastQuestion,
309
+ lastAnswer: record.lastAnswer,
310
+ lastMessageText: record.lastMessageText,
311
+ lastThinking: record.lastThinking,
312
+ lastPlan: record.lastPlan,
313
+ migratedFrom: record.migratedFrom,
314
+ migratedTo: record.migratedTo,
315
+ linkedSessions: record.linkedSessions,
316
+ handoverFrom: record.handoverFrom ?? null,
317
+ });
318
+ }
319
+ // ---------------------------------------------------------------------------
320
+ // File / directory helpers
321
+ // ---------------------------------------------------------------------------
322
+ function copyPath(sourcePath, targetPath) {
323
+ const stat = fs.statSync(sourcePath);
324
+ if (stat.isDirectory()) {
325
+ fs.cpSync(sourcePath, targetPath, { recursive: true, force: true });
326
+ return;
327
+ }
328
+ ensureDir(path.dirname(targetPath));
329
+ fs.copyFileSync(sourcePath, targetPath);
330
+ }
331
+ function createSessionDirAlias(aliasPath, targetPath) {
332
+ if (fs.existsSync(aliasPath) || !fs.existsSync(targetPath))
333
+ return;
334
+ try {
335
+ ensureDir(path.dirname(aliasPath));
336
+ const relativeTarget = path.relative(path.dirname(aliasPath), targetPath) || '.';
337
+ fs.symlinkSync(relativeTarget, aliasPath, process.platform === 'win32' ? 'junction' : 'dir');
338
+ }
339
+ catch { }
340
+ }
341
+ // ---------------------------------------------------------------------------
342
+ // Migration
343
+ // ---------------------------------------------------------------------------
344
+ function migrateSessionLayout(workdir, record) {
345
+ const targetSessionDir = sessionDirPath(workdir, record.agent, record.sessionId);
346
+ const targetWorkspacePath = sessionWorkspacePath(workdir, record.agent, record.sessionId);
347
+ const currentWorkspacePath = path.resolve(record.workspacePath || targetWorkspacePath);
348
+ const legacyWp = path.resolve(legacySessionWorkspacePath(workdir, record.agent, record.sessionId));
349
+ ensureDir(targetSessionDir);
350
+ ensureDir(targetWorkspacePath);
351
+ for (const sourceWorkspacePath of dedupeStrings([currentWorkspacePath, legacyWp])) {
352
+ if (sourceWorkspacePath === targetWorkspacePath || !fs.existsSync(sourceWorkspacePath))
353
+ continue;
354
+ if (!fs.statSync(sourceWorkspacePath).isDirectory())
355
+ continue;
356
+ for (const entry of fs.readdirSync(sourceWorkspacePath)) {
357
+ if (entry === PIKILOOP_DIR)
358
+ continue;
359
+ copyPath(path.join(sourceWorkspacePath, entry), path.join(targetWorkspacePath, entry));
360
+ }
361
+ if (sourceWorkspacePath === legacyWp)
362
+ fs.rmSync(sourceWorkspacePath, { recursive: true, force: true });
363
+ }
364
+ record.workspacePath = path.resolve(targetWorkspacePath);
365
+ return record;
366
+ }
367
+ // ---------------------------------------------------------------------------
368
+ // Save / update
369
+ // ---------------------------------------------------------------------------
370
+ export function saveSessionRecord(workdir, record) {
371
+ record = migrateSessionLayout(workdir, record);
372
+ ensureDir(sessionDirPath(workdir, record.agent, record.sessionId));
373
+ ensureDir(record.workspacePath);
374
+ const index = loadSessionIndex(workdir);
375
+ record.threadId = normalizeThreadId(record.threadId) || legacyThreadId(record.agent, record.sessionId);
376
+ record.updatedAt = new Date().toISOString();
377
+ const pos = index.sessions.findIndex(entry => entry.agent === record.agent && entry.sessionId === record.sessionId);
378
+ if (pos >= 0)
379
+ index.sessions[pos] = record;
380
+ else
381
+ index.sessions.unshift(record);
382
+ sortByUpdatedAtDesc(index.sessions);
383
+ writeSessionIndex(workdir, index.sessions);
384
+ writeSessionMeta(record);
385
+ return record;
386
+ }
387
+ /**
388
+ * Update mutable session metadata (classification, userStatus, userNote, links, migration)
389
+ * for an existing pikiloop-managed session. Returns true if the record was found and updated.
390
+ */
391
+ export function updateSessionMeta(workdir, agent, sessionId, patch) {
392
+ const resolvedWorkdir = path.resolve(workdir);
393
+ const index = loadSessionIndex(resolvedWorkdir);
394
+ const record = index.sessions.find(s => s.sessionId === sessionId && s.agent === agent);
395
+ if (!record)
396
+ return false;
397
+ if (patch.userStatus !== undefined)
398
+ record.userStatus = patch.userStatus;
399
+ if (patch.userNote !== undefined)
400
+ record.userNote = patch.userNote;
401
+ if (patch.classification !== undefined)
402
+ record.classification = patch.classification;
403
+ if (patch.migratedFrom !== undefined)
404
+ record.migratedFrom = patch.migratedFrom;
405
+ if (patch.migratedTo !== undefined)
406
+ record.migratedTo = patch.migratedTo;
407
+ if (patch.addLink) {
408
+ if (!record.linkedSessions)
409
+ record.linkedSessions = [];
410
+ const exists = record.linkedSessions.some(l => l.agent === patch.addLink.agent && l.sessionId === patch.addLink.sessionId);
411
+ if (!exists)
412
+ record.linkedSessions.push(patch.addLink);
413
+ }
414
+ record.updatedAt = new Date().toISOString();
415
+ writeSessionIndex(resolvedWorkdir, index.sessions);
416
+ writeSessionMeta(record);
417
+ return true;
418
+ }
419
+ /**
420
+ * Promote a pending session to a real session ID. Renames the workspace directory
421
+ * and updates the index. Called after the first stream returns the agent's native ID.
422
+ */
423
+ export function promoteSessionId(workdir, agent, pendingId, nativeId) {
424
+ if (!isPendingSessionId(pendingId) || !nativeId.trim())
425
+ return;
426
+ const resolvedWorkdir = path.resolve(workdir);
427
+ const index = loadSessionIndex(resolvedWorkdir);
428
+ const record = index.sessions.find(entry => entry.sessionId === pendingId && entry.agent === agent);
429
+ if (!record)
430
+ return;
431
+ const oldDir = sessionDirPath(resolvedWorkdir, agent, pendingId);
432
+ const newDir = sessionDirPath(resolvedWorkdir, agent, nativeId);
433
+ // Move workspace directory if it exists
434
+ if (fs.existsSync(oldDir) && !fs.existsSync(newDir)) {
435
+ try {
436
+ fs.renameSync(oldDir, newDir);
437
+ }
438
+ catch { /* cross-device: copy+delete */
439
+ try {
440
+ fs.cpSync(oldDir, newDir, { recursive: true });
441
+ fs.rmSync(oldDir, { recursive: true, force: true });
442
+ }
443
+ catch { }
444
+ }
445
+ createSessionDirAlias(oldDir, newDir);
446
+ }
447
+ writeSessionIndex(resolvedWorkdir, index.sessions.filter(entry => entry.agent !== agent || (entry.sessionId !== pendingId && entry.sessionId !== nativeId)));
448
+ record.sessionId = nativeId;
449
+ record.workspacePath = sessionWorkspacePath(resolvedWorkdir, agent, nativeId);
450
+ saveSessionRecord(resolvedWorkdir, record);
451
+ }
452
+ // ---------------------------------------------------------------------------
453
+ // Fork lineage
454
+ // ---------------------------------------------------------------------------
455
+ /**
456
+ * Record a fork relationship between two pikiloop-managed sessions.
457
+ *
458
+ * Sets `migratedFrom` (with kind='fork' + forkedAtTurn) on the child and
459
+ * appends the reverse link on the parent's `linkedSessions`. Both sides also
460
+ * get `migratedTo` set on the parent so the child is a discoverable twin.
461
+ *
462
+ * No-op if either record is missing — call sites are expected to ensure both
463
+ * managed records exist (the child is created via the fork stream completion).
464
+ */
465
+ export function recordFork(workdir, opts) {
466
+ const resolvedWorkdir = path.resolve(workdir);
467
+ const index = loadSessionIndex(resolvedWorkdir);
468
+ const parent = index.sessions.find(e => e.agent === opts.parent.agent && e.sessionId === opts.parent.sessionId);
469
+ const child = index.sessions.find(e => e.agent === opts.child.agent && e.sessionId === opts.child.sessionId);
470
+ if (!parent || !child)
471
+ return;
472
+ child.migratedFrom = {
473
+ agent: parent.agent,
474
+ sessionId: parent.sessionId,
475
+ kind: 'fork',
476
+ forkedAtTurn: opts.atTurn,
477
+ };
478
+ if (!parent.linkedSessions)
479
+ parent.linkedSessions = [];
480
+ const childRef = { agent: child.agent, sessionId: child.sessionId, kind: 'fork', forkedAtTurn: opts.atTurn };
481
+ if (!parent.linkedSessions.some(l => l.agent === child.agent && l.sessionId === child.sessionId)) {
482
+ parent.linkedSessions.push(childRef);
483
+ }
484
+ child.updatedAt = new Date().toISOString();
485
+ parent.updatedAt = new Date().toISOString();
486
+ writeSessionIndex(resolvedWorkdir, index.sessions);
487
+ writeSessionMeta(parent);
488
+ writeSessionMeta(child);
489
+ }
490
+ // ---------------------------------------------------------------------------
491
+ // Identity sync
492
+ // ---------------------------------------------------------------------------
493
+ export function syncManagedSessionIdentity(session, workdir, nativeId) {
494
+ const resolvedId = nativeId.trim();
495
+ if (!resolvedId || session.sessionId === resolvedId)
496
+ return false;
497
+ const resolvedWorkdir = path.resolve(workdir);
498
+ const previousId = session.sessionId;
499
+ if (isPendingSessionId(previousId)) {
500
+ // Pending → native: move the workspace dir into the native slot and
501
+ // remove the pending index entry (handled by promoteSessionId).
502
+ promoteSessionId(resolvedWorkdir, session.record.agent, previousId, resolvedId);
503
+ }
504
+ else {
505
+ // Native → native rotation (Claude `--resume` can rewrite the session id
506
+ // mid-stream). Drop the old index entry so the dashboard does not show a
507
+ // stale duplicate; both jsonl files stay on disk and the workspace stays
508
+ // under its original native id (the next saveSessionRecord will lay down
509
+ // a fresh dir under the new id).
510
+ const index = loadSessionIndex(resolvedWorkdir);
511
+ const filtered = index.sessions.filter(e => !(e.agent === session.record.agent && e.sessionId === previousId));
512
+ if (filtered.length !== index.sessions.length)
513
+ writeSessionIndex(resolvedWorkdir, filtered);
514
+ }
515
+ session.sessionId = resolvedId;
516
+ session.workspacePath = sessionWorkspacePath(resolvedWorkdir, session.record.agent, resolvedId);
517
+ session.record.sessionId = resolvedId;
518
+ session.record.workspacePath = session.workspacePath;
519
+ return true;
520
+ }
521
+ // ---------------------------------------------------------------------------
522
+ // Title / filename helpers
523
+ // ---------------------------------------------------------------------------
524
+ export function summarizePromptTitle(prompt) {
525
+ const raw = String(prompt || '').replace(/\r\n?/g, '\n');
526
+ const text = firstNonEmptyLine(raw).replace(/\s+/g, ' ').trim()
527
+ || raw.replace(/\s+/g, ' ').trim();
528
+ if (!text)
529
+ return null;
530
+ return text.length <= 120 ? text : `${text.slice(0, 117).trimEnd()}...`;
531
+ }
532
+ function safeWorkspaceFilename(filename) {
533
+ const base = path.basename(filename || 'file');
534
+ const sanitized = base.replace(/[^\w.\- ]+/g, '_').replace(/^\.+/, '').trim();
535
+ return sanitized || `file-${Date.now()}`;
536
+ }
537
+ function uniqueWorkspaceFilename(workspacePath, desiredName) {
538
+ const ext = path.extname(desiredName);
539
+ const stem = ext ? desiredName.slice(0, -ext.length) : desiredName;
540
+ let candidate = desiredName;
541
+ let index = 2;
542
+ while (fs.existsSync(path.join(workspacePath, candidate))) {
543
+ candidate = `${stem}-${index}${ext}`;
544
+ index++;
545
+ }
546
+ return candidate;
547
+ }
548
+ // ---------------------------------------------------------------------------
549
+ // Workspace file import
550
+ // ---------------------------------------------------------------------------
551
+ export function importFilesIntoWorkspace(workspacePath, files) {
552
+ const imported = [];
553
+ const realWorkspace = fs.realpathSync(workspacePath);
554
+ for (const filePath of files) {
555
+ const sourcePath = path.resolve(filePath);
556
+ if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isFile())
557
+ continue;
558
+ let relPath = path.relative(realWorkspace, sourcePath);
559
+ if (relPath && !relPath.startsWith('..') && !path.isAbsolute(relPath)) {
560
+ imported.push(relPath.split(path.sep).join(path.posix.sep));
561
+ continue;
562
+ }
563
+ const targetName = uniqueWorkspaceFilename(workspacePath, safeWorkspaceFilename(path.basename(sourcePath)));
564
+ fs.copyFileSync(sourcePath, path.join(workspacePath, targetName));
565
+ imported.push(targetName);
566
+ }
567
+ return dedupeStrings(imported);
568
+ }
569
+ // ---------------------------------------------------------------------------
570
+ // Ensure session workspace
571
+ // ---------------------------------------------------------------------------
572
+ export function ensureSessionWorkspace(opts) {
573
+ const workdir = path.resolve(opts.workdir);
574
+ const index = loadSessionIndex(workdir);
575
+ let record = index.sessions.find(entry => entry.agent === opts.agent && opts.sessionId && entry.sessionId === opts.sessionId)
576
+ || null;
577
+ if (!record) {
578
+ const sessionId = opts.sessionId?.trim() || nextPendingSessionId();
579
+ const threadId = normalizeThreadId(opts.threadId)
580
+ || (opts.sessionId ? legacyThreadId(opts.agent, sessionId) : nextThreadId());
581
+ record = {
582
+ sessionId, agent: opts.agent, workdir,
583
+ workspacePath: sessionWorkspacePath(workdir, opts.agent, sessionId),
584
+ threadId,
585
+ createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
586
+ title: summarizePromptTitle(opts.title) || null, model: null, thinkingEffort: null, profileId: null, stagedFiles: [], lastUserAttachments: [],
587
+ runState: 'completed', runDetail: null, runUpdatedAt: new Date().toISOString(),
588
+ runPid: null,
589
+ classification: null, userStatus: null, userNote: null,
590
+ lastQuestion: null, lastAnswer: null, lastMessageText: null,
591
+ lastThinking: null, lastPlan: null,
592
+ migratedFrom: null, migratedTo: null, linkedSessions: [],
593
+ handoverFrom: normalizeHandoverRef(opts.handoverFrom),
594
+ };
595
+ }
596
+ if (!record.threadId)
597
+ record.threadId = normalizeThreadId(opts.threadId) || legacyThreadId(record.agent, record.sessionId);
598
+ // Backfill handoverFrom on first staging only — never overwrite an existing one.
599
+ if (!record.handoverFrom)
600
+ record.handoverFrom = normalizeHandoverRef(opts.handoverFrom);
601
+ if (!record.title && opts.title)
602
+ record.title = summarizePromptTitle(opts.title);
603
+ record.workspacePath = path.resolve(record.workspacePath);
604
+ saveSessionRecord(workdir, record);
605
+ return { sessionId: record.sessionId, workspacePath: record.workspacePath, record };
606
+ }
607
+ // ---------------------------------------------------------------------------
608
+ // Record to SessionInfo
609
+ // ---------------------------------------------------------------------------
610
+ function managedRecordToSessionInfo(record) {
611
+ // Collapse pre-fix records that stored the canonical skill expansion as the
612
+ // title / lastQuestion / lastMessageText. New records get collapsed at write
613
+ // time in `prepareStreamOpts`; this read-time pass keeps existing sessions
614
+ // from showing the long instruction in the sidebar after the fix lands.
615
+ const title = collapseSkillPrompt(record.title) ?? record.title;
616
+ const lastQuestion = collapseSkillPrompt(record.lastQuestion) ?? record.lastQuestion;
617
+ const lastMessageText = collapseSkillPrompt(record.lastMessageText) ?? record.lastMessageText;
618
+ return {
619
+ sessionId: record.sessionId,
620
+ agent: record.agent,
621
+ workdir: record.workdir,
622
+ workspacePath: record.workspacePath,
623
+ threadId: record.threadId,
624
+ model: record.model,
625
+ thinkingEffort: record.thinkingEffort,
626
+ profileId: record.profileId ?? null,
627
+ createdAt: record.createdAt,
628
+ title,
629
+ running: record.runState === 'running',
630
+ runState: record.runState,
631
+ runDetail: record.runDetail,
632
+ runUpdatedAt: record.runUpdatedAt,
633
+ runPid: record.runPid,
634
+ classification: record.classification,
635
+ userStatus: record.userStatus,
636
+ userNote: record.userNote,
637
+ lastQuestion,
638
+ lastAnswer: record.lastAnswer,
639
+ lastMessageText,
640
+ migratedFrom: record.migratedFrom,
641
+ migratedTo: record.migratedTo,
642
+ linkedSessions: record.linkedSessions,
643
+ numTurns: record.numTurns ?? null,
644
+ handoverFrom: record.handoverFrom ?? null,
645
+ };
646
+ }
647
+ // ---------------------------------------------------------------------------
648
+ // Public session queries
649
+ // ---------------------------------------------------------------------------
650
+ // Exported for drivers
651
+ export function listPikiloopSessions(workdir, agent, limit) {
652
+ const records = sortByUpdatedAtDesc(loadSessionIndex(path.resolve(workdir)).sessions.filter(entry => entry.agent === agent));
653
+ return typeof limit === 'number' ? records.slice(0, limit) : records;
654
+ }
655
+ export function findPikiloopSession(workdir, agent, sessionId) {
656
+ return listPikiloopSessions(workdir, agent).find(entry => entry.sessionId === sessionId) || null;
657
+ }
658
+ /**
659
+ * Delete a pikiloop-managed session. Two scopes:
660
+ * - default: drop the index entry + recursively delete the per-session dir
661
+ * under `<workdir>/.pikiloop/sessions/<agent>/<sessionId>/` (and the legacy
662
+ * `workspaces/` path). Native agent transcript is left in place so the
663
+ * user can still resume the conversation outside pikiloop.
664
+ * - `purgeNative: true`: also call the driver's `deleteNativeSession` to
665
+ * remove the underlying jsonl/rollout file.
666
+ *
667
+ * Refuses to delete a session whose record is currently marked running and
668
+ * not stale (active process or recent mtime) — caller should stop the
669
+ * stream first.
670
+ *
671
+ * Sessions that exist only in the agent's native store (no pikiloop record)
672
+ * are still purgeable when `purgeNative` is set.
673
+ */
674
+ export async function deleteAgentSession(opts) {
675
+ const resolvedWorkdir = path.resolve(opts.workdir);
676
+ const { agent, sessionId } = opts;
677
+ const result = {
678
+ ok: false,
679
+ recordRemoved: false,
680
+ pikiloopPathsRemoved: [],
681
+ nativePathsRemoved: [],
682
+ refusedReason: null,
683
+ };
684
+ const index = loadSessionIndex(resolvedWorkdir);
685
+ const recordIdx = index.sessions.findIndex(s => s.agent === agent && s.sessionId === sessionId);
686
+ const record = recordIdx >= 0 ? index.sessions[recordIdx] : null;
687
+ if (record && record.runState === 'running' && !isRunningSessionStale(record, SESSION_RUNNING_THRESHOLD_MS)) {
688
+ result.refusedReason = 'session-running';
689
+ return result;
690
+ }
691
+ if (record) {
692
+ index.sessions.splice(recordIdx, 1);
693
+ writeSessionIndex(resolvedWorkdir, index.sessions);
694
+ result.recordRemoved = true;
695
+ }
696
+ for (const dir of [sessionDirPath(resolvedWorkdir, agent, sessionId), legacySessionWorkspacePath(resolvedWorkdir, agent, sessionId)]) {
697
+ if (!fs.existsSync(dir))
698
+ continue;
699
+ try {
700
+ fs.rmSync(dir, { recursive: true, force: true });
701
+ result.pikiloopPathsRemoved.push(dir);
702
+ }
703
+ catch (err) {
704
+ agentLog(`[sessions] failed to remove ${dir}: ${err.message}`);
705
+ }
706
+ }
707
+ if (opts.purgeNative) {
708
+ try {
709
+ const driver = getDriver(agent);
710
+ if (typeof driver.deleteNativeSession === 'function') {
711
+ const removed = await driver.deleteNativeSession(resolvedWorkdir, sessionId);
712
+ result.nativePathsRemoved = Array.isArray(removed) ? removed : [];
713
+ }
714
+ }
715
+ catch (err) {
716
+ agentLog(`[sessions] native session purge failed for ${agent}/${sessionId}: ${err.message}`);
717
+ }
718
+ }
719
+ result.ok = true;
720
+ return result;
721
+ }
722
+ /**
723
+ * Look up the persisted model, thinkingEffort, and bound profileId for an
724
+ * existing session. Returns null values when the session is not found or
725
+ * fields are not set.
726
+ */
727
+ export function getSessionStoredConfig(workdir, agent, sessionId) {
728
+ const record = findPikiloopSession(workdir, agent, sessionId);
729
+ return {
730
+ model: record?.model ?? null,
731
+ thinkingEffort: record?.thinkingEffort ?? null,
732
+ profileId: record?.profileId ?? null,
733
+ };
734
+ }
735
+ export function ensureManagedSession(opts) {
736
+ const session = ensureSessionWorkspace({
737
+ agent: opts.agent,
738
+ workdir: opts.workdir,
739
+ sessionId: opts.sessionId,
740
+ title: opts.title,
741
+ threadId: opts.threadId,
742
+ });
743
+ if (!session.record.title && opts.title)
744
+ session.record.title = summarizePromptTitle(opts.title);
745
+ if (!session.record.model && opts.model)
746
+ session.record.model = opts.model.trim() || null;
747
+ if (!session.record.thinkingEffort && opts.thinkingEffort) {
748
+ session.record.thinkingEffort = opts.thinkingEffort.trim().toLowerCase() || null;
749
+ }
750
+ if (!session.record.profileId && opts.profileId) {
751
+ session.record.profileId = opts.profileId.trim() || null;
752
+ }
753
+ saveSessionRecord(opts.workdir, session.record);
754
+ return managedRecordToSessionInfo(session.record);
755
+ }
756
+ export function findManagedThreadSession(workdir, threadId, agent) {
757
+ const record = sortByUpdatedAtDesc(loadSessionIndex(path.resolve(workdir)).sessions.filter(entry => entry.threadId === threadId && entry.agent === agent))[0] || null;
758
+ return record ? managedRecordToSessionInfo(record) : null;
759
+ }
760
+ export function stageSessionFiles(opts) {
761
+ const session = ensureSessionWorkspace({
762
+ agent: opts.agent,
763
+ workdir: opts.workdir,
764
+ sessionId: opts.sessionId,
765
+ title: opts.title,
766
+ threadId: opts.threadId,
767
+ handoverFrom: opts.handoverFrom,
768
+ });
769
+ const importedFiles = importFilesIntoWorkspace(session.workspacePath, opts.files);
770
+ if (importedFiles.length) {
771
+ session.record.stagedFiles = dedupeStrings([...session.record.stagedFiles, ...importedFiles]);
772
+ /* title will be set when the first text prompt arrives */
773
+ saveSessionRecord(opts.workdir, session.record);
774
+ }
775
+ return {
776
+ sessionId: session.sessionId,
777
+ workspacePath: session.workspacePath,
778
+ threadId: session.record.threadId,
779
+ importedFiles,
780
+ handoverFrom: session.record.handoverFrom ?? null,
781
+ };
782
+ }
783
+ // ---------------------------------------------------------------------------
784
+ // Merge managed and native sessions
785
+ // ---------------------------------------------------------------------------
786
+ function sessionTimelineAt(session) {
787
+ const ts = Date.parse(session.runUpdatedAt || session.createdAt || '');
788
+ return Number.isFinite(ts) ? ts : Number.NEGATIVE_INFINITY;
789
+ }
790
+ function preferNativeSessionTimeline(managed, native) {
791
+ const managedTs = sessionTimelineAt(managed);
792
+ const nativeTs = sessionTimelineAt(native);
793
+ return nativeTs > managedTs;
794
+ }
795
+ export function mergeManagedAndNativeSessions(managedSessions, nativeSessions) {
796
+ const managedById = new Map();
797
+ const merged = [];
798
+ const seen = new Set();
799
+ for (const session of managedSessions) {
800
+ if (!session.sessionId || isPendingSessionId(session.sessionId))
801
+ continue;
802
+ managedById.set(session.sessionId, session);
803
+ }
804
+ for (const native of nativeSessions) {
805
+ const sessionId = native.sessionId;
806
+ if (sessionId)
807
+ seen.add(sessionId);
808
+ const managed = sessionId ? managedById.get(sessionId) : null;
809
+ if (!managed) {
810
+ merged.push(native);
811
+ continue;
812
+ }
813
+ const useNativeTimeline = preferNativeSessionTimeline(managed, native);
814
+ merged.push({
815
+ ...managed,
816
+ ...native,
817
+ workdir: native.workdir || managed.workdir,
818
+ workspacePath: managed.workspacePath || native.workspacePath,
819
+ threadId: managed.threadId ?? native.threadId ?? null,
820
+ running: managed.running || native.running,
821
+ runState: managed.runState === 'running'
822
+ ? managed.runState
823
+ : (useNativeTimeline ? native.runState : managed.runState),
824
+ runDetail: useNativeTimeline ? (native.runDetail ?? managed.runDetail) : (managed.runDetail ?? native.runDetail),
825
+ runUpdatedAt: useNativeTimeline ? (native.runUpdatedAt ?? managed.runUpdatedAt) : (managed.runUpdatedAt ?? native.runUpdatedAt),
826
+ title: native.title || managed.title,
827
+ model: native.model || managed.model,
828
+ createdAt: native.createdAt || managed.createdAt,
829
+ classification: managed.classification ?? native.classification ?? null,
830
+ userStatus: managed.userStatus ?? native.userStatus ?? null,
831
+ userNote: managed.userNote ?? native.userNote ?? null,
832
+ lastQuestion: useNativeTimeline
833
+ ? (native.lastQuestion ?? managed.lastQuestion ?? null)
834
+ : (managed.lastQuestion ?? native.lastQuestion ?? null),
835
+ lastAnswer: useNativeTimeline
836
+ ? (native.lastAnswer ?? managed.lastAnswer ?? null)
837
+ : (managed.lastAnswer ?? native.lastAnswer ?? null),
838
+ lastMessageText: useNativeTimeline
839
+ ? (native.lastMessageText ?? managed.lastMessageText ?? native.lastAnswer ?? native.lastQuestion ?? managed.lastAnswer ?? managed.lastQuestion ?? null)
840
+ : (managed.lastMessageText ?? native.lastMessageText ?? managed.lastAnswer ?? managed.lastQuestion ?? native.lastAnswer ?? native.lastQuestion ?? null),
841
+ migratedFrom: managed.migratedFrom ?? native.migratedFrom ?? null,
842
+ migratedTo: managed.migratedTo ?? native.migratedTo ?? null,
843
+ linkedSessions: managed.linkedSessions?.length ? managed.linkedSessions : (native.linkedSessions ?? []),
844
+ numTurns: useNativeTimeline ? (native.numTurns ?? managed.numTurns ?? null) : (managed.numTurns ?? native.numTurns ?? null),
845
+ });
846
+ }
847
+ for (const managed of managedSessions) {
848
+ if (!managed.sessionId || isPendingSessionId(managed.sessionId) || seen.has(managed.sessionId))
849
+ continue;
850
+ merged.push(managed);
851
+ }
852
+ merged.sort((a, b) => Date.parse(b.createdAt || '') - Date.parse(a.createdAt || ''));
853
+ return merged;
854
+ }
855
+ // ---------------------------------------------------------------------------
856
+ // getSessions / getSessionTail / getSessionMessages
857
+ // ---------------------------------------------------------------------------
858
+ export function getSessions(opts) {
859
+ const workdir = path.resolve(opts.workdir);
860
+ agentLog(`[sessions] request agent=${opts.agent} workdir=${workdir} limit=${opts.limit ?? 'all'}`);
861
+ return getDriver(opts.agent).getSessions(workdir, opts.limit).then(result => {
862
+ agentLog(`[sessions] result agent=${opts.agent} ok=${result.ok} count=${result.sessions.length} error=${result.error || '(none)'}`);
863
+ return result;
864
+ });
865
+ }
866
+ export function getSessionTail(opts) {
867
+ return getDriver(opts.agent).getSessionTail(opts);
868
+ }
869
+ export function getSessionMessages(opts) {
870
+ return getDriver(opts.agent).getSessionMessages(opts);
871
+ }
872
+ // ---------------------------------------------------------------------------
873
+ // Turn windowing
874
+ // ---------------------------------------------------------------------------
875
+ function normalizeTurnWindowValue(value, fallback) {
876
+ if (!Number.isFinite(value) || value == null)
877
+ return fallback;
878
+ return Math.max(0, Math.floor(value));
879
+ }
880
+ /** Slice messages by turn window and count total turns. Exported for drivers. */
881
+ export function applyTurnWindow(allMsgs, opts = {}, richMsgs) {
882
+ let totalTurns = 0;
883
+ const turnStartIndexes = [];
884
+ for (let i = 0; i < allMsgs.length; i++) {
885
+ if (allMsgs[i].role === 'user') {
886
+ turnStartIndexes.push(i);
887
+ totalTurns++;
888
+ }
889
+ }
890
+ // If no rich messages provided, synthesize from plain messages so the
891
+ // API always returns a consistent richMessages array.
892
+ const rich = richMsgs ?? allMsgs.map(m => ({ role: m.role, text: m.text, blocks: [{ type: 'text', content: m.text }] }));
893
+ if (totalTurns <= 0) {
894
+ return {
895
+ ok: true,
896
+ messages: allMsgs,
897
+ richMessages: rich,
898
+ totalTurns,
899
+ window: {
900
+ offset: 0,
901
+ limit: 0,
902
+ returnedTurns: 0,
903
+ totalTurns: 0,
904
+ hasOlder: false,
905
+ hasNewer: false,
906
+ startTurn: 0,
907
+ endTurn: 0,
908
+ },
909
+ error: null,
910
+ };
911
+ }
912
+ const offset = normalizeTurnWindowValue(opts.turnOffset, 0);
913
+ const availableTurns = Math.max(0, totalTurns - offset);
914
+ const rawLimit = normalizeTurnWindowValue(opts.turnLimit ?? opts.lastNTurns, availableTurns);
915
+ const limit = rawLimit > 0 ? Math.min(rawLimit, availableTurns) : availableTurns;
916
+ if (limit <= 0 || availableTurns <= 0) {
917
+ const emptyTurn = Math.max(0, totalTurns - offset);
918
+ return {
919
+ ok: true,
920
+ messages: [],
921
+ richMessages: [],
922
+ totalTurns,
923
+ window: {
924
+ offset,
925
+ limit,
926
+ returnedTurns: 0,
927
+ totalTurns,
928
+ hasOlder: emptyTurn > 0,
929
+ hasNewer: offset > 0,
930
+ startTurn: emptyTurn,
931
+ endTurn: emptyTurn,
932
+ },
933
+ error: null,
934
+ };
935
+ }
936
+ const endTurn = Math.max(0, totalTurns - offset);
937
+ const startTurn = Math.max(0, endTurn - limit);
938
+ const startIdx = turnStartIndexes[startTurn] ?? 0;
939
+ const endIdx = endTurn < totalTurns ? (turnStartIndexes[endTurn] ?? allMsgs.length) : allMsgs.length;
940
+ return {
941
+ ok: true,
942
+ messages: allMsgs.slice(startIdx, endIdx),
943
+ richMessages: rich.slice(startIdx, endIdx),
944
+ totalTurns,
945
+ window: {
946
+ offset,
947
+ limit,
948
+ returnedTurns: endTurn - startTurn,
949
+ totalTurns,
950
+ hasOlder: startTurn > 0,
951
+ hasNewer: endTurn < totalTurns,
952
+ startTurn,
953
+ endTurn,
954
+ },
955
+ error: null,
956
+ };
957
+ }
958
+ /** Filter messages to last N turns and count total turns. Exported for drivers. */
959
+ export function applyTurnFilter(allMsgs, lastNTurns, richMsgs) {
960
+ return applyTurnWindow(allMsgs, { lastNTurns }, richMsgs);
961
+ }
962
+ // ---------------------------------------------------------------------------
963
+ // Session classification
964
+ // ---------------------------------------------------------------------------
965
+ const PROPOSAL_PATTERNS = /方案|option[s ]?[A-C]|plan|approach|建议|recommend|alternatively|trade-?off|pros?\s+(and|&)\s+cons?|选择|比较/i;
966
+ const IMPLEMENTATION_PATTERNS = /已完成|committed|done|implemented|fixed|created|wrote|修复|完成|写入|提交|applied|updated|modified|refactored/i;
967
+ const BLOCKED_PATTERNS = /error|failed|permission denied|cannot|无法|失败|报错|blocked|timed?\s*out/i;
968
+ export function classifySession(result) {
969
+ const now = new Date().toISOString();
970
+ const message = result.message || '';
971
+ const firstLine = message.split('\n').find(l => l.trim())?.trim() || '';
972
+ const summaryText = firstLine.length > 120 ? firstLine.slice(0, 117) + '...' : firstLine;
973
+ // 1. Structural signals from StreamResult
974
+ if (result.incomplete) {
975
+ return {
976
+ outcome: 'partial',
977
+ suggestedNextAction: result.stopReason === 'interrupted' ? 'Resume or restart the interrupted task' : 'Continue the incomplete task',
978
+ summary: summaryText || 'Task did not complete',
979
+ classifiedAt: now,
980
+ };
981
+ }
982
+ if (!result.ok) {
983
+ const errorDetail = result.error || result.stopReason || 'unknown error';
984
+ return {
985
+ outcome: 'blocked',
986
+ suggestedNextAction: `Resolve error: ${errorDetail.slice(0, 100)}`,
987
+ summary: summaryText || `Failed: ${errorDetail.slice(0, 100)}`,
988
+ classifiedAt: now,
989
+ };
990
+ }
991
+ // 2. Activity signals (tool use indicates implementation)
992
+ const activity = result.activity || '';
993
+ if (/\b(Edit|Write|Bash)\b/.test(activity)) {
994
+ return {
995
+ outcome: 'implementation',
996
+ suggestedNextAction: 'Verify the changes made',
997
+ summary: summaryText,
998
+ classifiedAt: now,
999
+ };
1000
+ }
1001
+ // 3. Content-based classification
1002
+ if (BLOCKED_PATTERNS.test(message.slice(0, 500))) {
1003
+ return {
1004
+ outcome: 'blocked',
1005
+ suggestedNextAction: 'Review the error and provide guidance',
1006
+ summary: summaryText,
1007
+ classifiedAt: now,
1008
+ };
1009
+ }
1010
+ if (PROPOSAL_PATTERNS.test(message.slice(0, 1000))) {
1011
+ return {
1012
+ outcome: 'proposal',
1013
+ suggestedNextAction: 'Review the proposal and decide on next steps',
1014
+ summary: summaryText,
1015
+ classifiedAt: now,
1016
+ };
1017
+ }
1018
+ if (IMPLEMENTATION_PATTERNS.test(message.slice(0, 500))) {
1019
+ return {
1020
+ outcome: 'implementation',
1021
+ suggestedNextAction: 'Verify the changes made',
1022
+ summary: summaryText,
1023
+ classifiedAt: now,
1024
+ };
1025
+ }
1026
+ // 4. Default: informational answer
1027
+ return {
1028
+ outcome: 'answer',
1029
+ suggestedNextAction: null,
1030
+ summary: summaryText,
1031
+ classifiedAt: now,
1032
+ };
1033
+ }
1034
+ /** Derive a default userStatus from classification outcome */
1035
+ export function deriveUserStatus(outcome) {
1036
+ switch (outcome) {
1037
+ case 'answer': return 'done';
1038
+ case 'partial': return 'active';
1039
+ default: return 'review';
1040
+ }
1041
+ }
1042
+ // ---------------------------------------------------------------------------
1043
+ // Session export/import
1044
+ // ---------------------------------------------------------------------------
1045
+ export async function exportSession(opts) {
1046
+ try {
1047
+ // Rich mode so we can include image blocks in the export. The session
1048
+ // pipeline always returns plain messages even when rich is set; rich is
1049
+ // additive.
1050
+ const result = await getSessionMessages({ ...opts, agent: opts.agent, rich: true });
1051
+ if (!result.ok)
1052
+ return { ok: false, content: '', filename: '', error: result.error };
1053
+ const messages = result.messages;
1054
+ const richMessages = result.richMessages;
1055
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
1056
+ let content;
1057
+ let ext;
1058
+ switch (opts.format) {
1059
+ case 'json': {
1060
+ // Materialize image bytes into inline data URLs so the JSON is a
1061
+ // self-contained artefact (no dangling filesystem references).
1062
+ const { materializeImage } = await import('./images.js');
1063
+ const enrichedRichMessages = richMessages?.map(message => ({
1064
+ ...message,
1065
+ blocks: message.blocks.map(block => {
1066
+ if (block.type !== 'image')
1067
+ return block;
1068
+ const resolved = materializeImage(block);
1069
+ if (!resolved)
1070
+ return block;
1071
+ return {
1072
+ ...block,
1073
+ content: `data:${resolved.mime};base64,${resolved.bytes.toString('base64')}`,
1074
+ };
1075
+ }),
1076
+ }));
1077
+ content = JSON.stringify({
1078
+ agent: opts.agent,
1079
+ sessionId: opts.sessionId,
1080
+ exportedAt: new Date().toISOString(),
1081
+ messages,
1082
+ richMessages: enrichedRichMessages,
1083
+ }, null, 2);
1084
+ ext = 'json';
1085
+ break;
1086
+ }
1087
+ case 'text':
1088
+ content = messages.map(m => `[${m.role}]\n${m.text}`).join('\n\n---\n\n');
1089
+ ext = 'txt';
1090
+ break;
1091
+ case 'markdown':
1092
+ default:
1093
+ content = await renderMarkdownExport(opts.agent, timestamp, messages, richMessages);
1094
+ ext = 'md';
1095
+ break;
1096
+ }
1097
+ const filename = `session-${opts.agent}-${opts.sessionId.slice(0, 8)}-${timestamp}.${ext}`;
1098
+ return { ok: true, content, filename, error: null };
1099
+ }
1100
+ catch (e) {
1101
+ return { ok: false, content: '', filename: '', error: e.message };
1102
+ }
1103
+ }
1104
+ /**
1105
+ * Render an export-friendly markdown view. Each turn renders the role header,
1106
+ * the text body, and (for image blocks) an inlined `![caption](data:…)` ref
1107
+ * so the markdown is self-contained and renders correctly in any viewer
1108
+ * (VSCode preview, GitHub, etc.) without external file lookups.
1109
+ */
1110
+ async function renderMarkdownExport(agent, timestamp, messages, richMessages) {
1111
+ const lines = [`# Session Export (${agent}, ${timestamp})`, ''];
1112
+ const { materializeImage } = await import('./images.js');
1113
+ // Walk by index so we can pair messages[i] with richMessages[i] when present.
1114
+ const indexed = richMessages?.length === messages.length ? richMessages : null;
1115
+ const sections = [];
1116
+ for (let i = 0; i < messages.length; i++) {
1117
+ const m = messages[i];
1118
+ const sectionHeader = `## ${m.role === 'user' ? 'User' : 'Assistant'}`;
1119
+ const sectionParts = [sectionHeader, '', m.text];
1120
+ const rich = indexed?.[i];
1121
+ if (rich) {
1122
+ for (const block of rich.blocks) {
1123
+ if (block.type !== 'image')
1124
+ continue;
1125
+ const resolved = materializeImage(block);
1126
+ if (!resolved)
1127
+ continue;
1128
+ const altText = (block.imageCaption || '').replace(/[\r\n]+/g, ' ').slice(0, 120);
1129
+ const dataUrl = `data:${resolved.mime};base64,${resolved.bytes.toString('base64')}`;
1130
+ sectionParts.push('', `![${altText}](${dataUrl})`);
1131
+ if (block.imageCaption)
1132
+ sectionParts.push('', `_${altText}_`);
1133
+ }
1134
+ }
1135
+ sections.push(sectionParts.join('\n'));
1136
+ }
1137
+ return lines.join('\n') + sections.join('\n\n---\n\n');
1138
+ }
1139
+ export function importSession(opts) {
1140
+ try {
1141
+ const format = opts.format || detectImportFormat(opts.content);
1142
+ let messages;
1143
+ switch (format) {
1144
+ case 'json': {
1145
+ const parsed = JSON.parse(opts.content);
1146
+ messages = Array.isArray(parsed.messages) ? parsed.messages : Array.isArray(parsed) ? parsed : [];
1147
+ break;
1148
+ }
1149
+ case 'markdown': {
1150
+ messages = parseMarkdownConversation(opts.content);
1151
+ break;
1152
+ }
1153
+ case 'text':
1154
+ default: {
1155
+ messages = parseTextConversation(opts.content);
1156
+ break;
1157
+ }
1158
+ }
1159
+ return { ok: true, messages, error: null };
1160
+ }
1161
+ catch (e) {
1162
+ return { ok: false, messages: [], error: e.message };
1163
+ }
1164
+ }
1165
+ function detectImportFormat(content) {
1166
+ const trimmed = content.trimStart();
1167
+ if (trimmed.startsWith('{') || trimmed.startsWith('['))
1168
+ return 'json';
1169
+ if (trimmed.startsWith('#'))
1170
+ return 'markdown';
1171
+ return 'text';
1172
+ }
1173
+ function parseMarkdownConversation(content) {
1174
+ const messages = [];
1175
+ const sections = content.split(/^## /m).slice(1);
1176
+ for (const section of sections) {
1177
+ const firstLine = section.split('\n')[0].trim().toLowerCase();
1178
+ const role = firstLine.includes('user') ? 'user' : 'assistant';
1179
+ // Strip inlined image data URLs (`![alt](data:image/...;base64,...)`) so
1180
+ // the imported text body stays readable. The base64 payload itself isn't
1181
+ // re-attached as a MessageBlock because the import API returns plain
1182
+ // TailMessages; downstream agents that re-process the export will see the
1183
+ // alt text "[image: alt]" placeholder where the markdown image stood.
1184
+ const stripped = section
1185
+ .split('\n')
1186
+ .slice(1)
1187
+ .join('\n')
1188
+ .replace(/^---\s*$/m, '')
1189
+ .replace(/!\[([^\]]*)\]\(data:image\/[a-zA-Z0-9.+-]+;base64,[^)]+\)/g, (_, alt) => alt ? `[image: ${alt}]` : '[image]')
1190
+ .trim();
1191
+ if (stripped)
1192
+ messages.push({ role, text: stripped });
1193
+ }
1194
+ return messages;
1195
+ }
1196
+ function parseTextConversation(content) {
1197
+ const messages = [];
1198
+ const blocks = content.split(/\n---\n/).map(b => b.trim()).filter(Boolean);
1199
+ for (const block of blocks) {
1200
+ const match = block.match(/^\[(user|assistant)\]\n([\s\S]+)$/i);
1201
+ if (match) {
1202
+ messages.push({ role: match[1].toLowerCase(), text: match[2].trim() });
1203
+ }
1204
+ }
1205
+ return messages;
1206
+ }