pikiloom 0.4.26 → 0.4.28

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-BNJ73P0m.js → AgentTab-DtjuI8NU.js} +1 -1
  2. package/dashboard/dist/assets/ConnectionModal-BIx6a5o1.js +1 -0
  3. package/dashboard/dist/assets/{DirBrowser-D-GT-G-w.js → DirBrowser-BINh_7NB.js} +1 -1
  4. package/dashboard/dist/assets/{ExtensionsTab-BrJMulJR.js → ExtensionsTab-CHpd147b.js} +1 -1
  5. package/dashboard/dist/assets/{IMAccessTab-auPnx468.js → IMAccessTab-C4DbGxbG.js} +1 -1
  6. package/dashboard/dist/assets/{Modal-lYfO9m-I.js → Modal-D_PUi-5O.js} +1 -1
  7. package/dashboard/dist/assets/{Modals-PRdWOIY9.js → Modals-BahDEMqf.js} +1 -1
  8. package/dashboard/dist/assets/{Select-BhbU-rll.js → Select-Cp5VXb_2.js} +1 -1
  9. package/dashboard/dist/assets/SessionPanel-DZVNXptx.js +1 -0
  10. package/dashboard/dist/assets/SystemTab-CLznS31f.js +1 -0
  11. package/dashboard/dist/assets/index-CLGovkkw.js +3 -0
  12. package/dashboard/dist/assets/index-CXIN3nTr.css +1 -0
  13. package/dashboard/dist/assets/index-DcYOI6rL.js +23 -0
  14. package/dashboard/dist/assets/{shared-C5uXvhZJ.js → shared-C8DCTJ44.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 +86 -196
  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-Dt05H2f9.js +0 -1
  152. package/dashboard/dist/assets/SessionPanel-_0dLWvEb.js +0 -1
  153. package/dashboard/dist/assets/SystemTab-CN_QrRKt.js +0 -1
  154. package/dashboard/dist/assets/index-CP3LYJR2.js +0 -23
  155. package/dashboard/dist/assets/index-C_neLnaO.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,9 +530,6 @@ 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);
@@ -556,13 +537,6 @@ export function importFilesIntoWorkspace(workspacePath, files) {
556
537
  const resolved = path.resolve(filePath);
557
538
  if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile())
558
539
  continue;
559
- // Resolve symlinks on the source too — realWorkspace is already realpath'd,
560
- // so comparing it against a merely path.resolve'd source makes a file that
561
- // ALREADY lives in the workspace look "outside" whenever a path component is
562
- // a symlink (macOS /tmp → /private/tmp, or an iCloud-synced ~/Desktop). That
563
- // mis-detection re-copies an already-staged attachment under a collision
564
- // name, so the same image lands in the prompt TWICE and renders twice.
565
- // realpath'ing both sides keeps the in-workspace check symlink-safe.
566
540
  const sourcePath = fs.realpathSync(resolved);
567
541
  const relPath = path.relative(realWorkspace, sourcePath);
568
542
  if (relPath && !relPath.startsWith('..') && !path.isAbsolute(relPath)) {
@@ -575,9 +549,6 @@ export function importFilesIntoWorkspace(workspacePath, files) {
575
549
  }
576
550
  return dedupeStrings(imported);
577
551
  }
578
- // ---------------------------------------------------------------------------
579
- // Ensure session workspace
580
- // ---------------------------------------------------------------------------
581
552
  export function ensureSessionWorkspace(opts) {
582
553
  const workdir = path.resolve(opts.workdir);
583
554
  const index = loadSessionIndex(workdir);
@@ -604,7 +575,6 @@ export function ensureSessionWorkspace(opts) {
604
575
  }
605
576
  if (!record.threadId)
606
577
  record.threadId = normalizeThreadId(opts.threadId) || legacyThreadId(record.agent, record.sessionId);
607
- // Backfill handoverFrom on first staging only — never overwrite an existing one.
608
578
  if (!record.handoverFrom)
609
579
  record.handoverFrom = normalizeHandoverRef(opts.handoverFrom);
610
580
  if (!record.title && opts.title)
@@ -613,14 +583,7 @@ export function ensureSessionWorkspace(opts) {
613
583
  saveSessionRecord(workdir, record);
614
584
  return { sessionId: record.sessionId, workspacePath: record.workspacePath, record };
615
585
  }
616
- // ---------------------------------------------------------------------------
617
- // Record to SessionInfo
618
- // ---------------------------------------------------------------------------
619
586
  export function managedRecordToSessionInfo(record) {
620
- // Collapse pre-fix records that stored the canonical skill expansion as the
621
- // title / lastQuestion / lastMessageText. New records get collapsed at write
622
- // time in `prepareStreamOpts`; this read-time pass keeps existing sessions
623
- // from showing the long instruction in the sidebar after the fix lands.
624
587
  const title = collapseSkillPrompt(record.title) ?? record.title;
625
588
  const lastQuestion = collapseSkillPrompt(record.lastQuestion) ?? record.lastQuestion;
626
589
  const lastMessageText = collapseSkillPrompt(record.lastMessageText) ?? record.lastMessageText;
@@ -654,10 +617,6 @@ export function managedRecordToSessionInfo(record) {
654
617
  handoverFrom: record.handoverFrom ?? null,
655
618
  };
656
619
  }
657
- // ---------------------------------------------------------------------------
658
- // Public session queries
659
- // ---------------------------------------------------------------------------
660
- // Exported for drivers
661
620
  export function listPikiloomSessions(workdir, agent, limit) {
662
621
  const records = sortByUpdatedAtDesc(loadSessionIndex(path.resolve(workdir)).sessions.filter(entry => entry.agent === agent));
663
622
  return typeof limit === 'number' ? records.slice(0, limit) : records;
@@ -665,22 +624,6 @@ export function listPikiloomSessions(workdir, agent, limit) {
665
624
  export function findPikiloomSession(workdir, agent, sessionId) {
666
625
  return listPikiloomSessions(workdir, agent).find(entry => entry.sessionId === sessionId) || null;
667
626
  }
668
- /**
669
- * Delete a pikiloom-managed session. Two scopes:
670
- * - default: drop the index entry + recursively delete the per-session dir
671
- * under `<workdir>/.pikiloom/sessions/<agent>/<sessionId>/` (and the legacy
672
- * `workspaces/` path). Native agent transcript is left in place so the
673
- * user can still resume the conversation outside pikiloom.
674
- * - `purgeNative: true`: also call the driver's `deleteNativeSession` to
675
- * remove the underlying jsonl/rollout file.
676
- *
677
- * Refuses to delete a session whose record is currently marked running and
678
- * not stale (active process or recent mtime) — caller should stop the
679
- * stream first.
680
- *
681
- * Sessions that exist only in the agent's native store (no pikiloom record)
682
- * are still purgeable when `purgeNative` is set.
683
- */
684
627
  export async function deleteAgentSession(opts) {
685
628
  const resolvedWorkdir = path.resolve(opts.workdir);
686
629
  const { agent, sessionId } = opts;
@@ -700,7 +643,8 @@ export async function deleteAgentSession(opts) {
700
643
  }
701
644
  if (record) {
702
645
  index.sessions.splice(recordIdx, 1);
703
- writeSessionIndex(resolvedWorkdir, index.sessions);
646
+ const prunedPromotions = prunePromotionsForDeletedSession(index.promotions, agent, sessionId);
647
+ writeSessionIndex(resolvedWorkdir, index.sessions, prunedPromotions ?? undefined);
704
648
  result.recordRemoved = true;
705
649
  }
706
650
  for (const dir of [sessionDirPath(resolvedWorkdir, agent, sessionId), legacySessionWorkspacePath(resolvedWorkdir, agent, sessionId)]) {
@@ -729,11 +673,6 @@ export async function deleteAgentSession(opts) {
729
673
  result.ok = true;
730
674
  return result;
731
675
  }
732
- /**
733
- * Look up the persisted model, thinkingEffort, and bound profileId for an
734
- * existing session. Returns null values when the session is not found or
735
- * fields are not set.
736
- */
737
676
  export function getSessionStoredConfig(workdir, agent, sessionId) {
738
677
  const record = findPikiloomSession(workdir, agent, sessionId);
739
678
  return {
@@ -780,7 +719,6 @@ export function stageSessionFiles(opts) {
780
719
  const importedFiles = importFilesIntoWorkspace(session.workspacePath, opts.files);
781
720
  if (importedFiles.length) {
782
721
  session.record.stagedFiles = dedupeStrings([...session.record.stagedFiles, ...importedFiles]);
783
- /* title will be set when the first text prompt arrives */
784
722
  saveSessionRecord(opts.workdir, session.record);
785
723
  }
786
724
  return {
@@ -791,9 +729,6 @@ export function stageSessionFiles(opts) {
791
729
  handoverFrom: session.record.handoverFrom ?? null,
792
730
  };
793
731
  }
794
- // ---------------------------------------------------------------------------
795
- // Merge managed and native sessions
796
- // ---------------------------------------------------------------------------
797
732
  function sessionTimelineAt(session) {
798
733
  const ts = Date.parse(session.runUpdatedAt || session.createdAt || '');
799
734
  return Number.isFinite(ts) ? ts : Number.NEGATIVE_INFINITY;
@@ -836,13 +771,6 @@ export function mergeManagedAndNativeSessions(managedSessions, nativeSessions) {
836
771
  runUpdatedAt: useNativeTimeline ? (native.runUpdatedAt ?? managed.runUpdatedAt) : (managed.runUpdatedAt ?? native.runUpdatedAt),
837
772
  title: native.title || managed.title,
838
773
  model: native.model || managed.model,
839
- // Pikiloom-owned metadata: the native session file (Claude JSONL etc.)
840
- // carries none of these, so the `...native` spread would clobber them with
841
- // `undefined`/`null`. The managed record (our centralized index) is the
842
- // source of truth — recover each like `model` above. Without this the list
843
- // silently drops the user's per-session choices: effort/Workflow fold back
844
- // to the global default (per-send `ultra` → `max` after the turn) and the
845
- // BYOK Profile binding is lost on resume.
846
774
  thinkingEffort: managed.thinkingEffort ?? native.thinkingEffort ?? null,
847
775
  workflowEnabled: managed.workflowEnabled ?? native.workflowEnabled ?? null,
848
776
  profileId: managed.profileId ?? native.profileId ?? null,
@@ -873,9 +801,6 @@ export function mergeManagedAndNativeSessions(managedSessions, nativeSessions) {
873
801
  merged.sort((a, b) => Date.parse(b.createdAt || '') - Date.parse(a.createdAt || ''));
874
802
  return merged;
875
803
  }
876
- // ---------------------------------------------------------------------------
877
- // getSessions / getSessionTail / getSessionMessages
878
- // ---------------------------------------------------------------------------
879
804
  export function getSessions(opts) {
880
805
  const workdir = path.resolve(opts.workdir);
881
806
  agentLog(`[sessions] request agent=${opts.agent} workdir=${workdir} limit=${opts.limit ?? 'all'}`);
@@ -890,15 +815,11 @@ export function getSessionTail(opts) {
890
815
  export function getSessionMessages(opts) {
891
816
  return getDriver(opts.agent).getSessionMessages(opts);
892
817
  }
893
- // ---------------------------------------------------------------------------
894
- // Turn windowing
895
- // ---------------------------------------------------------------------------
896
818
  function normalizeTurnWindowValue(value, fallback) {
897
819
  if (!Number.isFinite(value) || value == null)
898
820
  return fallback;
899
821
  return Math.max(0, Math.floor(value));
900
822
  }
901
- /** Slice messages by turn window and count total turns. Exported for drivers. */
902
823
  export function applyTurnWindow(allMsgs, opts = {}, richMsgs) {
903
824
  let totalTurns = 0;
904
825
  const turnStartIndexes = [];
@@ -908,8 +829,6 @@ export function applyTurnWindow(allMsgs, opts = {}, richMsgs) {
908
829
  totalTurns++;
909
830
  }
910
831
  }
911
- // If no rich messages provided, synthesize from plain messages so the
912
- // API always returns a consistent richMessages array.
913
832
  const rich = richMsgs ?? allMsgs.map(m => ({ role: m.role, text: m.text, blocks: [{ type: 'text', content: m.text }] }));
914
833
  if (totalTurns <= 0) {
915
834
  return {
@@ -976,13 +895,9 @@ export function applyTurnWindow(allMsgs, opts = {}, richMsgs) {
976
895
  error: null,
977
896
  };
978
897
  }
979
- /** Filter messages to last N turns and count total turns. Exported for drivers. */
980
898
  export function applyTurnFilter(allMsgs, lastNTurns, richMsgs) {
981
899
  return applyTurnWindow(allMsgs, { lastNTurns }, richMsgs);
982
900
  }
983
- // ---------------------------------------------------------------------------
984
- // Session classification
985
- // ---------------------------------------------------------------------------
986
901
  const PROPOSAL_PATTERNS = /方案|option[s ]?[A-C]|plan|approach|建议|recommend|alternatively|trade-?off|pros?\s+(and|&)\s+cons?|选择|比较/i;
987
902
  const IMPLEMENTATION_PATTERNS = /已完成|committed|done|implemented|fixed|created|wrote|修复|完成|写入|提交|applied|updated|modified|refactored/i;
988
903
  const BLOCKED_PATTERNS = /error|failed|permission denied|cannot|无法|失败|报错|blocked|timed?\s*out/i;
@@ -991,7 +906,6 @@ export function classifySession(result) {
991
906
  const message = result.message || '';
992
907
  const firstLine = message.split('\n').find(l => l.trim())?.trim() || '';
993
908
  const summaryText = firstLine.length > 120 ? firstLine.slice(0, 117) + '...' : firstLine;
994
- // 1. Structural signals from StreamResult
995
909
  if (result.incomplete) {
996
910
  return {
997
911
  outcome: 'partial',
@@ -1009,7 +923,6 @@ export function classifySession(result) {
1009
923
  classifiedAt: now,
1010
924
  };
1011
925
  }
1012
- // 2. Activity signals (tool use indicates implementation)
1013
926
  const activity = result.activity || '';
1014
927
  if (/\b(Edit|Write|Bash)\b/.test(activity)) {
1015
928
  return {
@@ -1019,7 +932,6 @@ export function classifySession(result) {
1019
932
  classifiedAt: now,
1020
933
  };
1021
934
  }
1022
- // 3. Content-based classification
1023
935
  if (BLOCKED_PATTERNS.test(message.slice(0, 500))) {
1024
936
  return {
1025
937
  outcome: 'blocked',
@@ -1044,7 +956,6 @@ export function classifySession(result) {
1044
956
  classifiedAt: now,
1045
957
  };
1046
958
  }
1047
- // 4. Default: informational answer
1048
959
  return {
1049
960
  outcome: 'answer',
1050
961
  suggestedNextAction: null,
@@ -1052,7 +963,6 @@ export function classifySession(result) {
1052
963
  classifiedAt: now,
1053
964
  };
1054
965
  }
1055
- /** Derive a default userStatus from classification outcome */
1056
966
  export function deriveUserStatus(outcome) {
1057
967
  switch (outcome) {
1058
968
  case 'answer': return 'done';
@@ -1060,14 +970,8 @@ export function deriveUserStatus(outcome) {
1060
970
  default: return 'review';
1061
971
  }
1062
972
  }
1063
- // ---------------------------------------------------------------------------
1064
- // Session export/import
1065
- // ---------------------------------------------------------------------------
1066
973
  export async function exportSession(opts) {
1067
974
  try {
1068
- // Rich mode so we can include image blocks in the export. The session
1069
- // pipeline always returns plain messages even when rich is set; rich is
1070
- // additive.
1071
975
  const result = await getSessionMessages({ ...opts, agent: opts.agent, rich: true });
1072
976
  if (!result.ok)
1073
977
  return { ok: false, content: '', filename: '', error: result.error };
@@ -1078,8 +982,6 @@ export async function exportSession(opts) {
1078
982
  let ext;
1079
983
  switch (opts.format) {
1080
984
  case 'json': {
1081
- // Materialize image bytes into inline data URLs so the JSON is a
1082
- // self-contained artefact (no dangling filesystem references).
1083
985
  const { materializeImage } = await import('./images.js');
1084
986
  const enrichedRichMessages = richMessages?.map(message => ({
1085
987
  ...message,
@@ -1122,16 +1024,9 @@ export async function exportSession(opts) {
1122
1024
  return { ok: false, content: '', filename: '', error: e.message };
1123
1025
  }
1124
1026
  }
1125
- /**
1126
- * Render an export-friendly markdown view. Each turn renders the role header,
1127
- * the text body, and (for image blocks) an inlined `![caption](data:…)` ref
1128
- * so the markdown is self-contained and renders correctly in any viewer
1129
- * (VSCode preview, GitHub, etc.) without external file lookups.
1130
- */
1131
1027
  async function renderMarkdownExport(agent, timestamp, messages, richMessages) {
1132
1028
  const lines = [`# Session Export (${agent}, ${timestamp})`, ''];
1133
1029
  const { materializeImage } = await import('./images.js');
1134
- // Walk by index so we can pair messages[i] with richMessages[i] when present.
1135
1030
  const indexed = richMessages?.length === messages.length ? richMessages : null;
1136
1031
  const sections = [];
1137
1032
  for (let i = 0; i < messages.length; i++) {
@@ -1197,11 +1092,6 @@ function parseMarkdownConversation(content) {
1197
1092
  for (const section of sections) {
1198
1093
  const firstLine = section.split('\n')[0].trim().toLowerCase();
1199
1094
  const role = firstLine.includes('user') ? 'user' : 'assistant';
1200
- // Strip inlined image data URLs (`![alt](data:image/...;base64,...)`) so
1201
- // the imported text body stays readable. The base64 payload itself isn't
1202
- // re-attached as a MessageBlock because the import API returns plain
1203
- // TailMessages; downstream agents that re-process the export will see the
1204
- // alt text "[image: alt]" placeholder where the markdown image stood.
1205
1095
  const stripped = section
1206
1096
  .split('\n')
1207
1097
  .slice(1)