pikiloom 0.4.25 → 0.4.27

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 (156) hide show
  1. package/dashboard/dist/assets/{AgentTab-B8Ai-_zs.js → AgentTab-DIP-y4dl.js} +1 -1
  2. package/dashboard/dist/assets/ConnectionModal-BCfvhkiI.js +1 -0
  3. package/dashboard/dist/assets/{DirBrowser-CklI2Lh4.js → DirBrowser-Dot7ua9-.js} +1 -1
  4. package/dashboard/dist/assets/{ExtensionsTab-BYLt52eV.js → ExtensionsTab-GioMx4S6.js} +1 -1
  5. package/dashboard/dist/assets/{IMAccessTab-BlwB4Hje.js → IMAccessTab-C9_r8jJP.js} +1 -1
  6. package/dashboard/dist/assets/{Modal-DoamIusy.js → Modal-M1AP1sgR.js} +1 -1
  7. package/dashboard/dist/assets/{Modals-BGRFYHLW.js → Modals-CCm3UUfJ.js} +1 -1
  8. package/dashboard/dist/assets/{Select-DCGLDJPj.js → Select-YubDM16P.js} +1 -1
  9. package/dashboard/dist/assets/SessionPanel-Cnv9o96n.js +1 -0
  10. package/dashboard/dist/assets/SystemTab-VgNivIe3.js +1 -0
  11. package/dashboard/dist/assets/index-BY7l20bi.js +23 -0
  12. package/dashboard/dist/assets/index-BlqvzDUL.js +3 -0
  13. package/dashboard/dist/assets/index-CXIN3nTr.css +1 -0
  14. package/dashboard/dist/assets/{shared-DsGKwuTP.js → shared-C0SAInnT.js} +1 -1
  15. package/dashboard/dist/index.html +2 -2
  16. package/dist/agent/acp-client.js +0 -59
  17. package/dist/agent/artifacts.js +1 -38
  18. package/dist/agent/auto-update.js +2 -78
  19. package/dist/agent/await-resume.js +0 -20
  20. package/dist/agent/cli/auth.js +3 -51
  21. package/dist/agent/cli/catalog.js +0 -3
  22. package/dist/agent/cli/detector.js +1 -19
  23. package/dist/agent/cli/index.js +0 -3
  24. package/dist/agent/cli/registry.js +0 -25
  25. package/dist/agent/driver.js +0 -14
  26. package/dist/agent/drivers/claude-tui.js +2 -769
  27. package/dist/agent/drivers/claude.js +12 -631
  28. package/dist/agent/drivers/codex.js +11 -162
  29. package/dist/agent/drivers/gemini.js +6 -114
  30. package/dist/agent/drivers/hermes.js +0 -128
  31. package/dist/agent/goal.js +0 -45
  32. package/dist/agent/handover.js +1 -46
  33. package/dist/agent/images.js +7 -133
  34. package/dist/agent/index.js +1 -29
  35. package/dist/agent/mcp/bridge.js +1 -197
  36. package/dist/agent/mcp/extensions.js +5 -84
  37. package/dist/agent/mcp/oauth.js +2 -69
  38. package/dist/agent/mcp/registry.js +2 -33
  39. package/dist/agent/mcp/session-server.js +2 -45
  40. package/dist/agent/mcp/tools/ask-user.js +0 -6
  41. package/dist/agent/mcp/tools/await-resume.js +0 -15
  42. package/dist/agent/mcp/tools/goal.js +0 -25
  43. package/dist/agent/mcp/tools/types.js +0 -5
  44. package/dist/agent/mcp/tools/workspace.js +0 -23
  45. package/dist/agent/npm.js +0 -14
  46. package/dist/agent/session.js +90 -192
  47. package/dist/agent/skill-installer.js +3 -70
  48. package/dist/agent/skills.js +0 -43
  49. package/dist/agent/stream.js +5 -148
  50. package/dist/agent/types.js +0 -12
  51. package/dist/agent/utils.js +2 -120
  52. package/dist/bot/bot.js +3 -468
  53. package/dist/bot/command-ui.js +0 -45
  54. package/dist/bot/commands.js +0 -51
  55. package/dist/bot/headless-bot.js +0 -21
  56. package/dist/bot/host.js +1 -5
  57. package/dist/bot/human-loop.js +0 -8
  58. package/dist/bot/menu.js +0 -3
  59. package/dist/bot/orchestration.js +0 -3
  60. package/dist/bot/render-shared.js +0 -63
  61. package/dist/bot/session-hub.js +14 -92
  62. package/dist/bot/session-status.js +1 -5
  63. package/dist/bot/streaming.js +0 -53
  64. package/dist/browser-profile.js +0 -51
  65. package/dist/browser-supervisor.js +0 -75
  66. package/dist/catalog/cli-tools.js +0 -53
  67. package/dist/catalog/index.js +0 -18
  68. package/dist/catalog/local-models.js +0 -18
  69. package/dist/catalog/mcp-servers.js +0 -35
  70. package/dist/catalog/skill-repos.js +0 -32
  71. package/dist/channels/base.js +0 -19
  72. package/dist/channels/dingtalk/bot.js +0 -3
  73. package/dist/channels/dingtalk/channel.js +0 -33
  74. package/dist/channels/discord/bot.js +0 -7
  75. package/dist/channels/discord/channel.js +0 -22
  76. package/dist/channels/feishu/bot.js +0 -71
  77. package/dist/channels/feishu/channel.js +0 -78
  78. package/dist/channels/feishu/markdown.js +0 -5
  79. package/dist/channels/feishu/render.js +0 -45
  80. package/dist/channels/health.js +0 -46
  81. package/dist/channels/slack/bot.js +0 -7
  82. package/dist/channels/slack/channel.js +1 -27
  83. package/dist/channels/states.js +0 -3
  84. package/dist/channels/telegram/bot.js +0 -60
  85. package/dist/channels/telegram/channel.js +2 -99
  86. package/dist/channels/telegram/directory.js +0 -3
  87. package/dist/channels/telegram/live-preview.js +0 -8
  88. package/dist/channels/telegram/render.js +0 -30
  89. package/dist/channels/wecom/bot.js +0 -3
  90. package/dist/channels/wecom/channel.js +0 -58
  91. package/dist/channels/weixin/api.js +0 -9
  92. package/dist/channels/weixin/bot.js +0 -88
  93. package/dist/channels/weixin/channel.js +0 -6
  94. package/dist/cli/autostart.js +1 -44
  95. package/dist/cli/channel-supervisor.js +1 -59
  96. package/dist/cli/channels.js +0 -6
  97. package/dist/cli/main.js +1 -145
  98. package/dist/cli/onboarding.js +0 -6
  99. package/dist/cli/run.js +0 -11
  100. package/dist/cli/setup-wizard.js +0 -3
  101. package/dist/core/config/runtime-config.js +0 -38
  102. package/dist/core/config/user-config.js +0 -63
  103. package/dist/core/config/validation.js +0 -9
  104. package/dist/core/constants.js +0 -268
  105. package/dist/core/git.js +0 -31
  106. package/dist/core/legacy-compat.js +1 -28
  107. package/dist/core/logging.js +0 -3
  108. package/dist/core/platform.js +0 -28
  109. package/dist/core/process-control.js +0 -8
  110. package/dist/core/secrets/index.js +0 -17
  111. package/dist/core/secrets/inline-seal.js +0 -7
  112. package/dist/core/secrets/ref.js +0 -19
  113. package/dist/core/secrets/resolver.js +0 -8
  114. package/dist/core/secrets/store.js +1 -12
  115. package/dist/core/utils.js +1 -37
  116. package/dist/core/version.js +0 -3
  117. package/dist/dashboard/platform.js +1 -35
  118. package/dist/dashboard/routes/agents.js +2 -71
  119. package/dist/dashboard/routes/cli.js +0 -27
  120. package/dist/dashboard/routes/config.js +0 -54
  121. package/dist/dashboard/routes/extensions.js +0 -132
  122. package/dist/dashboard/routes/local-models.js +0 -68
  123. package/dist/dashboard/routes/models.js +0 -46
  124. package/dist/dashboard/routes/sessions.js +0 -100
  125. package/dist/dashboard/runtime.js +1 -58
  126. package/dist/dashboard/server.js +0 -49
  127. package/dist/dashboard/session-control.js +2 -69
  128. package/dist/model/catalog.js +1 -17
  129. package/dist/model/index.js +0 -15
  130. package/dist/model/injector.js +1 -181
  131. package/dist/model/provider-models.js +1 -48
  132. package/dist/model/responses-bridge.js +3 -45
  133. package/dist/model/store.js +0 -17
  134. package/dist/model/types.js +0 -12
  135. package/dist/model/validation.js +1 -18
  136. package/dist/pikichannel/adapter-pikiloom.js +0 -31
  137. package/dist/pikichannel/code.js +0 -12
  138. package/dist/pikichannel/codec.js +0 -14
  139. package/dist/pikichannel/host.js +4 -33
  140. package/dist/pikichannel/protocol.js +0 -52
  141. package/dist/pikichannel/rendezvous-broker.js +5 -26
  142. package/dist/pikichannel/rendezvous-host.js +3 -16
  143. package/dist/pikichannel/server.js +6 -55
  144. package/dist/pikichannel/transport.js +0 -25
  145. package/dist/pikichannel/transports/webrtc-host.js +0 -18
  146. package/dist/pikichannel/transports/webrtc-shared.js +4 -34
  147. package/dist/pikichannel/transports/websocket-host.js +1 -14
  148. package/dist/pikichannel/turn.js +2 -69
  149. package/dist/pikichannel/web/sdk.js +14 -71
  150. package/package.json +1 -1
  151. package/dashboard/dist/assets/ConnectionModal-YR4dc2lx.js +0 -1
  152. package/dashboard/dist/assets/SessionPanel-DZziN6ft.js +0 -1
  153. package/dashboard/dist/assets/SystemTab-DaJk9s8R.js +0 -1
  154. package/dashboard/dist/assets/index-ClrAJ5vv.js +0 -23
  155. package/dashboard/dist/assets/index-CoBaePpK.js +0 -3
  156. package/dashboard/dist/assets/index-CyCspgtc.css +0 -1
@@ -1,6 +1,3 @@
1
- /**
2
- * Session workspace management, metadata persistence, classification, and export/import.
3
- */
4
1
  import crypto from 'node:crypto';
5
2
  import fs from 'node:fs';
6
3
  import path from 'node:path';
@@ -8,9 +5,6 @@ import { dedupeStrings, shortValue, firstNonEmptyLine, normalizeErrorMessage, no
8
5
  import { getDriver } from './driver.js';
9
6
  import { collapseSkillPrompt } from './skills.js';
10
7
  import { SESSION_RUNNING_THRESHOLD_MS } from '../core/constants.js';
11
- // ---------------------------------------------------------------------------
12
- // Private helpers
13
- // ---------------------------------------------------------------------------
14
8
  function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); }
15
9
  function readJsonFile(filePath, fallback) {
16
10
  try {
@@ -38,19 +32,12 @@ function trimSessionText(value, max = 24_000) {
38
32
  return text;
39
33
  return `${text.slice(0, Math.max(0, max - 3)).trimEnd()}...`;
40
34
  }
41
- // ---------------------------------------------------------------------------
42
- // Constants
43
- // ---------------------------------------------------------------------------
44
35
  const PIKILOOM_DIR = '.pikiloom';
45
36
  const PIKILOOM_SESSIONS_DIR = path.join(PIKILOOM_DIR, 'sessions');
46
37
  const PIKILOOM_SESSION_INDEX = path.join(PIKILOOM_SESSIONS_DIR, 'index.json');
47
38
  const PIKILOOM_LEGACY_WORKSPACES_DIR = path.join(PIKILOOM_DIR, 'workspaces');
48
39
  const SESSION_WORKSPACE_DIR = 'workspace';
49
40
  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
41
  function sessionIndexPath(workdir) { return path.join(workdir, PIKILOOM_SESSION_INDEX); }
55
42
  function sessionDirPath(workdir, agent, sessionId) { return path.join(workdir, PIKILOOM_SESSIONS_DIR, agent, sessionId); }
56
43
  function legacySessionWorkspacePath(workdir, agent, sessionId) { return path.join(workdir, PIKILOOM_LEGACY_WORKSPACES_DIR, agent, sessionId); }
@@ -61,19 +48,12 @@ function sessionRootFromWorkspacePath(workspacePath) {
61
48
  }
62
49
  function sessionMetaPath(workspacePath) { return path.join(sessionRootFromWorkspacePath(workspacePath), SESSION_META_FILE); }
63
50
  function legacySessionMetaPath(workspacePath) { return path.join(workspacePath, PIKILOOM_DIR, SESSION_META_FILE); }
64
- // ---------------------------------------------------------------------------
65
- // ID helpers
66
- // ---------------------------------------------------------------------------
67
- /** Generate a temporary session ID for new sessions before the agent assigns one. */
68
51
  function nextPendingSessionId() { return `pending_${crypto.randomBytes(6).toString('hex')}`; }
69
52
  function nextThreadId() { return `thread_${crypto.randomBytes(6).toString('hex')}`; }
70
53
  function legacyThreadId(agent, sessionId) { return `legacy:${agent}:${sessionId}`; }
71
54
  function normalizeThreadId(value) {
72
55
  return typeof value === 'string' && value.trim() ? value.trim() : null;
73
56
  }
74
- // ---------------------------------------------------------------------------
75
- // Run state helpers
76
- // ---------------------------------------------------------------------------
77
57
  function normalizeSessionRunState(rawState) {
78
58
  const state = typeof rawState === 'string' ? rawState.trim().toLowerCase() : '';
79
59
  if (state === 'completed' || state === 'incomplete' || state === 'running')
@@ -111,11 +91,6 @@ function incompleteRunDetail(result) {
111
91
  const message = firstNonEmptyLine(result.message || '');
112
92
  return message ? shortValue(message, 180) : 'Last run did not complete.';
113
93
  }
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
94
  export function isProcessAlive(pid) {
120
95
  if (!pid || !Number.isFinite(pid) || pid <= 0)
121
96
  return null;
@@ -131,13 +106,6 @@ export function isProcessAlive(pid) {
131
106
  return null;
132
107
  }
133
108
  }
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
109
  export function isRunningSessionStale(record, ageThresholdMs) {
142
110
  if (record.runState !== 'running')
143
111
  return false;
@@ -149,11 +117,6 @@ export function isRunningSessionStale(record, ageThresholdMs) {
149
117
  const age = record.runUpdatedAt ? Date.now() - Date.parse(record.runUpdatedAt) : Infinity;
150
118
  return age > ageThresholdMs;
151
119
  }
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
120
  export function reconcileOrphanedRunningSessions(workdir, ageThresholdMs = 30 * 60_000) {
158
121
  const resolvedWorkdir = path.resolve(workdir);
159
122
  const index = loadSessionIndex(resolvedWorkdir);
@@ -183,10 +146,8 @@ export function applySessionRunResult(record, result) {
183
146
  else {
184
147
  setSessionRunState(record, 'incomplete', incompleteRunDetail(result));
185
148
  }
186
- // Auto-classify the stream result
187
149
  const classification = classifySession({ ...result, activity: result.activity ?? null });
188
150
  record.classification = classification;
189
- // Only set userStatus if not manually overridden by the user
190
151
  if (!record.userStatus) {
191
152
  record.userStatus = deriveUserStatus(classification.outcome);
192
153
  }
@@ -202,7 +163,6 @@ function normalizeHandoverRef(value) {
202
163
  return { agent: agent, sessionId };
203
164
  }
204
165
  function normalizeSessionRecord(raw, workdir) {
205
- // Support both new format (sessionId) and legacy format (localSessionId + engineSessionId)
206
166
  const sessionId = typeof raw?.sessionId === 'string' ? raw.sessionId.trim()
207
167
  : typeof raw?.engineSessionId === 'string' && raw.engineSessionId.trim() ? raw.engineSessionId.trim()
208
168
  : typeof raw?.localSessionId === 'string' ? raw.localSessionId.trim()
@@ -246,20 +206,7 @@ function normalizeSessionRecord(raw, workdir) {
246
206
  handoverFrom: normalizeHandoverRef(raw?.handoverFrom),
247
207
  };
248
208
  }
249
- // ---------------------------------------------------------------------------
250
- // Index persistence
251
- // ---------------------------------------------------------------------------
252
- /**
253
- * Parsed-index cache keyed by index-file identity (mtime + size). loadSessionIndex
254
- * sits on the per-turn read path (getSessionStoredConfig), every dashboard session
255
- * read, and is hit several times within a single save flow — each call otherwise
256
- * does readFileSync + JSON.parse + a per-record normalize pass. A cache hit costs
257
- * one statSync. writeSessionIndex invalidates the entry, so a write is always
258
- * re-read fresh; every writer mutates records then writes, so the shared cache is
259
- * never left serving a half-mutated record.
260
- */
261
209
  const sessionIndexCache = new Map();
262
- /** Sort session records newest-first, parsing each `updatedAt` only once. */
263
210
  function sortByUpdatedAtDesc(records) {
264
211
  const at = new Map(records.map(r => [r, Date.parse(r.updatedAt) || 0]));
265
212
  return records.sort((a, b) => at.get(b) - at.get(a));
@@ -278,22 +225,102 @@ function loadSessionIndex(workdir) {
278
225
  }
279
226
  const parsed = readJsonFile(filePath, { version: 1, sessions: [] });
280
227
  const sessions = Array.isArray(parsed?.sessions) ? parsed.sessions : [];
228
+ const promotions = {};
229
+ if (parsed?.promotions && typeof parsed.promotions === 'object') {
230
+ for (const [k, v] of Object.entries(parsed.promotions)) {
231
+ if (typeof k === 'string' && k && typeof v === 'string' && v && k !== v)
232
+ promotions[k] = v;
233
+ }
234
+ }
281
235
  const data = {
282
236
  version: 1,
283
237
  sessions: sessions
284
238
  .map((entry) => normalizeSessionRecord(entry, workdir))
285
239
  .filter((entry) => !!entry)
286
240
  .filter((entry) => !isPendingSessionId(entry.sessionId) || fs.existsSync(sessionRootFromWorkspacePath(entry.workspacePath))),
241
+ promotions,
287
242
  };
288
243
  if (stat)
289
244
  sessionIndexCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, data });
290
245
  return data;
291
246
  }
292
- function writeSessionIndex(workdir, sessions) {
247
+ function writeSessionIndex(workdir, sessions, promotionsOverride) {
293
248
  const filePath = sessionIndexPath(workdir);
294
- writeJsonFile(filePath, { version: 1, sessions });
249
+ const promotions = promotionsOverride ?? loadSessionIndex(workdir).promotions;
250
+ const payload = { version: 1, sessions };
251
+ if (promotions && Object.keys(promotions).length)
252
+ payload.promotions = promotions;
253
+ writeJsonFile(filePath, payload);
295
254
  sessionIndexCache.delete(filePath);
296
255
  }
256
+ function prunePromotionsForDeletedSession(promotions, agent, deletedId) {
257
+ if (!promotions)
258
+ return null;
259
+ const out = {};
260
+ let changed = false;
261
+ for (const [key, val] of Object.entries(promotions)) {
262
+ const ci = key.indexOf(':');
263
+ const kAgent = ci > 0 ? key.slice(0, ci) : '';
264
+ const kOld = ci > 0 ? key.slice(ci + 1) : '';
265
+ if (kAgent === agent && (val === deletedId || kOld === deletedId)) {
266
+ changed = true;
267
+ continue;
268
+ }
269
+ out[key] = val;
270
+ }
271
+ return changed ? out : null;
272
+ }
273
+ export function resolveCanonicalSessionId(workdir, agent, sessionId) {
274
+ if (!sessionId)
275
+ return sessionId;
276
+ const resolvedWorkdir = path.resolve(workdir);
277
+ const promotions = loadSessionIndex(resolvedWorkdir).promotions;
278
+ let id = sessionId;
279
+ const seen = new Set();
280
+ while (!seen.has(id)) {
281
+ seen.add(id);
282
+ let next = promotions?.[`${agent}:${id}`];
283
+ if (!next && isPendingSessionId(id)) {
284
+ const aliased = readSessionDirAliasTarget(resolvedWorkdir, agent, id);
285
+ if (aliased) {
286
+ recordSessionPromotion(resolvedWorkdir, agent, id, aliased);
287
+ next = aliased;
288
+ }
289
+ }
290
+ if (!next || next === id)
291
+ break;
292
+ id = next;
293
+ }
294
+ return id;
295
+ }
296
+ function readSessionDirAliasTarget(workdir, agent, sessionId) {
297
+ try {
298
+ const dir = sessionDirPath(workdir, agent, sessionId);
299
+ if (!fs.lstatSync(dir).isSymbolicLink())
300
+ return null;
301
+ const native = path.basename(fs.readlinkSync(dir).replace(/[/\\]+$/, ''));
302
+ return native && native !== sessionId && !isPendingSessionId(native) ? native : null;
303
+ }
304
+ catch {
305
+ return null;
306
+ }
307
+ }
308
+ export function getSessionPromotions(workdir) {
309
+ return { ...loadSessionIndex(path.resolve(workdir)).promotions };
310
+ }
311
+ function recordSessionPromotion(workdir, agent, oldId, newId) {
312
+ if (!oldId || !newId || oldId === newId)
313
+ return;
314
+ const resolvedWorkdir = path.resolve(workdir);
315
+ const index = loadSessionIndex(resolvedWorkdir);
316
+ const promotions = { ...index.promotions };
317
+ for (const k of Object.keys(promotions)) {
318
+ if (promotions[k] === oldId && k.startsWith(`${agent}:`))
319
+ promotions[k] = newId;
320
+ }
321
+ promotions[`${agent}:${oldId}`] = newId;
322
+ writeSessionIndex(resolvedWorkdir, index.sessions, promotions);
323
+ }
297
324
  function writeSessionMeta(record) {
298
325
  writeJsonFile(sessionMetaPath(record.workspacePath), {
299
326
  sessionId: record.sessionId, agent: record.agent, workdir: record.workdir,
@@ -317,9 +344,6 @@ function writeSessionMeta(record) {
317
344
  handoverFrom: record.handoverFrom ?? null,
318
345
  });
319
346
  }
320
- // ---------------------------------------------------------------------------
321
- // File / directory helpers
322
- // ---------------------------------------------------------------------------
323
347
  function copyPath(sourcePath, targetPath) {
324
348
  const stat = fs.statSync(sourcePath);
325
349
  if (stat.isDirectory()) {
@@ -339,9 +363,6 @@ function createSessionDirAlias(aliasPath, targetPath) {
339
363
  }
340
364
  catch { }
341
365
  }
342
- // ---------------------------------------------------------------------------
343
- // Migration
344
- // ---------------------------------------------------------------------------
345
366
  function migrateSessionLayout(workdir, record) {
346
367
  const targetSessionDir = sessionDirPath(workdir, record.agent, record.sessionId);
347
368
  const targetWorkspacePath = sessionWorkspacePath(workdir, record.agent, record.sessionId);
@@ -365,9 +386,6 @@ function migrateSessionLayout(workdir, record) {
365
386
  record.workspacePath = path.resolve(targetWorkspacePath);
366
387
  return record;
367
388
  }
368
- // ---------------------------------------------------------------------------
369
- // Save / update
370
- // ---------------------------------------------------------------------------
371
389
  export function saveSessionRecord(workdir, record) {
372
390
  record = migrateSessionLayout(workdir, record);
373
391
  ensureDir(sessionDirPath(workdir, record.agent, record.sessionId));
@@ -385,10 +403,6 @@ export function saveSessionRecord(workdir, record) {
385
403
  writeSessionMeta(record);
386
404
  return record;
387
405
  }
388
- /**
389
- * Update mutable session metadata (classification, userStatus, userNote, links, migration)
390
- * for an existing pikiloom-managed session. Returns true if the record was found and updated.
391
- */
392
406
  export function updateSessionMeta(workdir, agent, sessionId, patch) {
393
407
  const resolvedWorkdir = path.resolve(workdir);
394
408
  const index = loadSessionIndex(resolvedWorkdir);
@@ -417,10 +431,6 @@ export function updateSessionMeta(workdir, agent, sessionId, patch) {
417
431
  writeSessionMeta(record);
418
432
  return true;
419
433
  }
420
- /**
421
- * Promote a pending session to a real session ID. Renames the workspace directory
422
- * and updates the index. Called after the first stream returns the agent's native ID.
423
- */
424
434
  export function promoteSessionId(workdir, agent, pendingId, nativeId) {
425
435
  if (!isPendingSessionId(pendingId) || !nativeId.trim())
426
436
  return;
@@ -431,12 +441,11 @@ export function promoteSessionId(workdir, agent, pendingId, nativeId) {
431
441
  return;
432
442
  const oldDir = sessionDirPath(resolvedWorkdir, agent, pendingId);
433
443
  const newDir = sessionDirPath(resolvedWorkdir, agent, nativeId);
434
- // Move workspace directory if it exists
435
444
  if (fs.existsSync(oldDir) && !fs.existsSync(newDir)) {
436
445
  try {
437
446
  fs.renameSync(oldDir, newDir);
438
447
  }
439
- catch { /* cross-device: copy+delete */
448
+ catch {
440
449
  try {
441
450
  fs.cpSync(oldDir, newDir, { recursive: true });
442
451
  fs.rmSync(oldDir, { recursive: true, force: true });
@@ -450,19 +459,6 @@ export function promoteSessionId(workdir, agent, pendingId, nativeId) {
450
459
  record.workspacePath = sessionWorkspacePath(resolvedWorkdir, agent, nativeId);
451
460
  saveSessionRecord(resolvedWorkdir, record);
452
461
  }
453
- // ---------------------------------------------------------------------------
454
- // Fork lineage
455
- // ---------------------------------------------------------------------------
456
- /**
457
- * Record a fork relationship between two pikiloom-managed sessions.
458
- *
459
- * Sets `migratedFrom` (with kind='fork' + forkedAtTurn) on the child and
460
- * appends the reverse link on the parent's `linkedSessions`. Both sides also
461
- * get `migratedTo` set on the parent so the child is a discoverable twin.
462
- *
463
- * No-op if either record is missing — call sites are expected to ensure both
464
- * managed records exist (the child is created via the fork stream completion).
465
- */
466
462
  export function recordFork(workdir, opts) {
467
463
  const resolvedWorkdir = path.resolve(workdir);
468
464
  const index = loadSessionIndex(resolvedWorkdir);
@@ -488,9 +484,6 @@ export function recordFork(workdir, opts) {
488
484
  writeSessionMeta(parent);
489
485
  writeSessionMeta(child);
490
486
  }
491
- // ---------------------------------------------------------------------------
492
- // Identity sync
493
- // ---------------------------------------------------------------------------
494
487
  export function syncManagedSessionIdentity(session, workdir, nativeId) {
495
488
  const resolvedId = nativeId.trim();
496
489
  if (!resolvedId || session.sessionId === resolvedId)
@@ -498,16 +491,9 @@ export function syncManagedSessionIdentity(session, workdir, nativeId) {
498
491
  const resolvedWorkdir = path.resolve(workdir);
499
492
  const previousId = session.sessionId;
500
493
  if (isPendingSessionId(previousId)) {
501
- // Pending → native: move the workspace dir into the native slot and
502
- // remove the pending index entry (handled by promoteSessionId).
503
494
  promoteSessionId(resolvedWorkdir, session.record.agent, previousId, resolvedId);
504
495
  }
505
496
  else {
506
- // Native → native rotation (Claude `--resume` can rewrite the session id
507
- // mid-stream). Drop the old index entry so the dashboard does not show a
508
- // stale duplicate; both jsonl files stay on disk and the workspace stays
509
- // under its original native id (the next saveSessionRecord will lay down
510
- // a fresh dir under the new id).
511
497
  const index = loadSessionIndex(resolvedWorkdir);
512
498
  const filtered = index.sessions.filter(e => !(e.agent === session.record.agent && e.sessionId === previousId));
513
499
  if (filtered.length !== index.sessions.length)
@@ -517,11 +503,9 @@ export function syncManagedSessionIdentity(session, workdir, nativeId) {
517
503
  session.workspacePath = sessionWorkspacePath(resolvedWorkdir, session.record.agent, resolvedId);
518
504
  session.record.sessionId = resolvedId;
519
505
  session.record.workspacePath = session.workspacePath;
506
+ recordSessionPromotion(resolvedWorkdir, session.record.agent, previousId, resolvedId);
520
507
  return true;
521
508
  }
522
- // ---------------------------------------------------------------------------
523
- // Title / filename helpers
524
- // ---------------------------------------------------------------------------
525
509
  export function summarizePromptTitle(prompt) {
526
510
  const raw = String(prompt || '').replace(/\r\n?/g, '\n');
527
511
  const text = firstNonEmptyLine(raw).replace(/\s+/g, ' ').trim()
@@ -546,17 +530,15 @@ function uniqueWorkspaceFilename(workspacePath, desiredName) {
546
530
  }
547
531
  return candidate;
548
532
  }
549
- // ---------------------------------------------------------------------------
550
- // Workspace file import
551
- // ---------------------------------------------------------------------------
552
533
  export function importFilesIntoWorkspace(workspacePath, files) {
553
534
  const imported = [];
554
535
  const realWorkspace = fs.realpathSync(workspacePath);
555
536
  for (const filePath of files) {
556
- const sourcePath = path.resolve(filePath);
557
- if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isFile())
537
+ const resolved = path.resolve(filePath);
538
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile())
558
539
  continue;
559
- let relPath = path.relative(realWorkspace, sourcePath);
540
+ const sourcePath = fs.realpathSync(resolved);
541
+ const relPath = path.relative(realWorkspace, sourcePath);
560
542
  if (relPath && !relPath.startsWith('..') && !path.isAbsolute(relPath)) {
561
543
  imported.push(relPath.split(path.sep).join(path.posix.sep));
562
544
  continue;
@@ -567,9 +549,6 @@ export function importFilesIntoWorkspace(workspacePath, files) {
567
549
  }
568
550
  return dedupeStrings(imported);
569
551
  }
570
- // ---------------------------------------------------------------------------
571
- // Ensure session workspace
572
- // ---------------------------------------------------------------------------
573
552
  export function ensureSessionWorkspace(opts) {
574
553
  const workdir = path.resolve(opts.workdir);
575
554
  const index = loadSessionIndex(workdir);
@@ -596,7 +575,6 @@ export function ensureSessionWorkspace(opts) {
596
575
  }
597
576
  if (!record.threadId)
598
577
  record.threadId = normalizeThreadId(opts.threadId) || legacyThreadId(record.agent, record.sessionId);
599
- // Backfill handoverFrom on first staging only — never overwrite an existing one.
600
578
  if (!record.handoverFrom)
601
579
  record.handoverFrom = normalizeHandoverRef(opts.handoverFrom);
602
580
  if (!record.title && opts.title)
@@ -605,14 +583,7 @@ export function ensureSessionWorkspace(opts) {
605
583
  saveSessionRecord(workdir, record);
606
584
  return { sessionId: record.sessionId, workspacePath: record.workspacePath, record };
607
585
  }
608
- // ---------------------------------------------------------------------------
609
- // Record to SessionInfo
610
- // ---------------------------------------------------------------------------
611
586
  export function managedRecordToSessionInfo(record) {
612
- // Collapse pre-fix records that stored the canonical skill expansion as the
613
- // title / lastQuestion / lastMessageText. New records get collapsed at write
614
- // time in `prepareStreamOpts`; this read-time pass keeps existing sessions
615
- // from showing the long instruction in the sidebar after the fix lands.
616
587
  const title = collapseSkillPrompt(record.title) ?? record.title;
617
588
  const lastQuestion = collapseSkillPrompt(record.lastQuestion) ?? record.lastQuestion;
618
589
  const lastMessageText = collapseSkillPrompt(record.lastMessageText) ?? record.lastMessageText;
@@ -646,10 +617,6 @@ export function managedRecordToSessionInfo(record) {
646
617
  handoverFrom: record.handoverFrom ?? null,
647
618
  };
648
619
  }
649
- // ---------------------------------------------------------------------------
650
- // Public session queries
651
- // ---------------------------------------------------------------------------
652
- // Exported for drivers
653
620
  export function listPikiloomSessions(workdir, agent, limit) {
654
621
  const records = sortByUpdatedAtDesc(loadSessionIndex(path.resolve(workdir)).sessions.filter(entry => entry.agent === agent));
655
622
  return typeof limit === 'number' ? records.slice(0, limit) : records;
@@ -657,22 +624,6 @@ export function listPikiloomSessions(workdir, agent, limit) {
657
624
  export function findPikiloomSession(workdir, agent, sessionId) {
658
625
  return listPikiloomSessions(workdir, agent).find(entry => entry.sessionId === sessionId) || null;
659
626
  }
660
- /**
661
- * Delete a pikiloom-managed session. Two scopes:
662
- * - default: drop the index entry + recursively delete the per-session dir
663
- * under `<workdir>/.pikiloom/sessions/<agent>/<sessionId>/` (and the legacy
664
- * `workspaces/` path). Native agent transcript is left in place so the
665
- * user can still resume the conversation outside pikiloom.
666
- * - `purgeNative: true`: also call the driver's `deleteNativeSession` to
667
- * remove the underlying jsonl/rollout file.
668
- *
669
- * Refuses to delete a session whose record is currently marked running and
670
- * not stale (active process or recent mtime) — caller should stop the
671
- * stream first.
672
- *
673
- * Sessions that exist only in the agent's native store (no pikiloom record)
674
- * are still purgeable when `purgeNative` is set.
675
- */
676
627
  export async function deleteAgentSession(opts) {
677
628
  const resolvedWorkdir = path.resolve(opts.workdir);
678
629
  const { agent, sessionId } = opts;
@@ -692,7 +643,8 @@ export async function deleteAgentSession(opts) {
692
643
  }
693
644
  if (record) {
694
645
  index.sessions.splice(recordIdx, 1);
695
- writeSessionIndex(resolvedWorkdir, index.sessions);
646
+ const prunedPromotions = prunePromotionsForDeletedSession(index.promotions, agent, sessionId);
647
+ writeSessionIndex(resolvedWorkdir, index.sessions, prunedPromotions ?? undefined);
696
648
  result.recordRemoved = true;
697
649
  }
698
650
  for (const dir of [sessionDirPath(resolvedWorkdir, agent, sessionId), legacySessionWorkspacePath(resolvedWorkdir, agent, sessionId)]) {
@@ -721,11 +673,6 @@ export async function deleteAgentSession(opts) {
721
673
  result.ok = true;
722
674
  return result;
723
675
  }
724
- /**
725
- * Look up the persisted model, thinkingEffort, and bound profileId for an
726
- * existing session. Returns null values when the session is not found or
727
- * fields are not set.
728
- */
729
676
  export function getSessionStoredConfig(workdir, agent, sessionId) {
730
677
  const record = findPikiloomSession(workdir, agent, sessionId);
731
678
  return {
@@ -772,7 +719,6 @@ export function stageSessionFiles(opts) {
772
719
  const importedFiles = importFilesIntoWorkspace(session.workspacePath, opts.files);
773
720
  if (importedFiles.length) {
774
721
  session.record.stagedFiles = dedupeStrings([...session.record.stagedFiles, ...importedFiles]);
775
- /* title will be set when the first text prompt arrives */
776
722
  saveSessionRecord(opts.workdir, session.record);
777
723
  }
778
724
  return {
@@ -783,9 +729,6 @@ export function stageSessionFiles(opts) {
783
729
  handoverFrom: session.record.handoverFrom ?? null,
784
730
  };
785
731
  }
786
- // ---------------------------------------------------------------------------
787
- // Merge managed and native sessions
788
- // ---------------------------------------------------------------------------
789
732
  function sessionTimelineAt(session) {
790
733
  const ts = Date.parse(session.runUpdatedAt || session.createdAt || '');
791
734
  return Number.isFinite(ts) ? ts : Number.NEGATIVE_INFINITY;
@@ -828,13 +771,6 @@ export function mergeManagedAndNativeSessions(managedSessions, nativeSessions) {
828
771
  runUpdatedAt: useNativeTimeline ? (native.runUpdatedAt ?? managed.runUpdatedAt) : (managed.runUpdatedAt ?? native.runUpdatedAt),
829
772
  title: native.title || managed.title,
830
773
  model: native.model || managed.model,
831
- // Pikiloom-owned metadata: the native session file (Claude JSONL etc.)
832
- // carries none of these, so the `...native` spread would clobber them with
833
- // `undefined`/`null`. The managed record (our centralized index) is the
834
- // source of truth — recover each like `model` above. Without this the list
835
- // silently drops the user's per-session choices: effort/Workflow fold back
836
- // to the global default (per-send `ultra` → `max` after the turn) and the
837
- // BYOK Profile binding is lost on resume.
838
774
  thinkingEffort: managed.thinkingEffort ?? native.thinkingEffort ?? null,
839
775
  workflowEnabled: managed.workflowEnabled ?? native.workflowEnabled ?? null,
840
776
  profileId: managed.profileId ?? native.profileId ?? null,
@@ -865,9 +801,6 @@ export function mergeManagedAndNativeSessions(managedSessions, nativeSessions) {
865
801
  merged.sort((a, b) => Date.parse(b.createdAt || '') - Date.parse(a.createdAt || ''));
866
802
  return merged;
867
803
  }
868
- // ---------------------------------------------------------------------------
869
- // getSessions / getSessionTail / getSessionMessages
870
- // ---------------------------------------------------------------------------
871
804
  export function getSessions(opts) {
872
805
  const workdir = path.resolve(opts.workdir);
873
806
  agentLog(`[sessions] request agent=${opts.agent} workdir=${workdir} limit=${opts.limit ?? 'all'}`);
@@ -882,15 +815,11 @@ export function getSessionTail(opts) {
882
815
  export function getSessionMessages(opts) {
883
816
  return getDriver(opts.agent).getSessionMessages(opts);
884
817
  }
885
- // ---------------------------------------------------------------------------
886
- // Turn windowing
887
- // ---------------------------------------------------------------------------
888
818
  function normalizeTurnWindowValue(value, fallback) {
889
819
  if (!Number.isFinite(value) || value == null)
890
820
  return fallback;
891
821
  return Math.max(0, Math.floor(value));
892
822
  }
893
- /** Slice messages by turn window and count total turns. Exported for drivers. */
894
823
  export function applyTurnWindow(allMsgs, opts = {}, richMsgs) {
895
824
  let totalTurns = 0;
896
825
  const turnStartIndexes = [];
@@ -900,8 +829,6 @@ export function applyTurnWindow(allMsgs, opts = {}, richMsgs) {
900
829
  totalTurns++;
901
830
  }
902
831
  }
903
- // If no rich messages provided, synthesize from plain messages so the
904
- // API always returns a consistent richMessages array.
905
832
  const rich = richMsgs ?? allMsgs.map(m => ({ role: m.role, text: m.text, blocks: [{ type: 'text', content: m.text }] }));
906
833
  if (totalTurns <= 0) {
907
834
  return {
@@ -968,13 +895,9 @@ export function applyTurnWindow(allMsgs, opts = {}, richMsgs) {
968
895
  error: null,
969
896
  };
970
897
  }
971
- /** Filter messages to last N turns and count total turns. Exported for drivers. */
972
898
  export function applyTurnFilter(allMsgs, lastNTurns, richMsgs) {
973
899
  return applyTurnWindow(allMsgs, { lastNTurns }, richMsgs);
974
900
  }
975
- // ---------------------------------------------------------------------------
976
- // Session classification
977
- // ---------------------------------------------------------------------------
978
901
  const PROPOSAL_PATTERNS = /方案|option[s ]?[A-C]|plan|approach|建议|recommend|alternatively|trade-?off|pros?\s+(and|&)\s+cons?|选择|比较/i;
979
902
  const IMPLEMENTATION_PATTERNS = /已完成|committed|done|implemented|fixed|created|wrote|修复|完成|写入|提交|applied|updated|modified|refactored/i;
980
903
  const BLOCKED_PATTERNS = /error|failed|permission denied|cannot|无法|失败|报错|blocked|timed?\s*out/i;
@@ -983,7 +906,6 @@ export function classifySession(result) {
983
906
  const message = result.message || '';
984
907
  const firstLine = message.split('\n').find(l => l.trim())?.trim() || '';
985
908
  const summaryText = firstLine.length > 120 ? firstLine.slice(0, 117) + '...' : firstLine;
986
- // 1. Structural signals from StreamResult
987
909
  if (result.incomplete) {
988
910
  return {
989
911
  outcome: 'partial',
@@ -1001,7 +923,6 @@ export function classifySession(result) {
1001
923
  classifiedAt: now,
1002
924
  };
1003
925
  }
1004
- // 2. Activity signals (tool use indicates implementation)
1005
926
  const activity = result.activity || '';
1006
927
  if (/\b(Edit|Write|Bash)\b/.test(activity)) {
1007
928
  return {
@@ -1011,7 +932,6 @@ export function classifySession(result) {
1011
932
  classifiedAt: now,
1012
933
  };
1013
934
  }
1014
- // 3. Content-based classification
1015
935
  if (BLOCKED_PATTERNS.test(message.slice(0, 500))) {
1016
936
  return {
1017
937
  outcome: 'blocked',
@@ -1036,7 +956,6 @@ export function classifySession(result) {
1036
956
  classifiedAt: now,
1037
957
  };
1038
958
  }
1039
- // 4. Default: informational answer
1040
959
  return {
1041
960
  outcome: 'answer',
1042
961
  suggestedNextAction: null,
@@ -1044,7 +963,6 @@ export function classifySession(result) {
1044
963
  classifiedAt: now,
1045
964
  };
1046
965
  }
1047
- /** Derive a default userStatus from classification outcome */
1048
966
  export function deriveUserStatus(outcome) {
1049
967
  switch (outcome) {
1050
968
  case 'answer': return 'done';
@@ -1052,14 +970,8 @@ export function deriveUserStatus(outcome) {
1052
970
  default: return 'review';
1053
971
  }
1054
972
  }
1055
- // ---------------------------------------------------------------------------
1056
- // Session export/import
1057
- // ---------------------------------------------------------------------------
1058
973
  export async function exportSession(opts) {
1059
974
  try {
1060
- // Rich mode so we can include image blocks in the export. The session
1061
- // pipeline always returns plain messages even when rich is set; rich is
1062
- // additive.
1063
975
  const result = await getSessionMessages({ ...opts, agent: opts.agent, rich: true });
1064
976
  if (!result.ok)
1065
977
  return { ok: false, content: '', filename: '', error: result.error };
@@ -1070,8 +982,6 @@ export async function exportSession(opts) {
1070
982
  let ext;
1071
983
  switch (opts.format) {
1072
984
  case 'json': {
1073
- // Materialize image bytes into inline data URLs so the JSON is a
1074
- // self-contained artefact (no dangling filesystem references).
1075
985
  const { materializeImage } = await import('./images.js');
1076
986
  const enrichedRichMessages = richMessages?.map(message => ({
1077
987
  ...message,
@@ -1114,16 +1024,9 @@ export async function exportSession(opts) {
1114
1024
  return { ok: false, content: '', filename: '', error: e.message };
1115
1025
  }
1116
1026
  }
1117
- /**
1118
- * Render an export-friendly markdown view. Each turn renders the role header,
1119
- * the text body, and (for image blocks) an inlined `![caption](data:…)` ref
1120
- * so the markdown is self-contained and renders correctly in any viewer
1121
- * (VSCode preview, GitHub, etc.) without external file lookups.
1122
- */
1123
1027
  async function renderMarkdownExport(agent, timestamp, messages, richMessages) {
1124
1028
  const lines = [`# Session Export (${agent}, ${timestamp})`, ''];
1125
1029
  const { materializeImage } = await import('./images.js');
1126
- // Walk by index so we can pair messages[i] with richMessages[i] when present.
1127
1030
  const indexed = richMessages?.length === messages.length ? richMessages : null;
1128
1031
  const sections = [];
1129
1032
  for (let i = 0; i < messages.length; i++) {
@@ -1189,11 +1092,6 @@ function parseMarkdownConversation(content) {
1189
1092
  for (const section of sections) {
1190
1093
  const firstLine = section.split('\n')[0].trim().toLowerCase();
1191
1094
  const role = firstLine.includes('user') ? 'user' : 'assistant';
1192
- // Strip inlined image data URLs (`![alt](data:image/...;base64,...)`) so
1193
- // the imported text body stays readable. The base64 payload itself isn't
1194
- // re-attached as a MessageBlock because the import API returns plain
1195
- // TailMessages; downstream agents that re-process the export will see the
1196
- // alt text "[image: alt]" placeholder where the markdown image stood.
1197
1095
  const stripped = section
1198
1096
  .split('\n')
1199
1097
  .slice(1)