pikiloom 0.4.35 → 0.4.37

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 (31) hide show
  1. package/dashboard/dist/assets/AgentTab-DbFzaIyZ.js +1 -0
  2. package/dashboard/dist/assets/{ConnectionModal-DI0f63Bc.js → ConnectionModal-QNYOWHCU.js} +1 -1
  3. package/dashboard/dist/assets/{DirBrowser-CzkwSAi6.js → DirBrowser-BGaKHjFT.js} +1 -1
  4. package/dashboard/dist/assets/{ExtensionsTab-BdK8ylOy.js → ExtensionsTab-D4Gv9b8z.js} +1 -1
  5. package/dashboard/dist/assets/{IMAccessTab-BdkwcX7w.js → IMAccessTab-DxxoZd84.js} +1 -1
  6. package/dashboard/dist/assets/{Modal-CaNqa2E7.js → Modal-Nm2e93s5.js} +1 -1
  7. package/dashboard/dist/assets/{Modals-2DgJPKGI.js → Modals-BwxZmU0U.js} +1 -1
  8. package/dashboard/dist/assets/{Select-izl0nVH0.js → Select-SGlII0yx.js} +1 -1
  9. package/dashboard/dist/assets/SessionPanel-DYfSlreh.js +1 -0
  10. package/dashboard/dist/assets/{SystemTab-DKuQIo87.js → SystemTab-BF6lIAYM.js} +1 -1
  11. package/dashboard/dist/assets/index-BP8R_bLT.js +3 -0
  12. package/dashboard/dist/assets/index-CtS48Jn-.css +1 -0
  13. package/dashboard/dist/assets/index-DZiAiRNt.js +23 -0
  14. package/dashboard/dist/assets/{shared-CZ_jczjY.js → shared-S0kcs5yP.js} +1 -1
  15. package/dashboard/dist/index.html +2 -2
  16. package/dist/agent/artifacts.js +12 -24
  17. package/dist/agent/drivers/codex.js +20 -3
  18. package/dist/agent/index.js +2 -2
  19. package/dist/agent/stream.js +41 -1
  20. package/dist/bot/commands.js +5 -27
  21. package/dist/core/config/runtime-config.js +35 -0
  22. package/dist/core/constants.js +0 -1
  23. package/dist/dashboard/routes/agents.js +2 -1
  24. package/dist/dashboard/routes/sessions.js +11 -12
  25. package/dist/dashboard/runtime.js +11 -18
  26. package/package.json +1 -1
  27. package/dashboard/dist/assets/AgentTab-BFGZXRX9.js +0 -1
  28. package/dashboard/dist/assets/SessionPanel-BKde6FwO.js +0 -1
  29. package/dashboard/dist/assets/index-B4_qsasx.js +0 -3
  30. package/dashboard/dist/assets/index-CkriX56t.css +0 -1
  31. package/dashboard/dist/assets/index-CwfalaN2.js +0 -23
@@ -6,10 +6,10 @@
6
6
  <link rel="icon" type="image/png" href="/logo.png">
7
7
  <title>Pikiloom</title>
8
8
  <link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
9
- <script type="module" crossorigin src="/assets/index-B4_qsasx.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-BP8R_bLT.js"></script>
10
10
  <link rel="modulepreload" crossorigin href="/assets/react-vendor-C7Sl8SE7.js">
11
11
  <link rel="modulepreload" crossorigin href="/assets/router-DHISdpPk.js">
12
- <link rel="stylesheet" crossorigin href="/assets/index-CkriX56t.css">
12
+ <link rel="stylesheet" crossorigin href="/assets/index-CtS48Jn-.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -101,31 +101,19 @@ export function latestDeliveredTaskId(agent, sessionId) {
101
101
  }
102
102
  return latest?.taskId;
103
103
  }
104
- export function tailDeliveredBlocks(agent, sessionId, opts) {
105
- // While a turn is running or just finished, the bot still knows its task id. Scope
106
- // strictly to it: the latest turn's deliveries only, and nothing when the latest turn
107
- // delivered no fileso a prior turn's image does not re-append onto every later reply.
108
- if (opts.currentTaskId != null) {
109
- const filter = (a) => a.taskId === opts.currentTaskId;
110
- return readDeliveredArtifacts(agent, sessionId).some(filter)
111
- ? deliveredArtifactBlocks(agent, sessionId, filter)
112
- : [];
113
- }
114
- // Idle / cold reload: the delivering turn is long over and task ids are not persisted
115
- // per turn. Fall back to the most recent delivery, suppressed once the session has kept
116
- // running well past it.
117
- const latestTask = latestDeliveredTaskId(agent, sessionId);
118
- const filter = latestTask ? (a) => a.taskId === latestTask : undefined;
119
- const scoped = readDeliveredArtifacts(agent, sessionId).filter(a => !filter || filter(a));
120
- if (!scoped.length)
121
- return [];
122
- let newestTs = scoped[0].ts;
123
- for (const a of scoped)
124
- if (a.ts > newestTs)
125
- newestTs = a.ts;
126
- if (opts.lastActivityMs != null && opts.lastActivityMs - newestTs > opts.staleAfterMs)
104
+ export function tailDeliveredBlocks(agent, sessionId, lastRunTaskId) {
105
+ // Surface only the files the LAST-RUN turn delivered, scoped to that turn's task id (the live
106
+ // stream snapshot's taskId, which persists after the turn ends). Keying on the last *run* task
107
+ // not the last *delivering* task is what stops the bleed: once any newer turn runs, even one
108
+ // that delivered nothing, the filter matches nothing and a prior turn's image stops re-appending
109
+ // onto the reply. With no known task (cold start / snapshot lost) show nothing rather than guess
110
+ // by an elapsed-time threshold, which is what previously let stale deliveries leak for an hour.
111
+ if (!lastRunTaskId)
127
112
  return [];
128
- return deliveredArtifactBlocks(agent, sessionId, filter);
113
+ const filter = (a) => a.taskId === lastRunTaskId;
114
+ return readDeliveredArtifacts(agent, sessionId).some(filter)
115
+ ? deliveredArtifactBlocks(agent, sessionId, filter)
116
+ : [];
129
117
  }
130
118
  export function deliveredArtifactBlocks(agent, sessionId, filter) {
131
119
  const blocks = [];
@@ -773,6 +773,23 @@ function createCodexStreamState(opts) {
773
773
  generatingImages: 0,
774
774
  };
775
775
  }
776
+ // Codex on a ChatGPT-account login rejects non-OpenAI models with a raw upstream JSON blob
777
+ // (e.g. {"detail":"The 'deepseek-v4-pro' model is not supported when using Codex with a
778
+ // ChatGPT account."}). When a third-party model is correctly bound to a Provider profile the
779
+ // injector routes around this; this only fires when no binding exists, so turn the opaque
780
+ // upstream error into an actionable instruction instead of leaking JSON to the chat bubble.
781
+ export function humanizeCodexError(raw) {
782
+ if (!raw)
783
+ return null;
784
+ const match = raw.match(/The '([^']+)' model is not supported when using Codex with a ChatGPT account/i);
785
+ if (match) {
786
+ const model = match[1];
787
+ return `Codex 正在使用 ChatGPT 账号登录,无法运行第三方模型「${model}」。`
788
+ + `请在「智能体配置」页接入该模型的供应商(填入 Base URL 与 API Key),添加对应模型档案并绑定到 Codex,`
789
+ + `或改用 Codex 原生模型(如 gpt-5.5)。`;
790
+ }
791
+ return raw;
792
+ }
776
793
  function codexErrorResult(error, start, sessionId, model, thinkingEffort) {
777
794
  return {
778
795
  ok: false, message: error, thinking: null,
@@ -1119,7 +1136,7 @@ export async function doCodexStream(opts) {
1119
1136
  if (threadResp.error) {
1120
1137
  const errMsg = threadResp.error.message || 'thread/start failed';
1121
1138
  agentWarn(`[codex-rpc] thread error: ${errMsg}`);
1122
- return codexErrorResult(errMsg, start, opts.sessionId, opts.model, opts.thinkingEffort);
1139
+ return codexErrorResult(humanizeCodexError(errMsg) ?? errMsg, start, opts.sessionId, opts.model, opts.thinkingEffort);
1123
1140
  }
1124
1141
  const threadResult = threadResp.result;
1125
1142
  s.sessionId = threadResult.thread?.id ?? s.sessionId;
@@ -1210,7 +1227,7 @@ export async function doCodexStream(opts) {
1210
1227
  unsubscribeRequests();
1211
1228
  const errMsg = turnResp.error.message || 'turn/start failed';
1212
1229
  agentWarn(`[codex-rpc] turn/start error: ${errMsg}`);
1213
- return codexErrorResult(errMsg, start, s.sessionId, s.model, s.thinkingEffort);
1230
+ return codexErrorResult(humanizeCodexError(errMsg) ?? errMsg, start, s.sessionId, s.model, s.thinkingEffort);
1214
1231
  }
1215
1232
  s.turnId = turnResp.result?.turn?.id ?? null;
1216
1233
  publishTurnControl();
@@ -1226,7 +1243,7 @@ export async function doCodexStream(opts) {
1226
1243
  tryEmitCodexImageBlock(s, callId);
1227
1244
  }
1228
1245
  const ok = s.turnStatus === 'completed' && !timedOut && !interrupted;
1229
- const error = s.turnError
1246
+ const error = humanizeCodexError(s.turnError)
1230
1247
  || (interrupted ? 'Interrupted by user.' : null)
1231
1248
  || (timedOut ? `Timed out after ${opts.timeout}s waiting for turn completion.` : null)
1232
1249
  || (!ok ? `Turn ${s.turnStatus || 'unknown'}.` : null);
@@ -7,7 +7,7 @@ export { attachAgentImage, attachInlineImage, materializeImage, rewriteAttachmen
7
7
  export { deliverArtifact, readDeliveredArtifacts, deliveredArtifactBlocks, tailDeliveredBlocks, latestDeliveredTaskId, mimeForArtifact, } from './artifacts.js';
8
8
  export { Q, agentLog, agentWarn, agentError, dedupeStrings, numberOrNull, normalizeStreamPreviewPlan, parseTodoWriteAsPlan, normalizeActivityLine, pushRecentActivity, detectClaudeApiError, isRetryableClaudeApiError, detectClaudeModelError, claudeModelErrorMessage, firstNonEmptyLine, shortValue, normalizeErrorMessage, joinErrorMessages, appendSystemPrompt, mimeForExt, computeContext, buildStreamPreviewMeta, summarizeClaudeToolUse, summarizeClaudeToolResult, previewToolCallInput, previewToolCallResult, roundPercent, toIsoFromEpochSeconds, normalizeUsageStatus, labelFromWindowMinutes, usageWindowFromRateLimit, parseJsonTail, modelFamily, normalizeClaudeModelId, emptyUsage, readTailLines, stripInjectedPrompts, sanitizeSessionUserPreviewText, SESSION_PREVIEW_IMAGE_PLACEHOLDER_RE, CLAUDE_AT_MENTION_IMAGE_RE, extractClaudeAtMentionImagePaths, stripClaudeAtMentionImages, isPendingSessionId, emitSessionIdUpdate, sessionListDisplayTitle, } from './utils.js';
9
9
  export { updateSessionMeta, promoteSessionId, recordFork, resolveCanonicalSessionId, getSessionPromotions, listPikiloomSessions, findPikiloomSession, getSessionStoredConfig, ensureManagedSession, findManagedThreadSession, stageSessionFiles, mergeManagedAndNativeSessions, managedRecordToSessionInfo, getSessions, getSessionTail, getSessionMessages, applyTurnWindow, applyTurnFilter, classifySession, deriveUserStatus, exportSession, importSession, deleteAgentSession, isProcessAlive, isRunningSessionStale, reconcileOrphanedRunningSessions, } from './session.js';
10
- export { detectAgentBin, listAgents, resolveDefaultAgent, run, doStream, listModels, resolveAgentModels, getUsage, getAgentBoundModelId, setAgentBoundModelId, } from './stream.js';
10
+ export { detectAgentBin, listAgents, resolveDefaultAgent, run, doStream, recoverProfileIdForModel, listModels, resolveAgentModels, getUsage, getAgentBoundModelId, setAgentBoundModelId, } from './stream.js';
11
11
  export { registerDriver, getDriver, getDriverCapabilities, allDrivers, allDriverIds, hasDriver, shutdownAllDrivers, } from './driver.js';
12
12
  export { getProjectSkillPaths, initializeProjectSkills, listSkills, getGlobalSkillsRoot, collapseSkillPrompt, } from './skills.js';
13
13
  export { readGoal, writeGoal, clearGoal, setGoal, pauseGoal, resumeGoal, completeGoal, accountTurn, bumpContinuationCount, shouldContinueAfterTurn, renderContinuationPrompt, renderBudgetLimitPrompt, sessionGoalPath, DEFAULT_MAX_CONTINUATIONS, } from './goal.js';
@@ -19,6 +19,6 @@ export { getMcpToken, saveMcpToken, deleteMcpToken, hasValidMcpToken, startAutho
19
19
  export { installSkill, removeSkill, checkSkillUpdates, getGlobalSkillsDir, recordSkillInstall, getSkillLedgerEntry, forgetSkillInstall, normalizeSkillSourceKey, } from './skill-installer.js';
20
20
  export { getRecommendedClis, getRecommendedCli, detectCli, getCachedCliStatus, invalidateCliStatus, currentPlatform, getCliCatalog, refreshCliStatus, startCliAuthSession, getAuthSession, cancelAuthSession, applyCliToken, logoutCli, startCliInstallSession, } from './cli/index.js';
21
21
  export { doClaudeStream } from './drivers/claude.js';
22
- export { doCodexStream, buildCodexTurnInput, shutdownCodexServer, getCodexUsageLive } from './drivers/codex.js';
22
+ export { doCodexStream, buildCodexTurnInput, shutdownCodexServer, getCodexUsageLive, humanizeCodexError } from './drivers/codex.js';
23
23
  export { doGeminiStream } from './drivers/gemini.js';
24
24
  export { doHermesStream } from './drivers/hermes.js';
@@ -388,6 +388,35 @@ function finalizeStreamResult(result, workdir, prompt, session, workflowEnabled,
388
388
  saveSessionRecord(workdir, session.record);
389
389
  return { ...result, sessionId: session.sessionId, workspacePath: session.workspacePath };
390
390
  }
391
+ function requestedModelForAgent(opts) {
392
+ switch (opts.agent) {
393
+ case 'claude': return (opts.claudeModel || opts.model || '').trim();
394
+ case 'codex': return (opts.codexModel || opts.model || '').trim();
395
+ case 'gemini': return (opts.geminiModel || opts.model || '').trim();
396
+ case 'hermes': return (opts.hermesModel || opts.model || '').trim();
397
+ }
398
+ return (opts.model || '').trim();
399
+ }
400
+ // When a session carries a third-party model id but its profile binding was lost
401
+ // (stale/deleted profileId, or a model selected without a binding), resolveAgentInjection
402
+ // returns null and the agent silently falls back to its native account — e.g. Codex on a
403
+ // ChatGPT login rejects "deepseek-v4-pro". If exactly one configured profile (for a provider
404
+ // kind this agent accepts) declares that model id, the intent is unambiguous: recover it.
405
+ export function recoverProfileIdForModel(agent, modelId) {
406
+ const model = modelId.trim();
407
+ if (!model)
408
+ return null;
409
+ const accepted = new Set(getAcceptedProviderKinds(agent));
410
+ if (accepted.size === 0)
411
+ return null;
412
+ const matches = listProfiles().filter(profile => {
413
+ if (profile.modelId !== model)
414
+ return false;
415
+ const provider = getProvider(profile.providerId);
416
+ return !!provider && accepted.has(provider.kind);
417
+ });
418
+ return matches.length === 1 ? matches[0].id : null;
419
+ }
391
420
  export async function doStream(opts) {
392
421
  let session;
393
422
  let prepared;
@@ -446,7 +475,18 @@ export async function doStream(opts) {
446
475
  agentWarn(`[mcp] bridge start failed: ${e.message} — proceeding without MCP`);
447
476
  }
448
477
  try {
449
- const injection = await resolveAgentInjection(prepared.agent, prepared.profileId);
478
+ let injection = await resolveAgentInjection(prepared.agent, prepared.profileId);
479
+ if (!injection) {
480
+ const requestedModel = requestedModelForAgent(prepared);
481
+ const recoveredProfileId = recoverProfileIdForModel(prepared.agent, requestedModel);
482
+ if (recoveredProfileId) {
483
+ injection = await resolveAgentInjection(prepared.agent, recoveredProfileId);
484
+ if (injection) {
485
+ prepared.profileId = recoveredProfileId;
486
+ agentLog(`[byok] recovered lost provider binding: agent=${prepared.agent} model=${requestedModel} → profile=${recoveredProfileId}`);
487
+ }
488
+ }
489
+ }
450
490
  if (injection) {
451
491
  prepared.extraEnv = { ...(prepared.extraEnv || {}), ...injection.env };
452
492
  if (injection.modelOverride) {
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  import { fmtTokens, fmtUptime, fmtBytes } from './bot.js';
4
4
  import { getProjectSkillPaths, normalizeClaudeModelId, sessionListDisplayTitle, listAllMcpExtensions, listSkills as listAllSkills, } from '../agent/index.js';
5
5
  import { getDriver } from '../agent/driver.js';
6
+ import { effortOptionsFor } from '../core/config/runtime-config.js';
6
7
  import { getActiveProfile, getProvider } from '../model/index.js';
7
8
  import { buildWelcomeIntro, buildSkillCommandName, indexSkillsByCommand, SKILL_CMD_PREFIX } from './menu.js';
8
9
  import { buildBotMenuState } from './orchestration.js';
@@ -380,35 +381,12 @@ export function modelMatchesSelection(agent, selection, currentModel) {
380
381
  const b = claudeModelFamily(currentModel);
381
382
  return !!a && a === b;
382
383
  }
383
- const EFFORT_LEVELS = {
384
- claude: [
385
- { id: 'low', label: 'Low' },
386
- { id: 'medium', label: 'Medium' },
387
- { id: 'high', label: 'High' },
388
- { id: 'xhigh', label: 'Very High' },
389
- { id: 'max', label: 'Max' },
390
- { id: 'ultra', label: 'Ultra' },
391
- ],
392
- codex: [
393
- { id: 'low', label: 'Low' },
394
- { id: 'medium', label: 'Medium' },
395
- { id: 'high', label: 'High' },
396
- { id: 'xhigh', label: 'Very High' },
397
- ],
398
- hermes: [
399
- { id: 'minimal', label: 'Minimal' },
400
- { id: 'low', label: 'Low' },
401
- { id: 'medium', label: 'Medium' },
402
- { id: 'high', label: 'High' },
403
- { id: 'xhigh', label: 'Very High' },
404
- ],
405
- };
406
- function buildEffortData(bot, agent) {
384
+ function buildEffortData(bot, agent, model) {
407
385
  const currentEffort = bot.effortSelectionForAgent(agent);
408
386
  if (!currentEffort)
409
387
  return null;
410
- const levels = EFFORT_LEVELS[agent];
411
- if (!levels)
388
+ const levels = effortOptionsFor(agent, model);
389
+ if (!levels.length)
412
390
  return null;
413
391
  return {
414
392
  current: currentEffort,
@@ -440,7 +418,7 @@ export async function getModelsListData(bot, chatId) {
440
418
  providerName: m.providerName ?? null,
441
419
  };
442
420
  }),
443
- effort: buildEffortData(bot, cs.agent),
421
+ effort: buildEffortData(bot, cs.agent, currentModel),
444
422
  };
445
423
  }
446
424
  export async function getStatusDataAsync(bot, chatId) {
@@ -164,3 +164,38 @@ export function decomposeEffortSelection(raw) {
164
164
  return { effort: 'max', workflow: true };
165
165
  return { effort: value, workflow: false };
166
166
  }
167
+ // Single source of truth for reasoning-effort levels, ordered low→high. BOTH the dashboard
168
+ // (which receives them via the agent/model API payload) and the IM bot consume this — do not
169
+ // reintroduce a second copy. Resolve options through effortOptionsFor(), never index directly,
170
+ // so per-model/provider rules stay in one place.
171
+ const AGENT_EFFORT_LEVELS = {
172
+ claude: [
173
+ { id: 'low', label: 'Low' },
174
+ { id: 'medium', label: 'Medium' },
175
+ { id: 'high', label: 'High' },
176
+ { id: 'xhigh', label: 'Very High' },
177
+ { id: 'max', label: 'Max' },
178
+ { id: ULTRA_EFFORT, label: 'Ultra' },
179
+ ],
180
+ codex: [
181
+ { id: 'low', label: 'Low' },
182
+ { id: 'medium', label: 'Medium' },
183
+ { id: 'high', label: 'High' },
184
+ { id: 'xhigh', label: 'Very High' },
185
+ ],
186
+ // gemini intentionally has no UI-exposed effort levels: pikiloom sends it no reasoning-effort
187
+ // (see the gemini→null guards in InputComposer). Add a gemini entry here to surface low/high.
188
+ hermes: [
189
+ { id: 'minimal', label: 'Minimal' },
190
+ { id: 'low', label: 'Low' },
191
+ { id: 'medium', label: 'Medium' },
192
+ { id: 'high', label: 'High' },
193
+ { id: 'xhigh', label: 'Very High' },
194
+ ],
195
+ };
196
+ // Valid effort levels for a given (agent, model, providerKind). Returns [] when reasoning
197
+ // effort does not apply (the UI then hides the selector entirely). model/providerKind are the
198
+ // seam for per-model rules — add them here and nowhere else.
199
+ export function effortOptionsFor(agent, _model, _providerKind) {
200
+ return AGENT_EFFORT_LEVELS[agent] ?? [];
201
+ }
@@ -8,7 +8,6 @@ export const MCP_TIMEOUTS = {
8
8
  codexMcpRemove: 5_000,
9
9
  };
10
10
  export const MCP_ARTIFACT_MAX_BYTES = 20 * 1024 * 1024;
11
- export const DELIVERED_ARTIFACT_TAIL_STALE_MS = 60 * 60_000;
12
11
  export const DASHBOARD_TIMEOUTS = {
13
12
  agentStatusModels: 4_000,
14
13
  agentStatusUsage: 1_500,
@@ -7,7 +7,7 @@ import { loadUserConfig, saveUserConfig, applyUserConfig } from '../../core/conf
7
7
  import { setAgentBoundModelId } from '../../agent/index.js';
8
8
  import { getAgentUpdateState, checkAgentLatestVersion, manualAgentUpdate } from '../../agent/auto-update.js';
9
9
  import { getDriver, getDriverCapabilities } from '../../agent/driver.js';
10
- import { decomposeEffortSelection } from '../../core/config/runtime-config.js';
10
+ import { decomposeEffortSelection, effortOptionsFor } from '../../core/config/runtime-config.js';
11
11
  import { getActiveProfile, getProvider, peekProviderModelList, prefetchProviderModels, } from '../../model/index.js';
12
12
  import { DASHBOARD_TIMEOUTS } from '../../core/constants.js';
13
13
  import { withTimeoutFallback } from '../../core/utils.js';
@@ -195,6 +195,7 @@ async function buildAgentStatusResponse(config = loadUserConfig(), agentOptions
195
195
  ...agentState,
196
196
  selectedModel,
197
197
  selectedEffort,
198
+ effortOptions: effortOptionsFor(agentId, selectedModel, byokProvider?.kind ?? null),
198
199
  nativeSelectedModel,
199
200
  nativeSelectedEffort,
200
201
  workflowEnabled: runtime.getRuntimeWorkflowEnabled(agentId, config),
@@ -10,7 +10,7 @@ import { findPikiloomSession } from '../../agent/session.js';
10
10
  import { readAwaitResume } from '../../agent/await-resume.js';
11
11
  import { cancelSessionTask, stopSessionTasks, getSessionStreamState, queueDashboardSessionTask, forkDashboardSessionTask, steerSessionTask, interactionSelectOption, interactionSubmitText, interactionSkip, interactionCancel, getInteractionPrompt, } from '../session-control.js';
12
12
  import { querySessions, querySessionTail, querySessionMessages, getWorkspaceOverviews, updateSession, linkSessions, buildMigrationContext, exportSession, importSession, deleteSession, loadWorkspaces, addWorkspace, removeWorkspace, updateWorkspace, } from '../../bot/session-hub.js';
13
- import { DASHBOARD_PAGINATION, DELIVERED_ARTIFACT_TAIL_STALE_MS } from '../../core/constants.js';
13
+ import { DASHBOARD_PAGINATION } from '../../core/constants.js';
14
14
  import { runtime } from '../runtime.js';
15
15
  const DEFAULT_SESSION_PAGE_SIZE = DASHBOARD_PAGINATION.defaultPageSize;
16
16
  const MAX_SESSION_PAGE_SIZE = DASHBOARD_PAGINATION.maxPageSize;
@@ -394,16 +394,17 @@ app.post('/api/session-hub/session/messages', async (c) => {
394
394
  turnLimit: Number.isFinite(turnLimit) ? turnLimit : undefined,
395
395
  rich,
396
396
  });
397
- const record = findPikiloomSession(workdir, agent, sessionId);
398
- const lastActivity = record ? Date.parse(record.runUpdatedAt || record.updatedAt) : NaN;
399
- const lastActivityMs = Number.isFinite(lastActivity) ? lastActivity : null;
400
- return c.json(prepareSessionMessagesForDashboard(result, agent, sessionId, lastActivityMs));
397
+ // Scope the delivered-artifact tail to the session's last-run turn (its task id, from the
398
+ // live snapshot which persists after the turn). A later turn that delivered nothing then
399
+ // yields no tail blocks, so a prior turn's image can't re-append onto the newest reply.
400
+ const lastRunTaskId = runtime.getBotRef()?.getStreamSnapshot(`${agent}:${sessionId}`)?.taskId ?? null;
401
+ return c.json(prepareSessionMessagesForDashboard(result, agent, sessionId, lastRunTaskId));
401
402
  }
402
403
  catch (e) {
403
404
  return c.json({ ok: false, error: e.message }, 500);
404
405
  }
405
406
  });
406
- function prepareSessionMessagesForDashboard(result, agent, sessionId, lastActivityMs) {
407
+ function prepareSessionMessagesForDashboard(result, agent, sessionId, lastRunTaskId) {
407
408
  if (result.richMessages === undefined)
408
409
  return result;
409
410
  const richMessages = result.richMessages.map(message => ({
@@ -412,12 +413,10 @@ function prepareSessionMessagesForDashboard(result, agent, sessionId, lastActivi
412
413
  }));
413
414
  const includesTail = !result.window || !result.window.hasNewer;
414
415
  if (includesTail) {
415
- // Surface the latest turn's delivered files. Artifacts live in a session-wide
416
- // manifest, so the dump is scoped to the latest task (without it every file ever
417
- // sent would re-append onto the latest reply) and suppressed once the session has
418
- // kept running well past the last delivery an out-of-band file is only "tail"
419
- // content for the turn that sent it, not for later turns that delivered nothing.
420
- const delivered = rewriteAttachmentBlocksForTransport(tailDeliveredBlocks(agent, sessionId, { lastActivityMs, staleAfterMs: DELIVERED_ARTIFACT_TAIL_STALE_MS }), { agent, sessionId });
416
+ // Out-of-band delivered files (im_send_file) aren't in the transcript, so the latest turn's
417
+ // deliveries are appended as a trailing message. tailDeliveredBlocks scopes strictly to the
418
+ // last-run task id only THIS turn's files surface, never a prior turn's (no time heuristic).
419
+ const delivered = rewriteAttachmentBlocksForTransport(tailDeliveredBlocks(agent, sessionId, lastRunTaskId), { agent, sessionId });
421
420
  if (delivered.length) {
422
421
  const text = deliveredSummaryText(delivered);
423
422
  richMessages.push({ role: 'assistant', text, blocks: delivered });
@@ -284,24 +284,17 @@ class Runtime {
284
284
  }
285
285
  return { channel, key, cached: null, livePromise: this.validateChannel(channel, config), fallback: fallback[idx] };
286
286
  });
287
- const resolved = await Promise.all(plans.map(plan => {
288
- if (plan.cached)
289
- return Promise.resolve(plan.cached);
290
- return withTimeoutFallback(plan.livePromise, CHANNEL_STATUS_VALIDATION_TIMEOUT_MS, plan.fallback);
291
- }));
292
- plans.forEach((plan, i) => {
287
+ // Never block /api/state on live network validation — Feishu/Weixin round-trips can take
288
+ // seconds and this is the sole caller. Return fresh-cached states where available, local
289
+ // fallback otherwise, and validate uncached channels in the background to populate the
290
+ // cache. The dashboard re-polls while any channel is pending (hasPendingChannelValidation)
291
+ // and converges to the live result on the next poll.
292
+ const resolved = plans.map(plan => plan.cached ?? plan.fallback);
293
+ for (const plan of plans) {
293
294
  if (!plan.livePromise)
294
- return;
295
- const state = resolved[i];
296
- if (shouldCacheChannelStates([state])) {
297
- this.channelStateCache.set(plan.channel, {
298
- key: plan.key,
299
- expiresAt: now + CHANNEL_STATUS_CACHE_TTL_MS,
300
- state,
301
- });
302
- return;
303
- }
304
- void plan.livePromise.then(bgState => {
295
+ continue;
296
+ void withTimeoutFallback(plan.livePromise, CHANNEL_STATUS_VALIDATION_TIMEOUT_MS, plan.fallback)
297
+ .then(bgState => {
305
298
  if (!shouldCacheChannelStates([bgState]))
306
299
  return;
307
300
  const current = this.channelStateCache.get(plan.channel);
@@ -313,7 +306,7 @@ class Runtime {
313
306
  state: bgState,
314
307
  });
315
308
  }).catch(() => { });
316
- });
309
+ }
317
310
  return resolved;
318
311
  }
319
312
  getSetupState(config = loadUserConfig(), agentOptions = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikiloom",
3
- "version": "0.4.35",
3
+ "version": "0.4.37",
4
4
  "description": "Put the world's smartest AI agents in your pocket. Command local Claude & Gemini via IM. | 让最好用的 IM 变成你电脑上的顶级 Agent 控制台",
5
5
  "type": "module",
6
6
  "bin": {