pikiloop 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +353 -0
- package/README.v2.md +287 -0
- package/README.zh-CN.md +352 -0
- package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
- package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
- package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
- package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
- package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
- package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
- package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
- package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
- package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
- package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
- package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
- package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
- package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
- package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
- package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
- package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
- package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
- package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
- package/dashboard/dist/assets/index-reSbuley.css +1 -0
- package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
- package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
- package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
- package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
- package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
- package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
- package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
- package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
- package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
- package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
- package/dashboard/dist/favicon.svg +28 -0
- package/dashboard/dist/index.html +17 -0
- package/dist/agent/acp-client.js +261 -0
- package/dist/agent/auto-update.js +432 -0
- package/dist/agent/await-resume.js +50 -0
- package/dist/agent/cli/auth.js +325 -0
- package/dist/agent/cli/catalog.js +40 -0
- package/dist/agent/cli/detector.js +136 -0
- package/dist/agent/cli/index.js +7 -0
- package/dist/agent/cli/registry.js +33 -0
- package/dist/agent/driver.js +39 -0
- package/dist/agent/drivers/claude-tui.js +2297 -0
- package/dist/agent/drivers/claude.js +2689 -0
- package/dist/agent/drivers/codex.js +2210 -0
- package/dist/agent/drivers/gemini.js +1059 -0
- package/dist/agent/drivers/hermes.js +795 -0
- package/dist/agent/goal.js +274 -0
- package/dist/agent/handover.js +130 -0
- package/dist/agent/images.js +355 -0
- package/dist/agent/index.js +50 -0
- package/dist/agent/mcp/bridge.js +791 -0
- package/dist/agent/mcp/extensions.js +637 -0
- package/dist/agent/mcp/oauth.js +353 -0
- package/dist/agent/mcp/registry.js +119 -0
- package/dist/agent/mcp/session-server.js +229 -0
- package/dist/agent/mcp/tools/ask-user.js +113 -0
- package/dist/agent/mcp/tools/await-resume.js +77 -0
- package/dist/agent/mcp/tools/goal.js +144 -0
- package/dist/agent/mcp/tools/types.js +12 -0
- package/dist/agent/mcp/tools/workspace.js +212 -0
- package/dist/agent/npm.js +31 -0
- package/dist/agent/session.js +1206 -0
- package/dist/agent/skill-installer.js +160 -0
- package/dist/agent/skills.js +257 -0
- package/dist/agent/stream.js +743 -0
- package/dist/agent/types.js +13 -0
- package/dist/agent/utils.js +687 -0
- package/dist/bot/bot.js +2499 -0
- package/dist/bot/command-ui.js +633 -0
- package/dist/bot/commands.js +513 -0
- package/dist/bot/headless-bot.js +36 -0
- package/dist/bot/host.js +192 -0
- package/dist/bot/human-loop.js +168 -0
- package/dist/bot/menu.js +48 -0
- package/dist/bot/orchestration.js +79 -0
- package/dist/bot/render-shared.js +309 -0
- package/dist/bot/session-hub.js +361 -0
- package/dist/bot/session-status.js +55 -0
- package/dist/bot/streaming.js +309 -0
- package/dist/browser-profile.js +579 -0
- package/dist/browser-supervisor.js +249 -0
- package/dist/catalog/cli-tools.js +421 -0
- package/dist/catalog/index.js +21 -0
- package/dist/catalog/local-models.js +94 -0
- package/dist/catalog/mcp-servers.js +315 -0
- package/dist/catalog/skill-repos.js +173 -0
- package/dist/channels/base.js +55 -0
- package/dist/channels/dingtalk/bot.js +549 -0
- package/dist/channels/dingtalk/channel.js +268 -0
- package/dist/channels/discord/bot.js +552 -0
- package/dist/channels/discord/channel.js +245 -0
- package/dist/channels/feishu/bot.js +1275 -0
- package/dist/channels/feishu/channel.js +911 -0
- package/dist/channels/feishu/markdown.js +91 -0
- package/dist/channels/feishu/render.js +619 -0
- package/dist/channels/health.js +109 -0
- package/dist/channels/slack/bot.js +554 -0
- package/dist/channels/slack/channel.js +283 -0
- package/dist/channels/states.js +6 -0
- package/dist/channels/telegram/bot.js +1310 -0
- package/dist/channels/telegram/channel.js +820 -0
- package/dist/channels/telegram/directory.js +111 -0
- package/dist/channels/telegram/live-preview.js +220 -0
- package/dist/channels/telegram/render.js +384 -0
- package/dist/channels/wecom/bot.js +558 -0
- package/dist/channels/wecom/channel.js +479 -0
- package/dist/channels/weixin/api.js +520 -0
- package/dist/channels/weixin/bot.js +1000 -0
- package/dist/channels/weixin/channel.js +222 -0
- package/dist/cli/autostart.js +262 -0
- package/dist/cli/channel-supervisor.js +313 -0
- package/dist/cli/channels.js +54 -0
- package/dist/cli/main.js +726 -0
- package/dist/cli/onboarding.js +227 -0
- package/dist/cli/run.js +308 -0
- package/dist/cli/setup-wizard.js +235 -0
- package/dist/core/config/runtime-config.js +201 -0
- package/dist/core/config/user-config.js +510 -0
- package/dist/core/config/validation.js +521 -0
- package/dist/core/constants.js +400 -0
- package/dist/core/git.js +145 -0
- package/dist/core/legacy-compat.js +60 -0
- package/dist/core/logging.js +101 -0
- package/dist/core/platform.js +59 -0
- package/dist/core/process-control.js +315 -0
- package/dist/core/secrets/index.js +42 -0
- package/dist/core/secrets/inline-seal.js +60 -0
- package/dist/core/secrets/ref.js +33 -0
- package/dist/core/secrets/resolver.js +65 -0
- package/dist/core/secrets/store.js +63 -0
- package/dist/core/utils.js +233 -0
- package/dist/core/version.js +15 -0
- package/dist/dashboard/platform.js +219 -0
- package/dist/dashboard/routes/agents.js +450 -0
- package/dist/dashboard/routes/cli.js +174 -0
- package/dist/dashboard/routes/config.js +523 -0
- package/dist/dashboard/routes/extensions.js +745 -0
- package/dist/dashboard/routes/local-models.js +290 -0
- package/dist/dashboard/routes/models.js +324 -0
- package/dist/dashboard/routes/sessions.js +838 -0
- package/dist/dashboard/runtime.js +410 -0
- package/dist/dashboard/server.js +237 -0
- package/dist/dashboard/session-control.js +347 -0
- package/dist/model/catalog.js +104 -0
- package/dist/model/index.js +20 -0
- package/dist/model/injector.js +272 -0
- package/dist/model/provider-models.js +112 -0
- package/dist/model/store.js +212 -0
- package/dist/model/types.js +13 -0
- package/dist/model/validation.js +203 -0
- package/package.json +82 -0
package/dist/bot/bot.js
ADDED
|
@@ -0,0 +1,2499 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Bot base class: chat state, session lifecycle, task queue, streaming bridge.
|
|
3
|
+
*
|
|
4
|
+
* Channel-agnostic. Subclassed per IM channel (see channels/telegram/bot.ts, etc.).
|
|
5
|
+
*/
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { execSync, spawn } from 'node:child_process';
|
|
9
|
+
import { getActiveUserConfig, loadWorkspaces, onUserConfigChange, resolveUserWorkdir, setUserWorkdir, updateUserConfig } from '../core/config/user-config.js';
|
|
10
|
+
import { doStream, ensureManagedSession, findManagedThreadSession, getSessionStoredConfig, getUsage, initializeProjectSkills, listAgents, resolveAgentModels, resolveDefaultAgent, listSkills, stageSessionFiles, reconcileOrphanedRunningSessions, getAgentBoundModelId, setAgentBoundModelId, collapseSkillPrompt, readGoal, accountTurn, shouldContinueAfterTurn, renderContinuationPrompt, renderBudgetLimitPrompt, bumpContinuationCount, pauseGoal, resumeGoal, setGoal as setGoalState, clearGoal as clearGoalState, setCodexGoal, getCodexGoal, clearCodexGoal, pauseCodexGoal, resumeCodexGoal, getClaudeNativeGoal, buildClaudeSetGoalPrompt, buildClaudeClearGoalPrompt, isPendingSessionId, } from '../agent/index.js';
|
|
11
|
+
import { compactForHandover, describeHandoverRef } from '../agent/handover.js';
|
|
12
|
+
import { getActiveProfileId, setActiveProfile } from '../model/index.js';
|
|
13
|
+
import { querySessions, querySessionTail, updateSession, } from './session-hub.js';
|
|
14
|
+
import { getDriver, hasDriver, allDriverIds, getDriverCapabilities } from '../agent/driver.js';
|
|
15
|
+
import { resolveGuiIntegrationConfig } from '../agent/mcp/bridge.js';
|
|
16
|
+
import { terminateProcessTree } from '../core/process-control.js';
|
|
17
|
+
import { expandTilde } from '../core/platform.js';
|
|
18
|
+
import { VERSION } from '../core/version.js';
|
|
19
|
+
import { buildHumanLoopResponse, createEmptyHumanLoopAnswer, currentHumanLoopQuestion, isHumanLoopAwaitingText, setHumanLoopOption, setHumanLoopText, skipHumanLoopQuestion, summarizeResolvedHumanLoopAnswers, } from './human-loop.js';
|
|
20
|
+
import { writeScopedLog } from '../core/logging.js';
|
|
21
|
+
import { resolveAgentEffort, resolveAgentModel, resolveClaudeAccessMode, DEFAULT_CLAUDE_ACCESS_MODE, } from '../core/config/runtime-config.js';
|
|
22
|
+
import { envBool, envString, envInt, shellSplit, whichSync, fmtTokens, parseAllowedChatIds, ensureGitignore, } from '../core/utils.js';
|
|
23
|
+
import { getHostBatteryData, getHostCpuUsageData, getHostDisplayName, getHostMemoryUsageData, } from './host.js';
|
|
24
|
+
export { updateSession };
|
|
25
|
+
export { envBool, envString, envInt, shellSplit, whichSync, fmtTokens, fmtUptime, fmtBytes, parseAllowedChatIds, listSubdirs, extractThinkingTail, formatThinkingForDisplay, buildPrompt, ensureGitignore } from '../core/utils.js';
|
|
26
|
+
export { getHostBatteryData, getHostCpuUsageData, getHostDisplayName, getHostMemoryUsageData } from './host.js';
|
|
27
|
+
export { readGitStatus, formatGitStatusLine } from '../core/git.js';
|
|
28
|
+
import { BOT_TIMEOUTS } from '../core/constants.js';
|
|
29
|
+
export const DEFAULT_RUN_TIMEOUT_S = BOT_TIMEOUTS.defaultRunTimeoutS;
|
|
30
|
+
const MACOS_USER_ACTIVITY_PULSE_INTERVAL_MS = BOT_TIMEOUTS.macosUserActivityPulseInterval;
|
|
31
|
+
const MACOS_USER_ACTIVITY_PULSE_TIMEOUT_S = BOT_TIMEOUTS.macosUserActivityPulseTimeoutS;
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Helpers
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
export function normalizeAgent(raw) {
|
|
36
|
+
const v = raw.trim().toLowerCase();
|
|
37
|
+
if (!hasDriver(v))
|
|
38
|
+
throw new Error(`Invalid agent: ${v}. Use: ${allDriverIds().join(', ')}`);
|
|
39
|
+
return v;
|
|
40
|
+
}
|
|
41
|
+
export function thinkLabel(agent) {
|
|
42
|
+
try {
|
|
43
|
+
return getDriver(agent).thinkLabel;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return 'Thinking';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Prompt assembly helpers
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
function appendExtraPrompt(base, extra) {
|
|
53
|
+
const lhs = String(base || '').trim();
|
|
54
|
+
const rhs = String(extra || '').trim();
|
|
55
|
+
if (!lhs)
|
|
56
|
+
return rhs;
|
|
57
|
+
if (!rhs)
|
|
58
|
+
return lhs;
|
|
59
|
+
return `${lhs}\n\n${rhs}`;
|
|
60
|
+
}
|
|
61
|
+
function buildMcpDeliveryPrompt() {
|
|
62
|
+
return [
|
|
63
|
+
'[Artifact Return]',
|
|
64
|
+
'This is an IM/chat conversation, so pay attention to the IM tools.',
|
|
65
|
+
].join('\n');
|
|
66
|
+
}
|
|
67
|
+
function buildClaudeAskUserPrompt() {
|
|
68
|
+
// Claude is heavily trained on its built-in `AskUserQuestion` tool, so just
|
|
69
|
+
// registering `mcp__pikiloop__im_ask_user` alongside it isn't enough — the
|
|
70
|
+
// model still picks the native one, the CLI rejects it in -p mode with
|
|
71
|
+
// `is_error: true content: "Answer questions?"`, and the turn dies without
|
|
72
|
+
// ever firing the human-loop. This directive redirects calls *if* the model
|
|
73
|
+
// chooses to ask. It deliberately does not nudge the default ask-less
|
|
74
|
+
// behaviour — only the routing.
|
|
75
|
+
return [
|
|
76
|
+
'[Asking the user]',
|
|
77
|
+
'The built-in `AskUserQuestion` tool is disabled here and will fail. If you would otherwise call it, call `mcp__pikiloop__im_ask_user` instead — same intent (a question plus optional choices), it blocks until the user replies via the IM/dashboard channel. Default behaviour is unchanged: infer obvious decisions yourself and only ask when you genuinely cannot proceed.',
|
|
78
|
+
].join('\n');
|
|
79
|
+
}
|
|
80
|
+
function buildBrowserAutomationPrompt(browserEnabled) {
|
|
81
|
+
if (!browserEnabled) {
|
|
82
|
+
return [
|
|
83
|
+
'[Browser Automation]',
|
|
84
|
+
'Managed browser automation is disabled by default for this session.',
|
|
85
|
+
process.platform === 'darwin'
|
|
86
|
+
? 'On macOS, operate your main browser directly with native commands such as open, osascript, and screencapture when needed.'
|
|
87
|
+
: 'Use native OS or browser commands directly when browser automation is not enabled.',
|
|
88
|
+
].join('\n');
|
|
89
|
+
}
|
|
90
|
+
return [
|
|
91
|
+
'[Browser Automation]',
|
|
92
|
+
'A Playwright MCP browser server is already configured to use the local Chrome channel with a persistent profile.',
|
|
93
|
+
'Do not call browser_install unless a browser tool explicitly reports that Chrome or the browser is missing.',
|
|
94
|
+
'If you need a new tab, use browser_tabs with action="new".',
|
|
95
|
+
].join('\n');
|
|
96
|
+
}
|
|
97
|
+
function buildWorkflowOptInPrompt() {
|
|
98
|
+
// Standing opt-in injected only when the user explicitly enabled workflow
|
|
99
|
+
// orchestration for this agent. The Workflow tool is left enabled (not
|
|
100
|
+
// disallowed) in this mode; this directive tells the model it may reach for
|
|
101
|
+
// it proactively on genuinely large work, while preserving the default
|
|
102
|
+
// single-agent behaviour for everything else (no baseline regression).
|
|
103
|
+
return [
|
|
104
|
+
'[Multi-agent Workflow]',
|
|
105
|
+
'Workflow orchestration is enabled for this session. For substantial multi-step work — broad research, large refactors or audits, fan-out reviews across many files — you may proactively author and run a Workflow to decompose and parallelise it.',
|
|
106
|
+
'Keep it proportional: do NOT orchestrate trivial or single-file tasks. When a workflow would not add value, just answer directly as a single agent. Workflows can spawn many sub-agents and consume significant tokens, so reserve them for work whose scale genuinely warrants the fan-out.',
|
|
107
|
+
].join('\n');
|
|
108
|
+
}
|
|
109
|
+
function normalizeFromPikiloop(goal) {
|
|
110
|
+
return {
|
|
111
|
+
source: 'pikiloop',
|
|
112
|
+
objective: goal.objective,
|
|
113
|
+
status: goal.status,
|
|
114
|
+
tokenBudget: goal.tokenBudget,
|
|
115
|
+
tokensUsed: goal.tokensUsed,
|
|
116
|
+
timeUsedSeconds: goal.timeUsedSeconds,
|
|
117
|
+
continuationCount: goal.continuationCount,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function normalizeFromCodex(goal) {
|
|
121
|
+
return {
|
|
122
|
+
source: 'codex',
|
|
123
|
+
objective: goal.objective,
|
|
124
|
+
status: goal.status === 'budgetLimited' ? 'budget_limited' : goal.status,
|
|
125
|
+
tokenBudget: goal.tokenBudget,
|
|
126
|
+
tokensUsed: goal.tokensUsed,
|
|
127
|
+
timeUsedSeconds: goal.timeUsedSeconds,
|
|
128
|
+
continuationCount: null,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function normalizeFromClaudeNative(goal) {
|
|
132
|
+
return {
|
|
133
|
+
source: 'claude',
|
|
134
|
+
objective: goal.condition,
|
|
135
|
+
// Native /goal exposes no pause/budget — it's either active or absent.
|
|
136
|
+
status: 'active',
|
|
137
|
+
tokenBudget: null,
|
|
138
|
+
tokensUsed: 0,
|
|
139
|
+
timeUsedSeconds: 0,
|
|
140
|
+
continuationCount: null,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Bot
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
export class Bot {
|
|
147
|
+
workdir;
|
|
148
|
+
defaultAgent;
|
|
149
|
+
runTimeout;
|
|
150
|
+
allowedChatIds;
|
|
151
|
+
// Per-agent config — keyed by agent id
|
|
152
|
+
agentConfigs = {};
|
|
153
|
+
// Convenience accessors (backward-compat)
|
|
154
|
+
get codexModel() { return this.agentConfigs.codex?.model || ''; }
|
|
155
|
+
set codexModel(v) { this.agentConfigs.codex.model = v; }
|
|
156
|
+
get codexReasoningEffort() { return this.agentConfigs.codex?.reasoningEffort || 'xhigh'; }
|
|
157
|
+
set codexReasoningEffort(v) { this.agentConfigs.codex.reasoningEffort = v; }
|
|
158
|
+
get codexFullAccess() { return this.agentConfigs.codex?.fullAccess ?? true; }
|
|
159
|
+
get codexExtraArgs() { return this.agentConfigs.codex?.extraArgs || []; }
|
|
160
|
+
get claudeModel() { return this.agentConfigs.claude?.model || ''; }
|
|
161
|
+
set claudeModel(v) { this.agentConfigs.claude.model = v; }
|
|
162
|
+
get claudePermissionMode() { return this.agentConfigs.claude?.permissionMode || 'bypassPermissions'; }
|
|
163
|
+
get claudeExtraArgs() { return this.agentConfigs.claude?.extraArgs || []; }
|
|
164
|
+
get claudeWorkflowEnabled() { return this.agentConfigs.claude?.workflowEnabled ?? false; }
|
|
165
|
+
get claudeAccessMode() { return this.agentConfigs.claude?.accessMode || DEFAULT_CLAUDE_ACCESS_MODE; }
|
|
166
|
+
get geminiApprovalMode() { return this.agentConfigs.gemini?.approvalMode || 'yolo'; }
|
|
167
|
+
get geminiSandbox() { return this.agentConfigs.gemini?.sandbox ?? false; }
|
|
168
|
+
get geminiExtraArgs() { return this.agentConfigs.gemini?.extraArgs || []; }
|
|
169
|
+
chats = new Map();
|
|
170
|
+
sessionStates = new Map();
|
|
171
|
+
activeTasks = new Map();
|
|
172
|
+
startedAt = Date.now();
|
|
173
|
+
connected = false;
|
|
174
|
+
stats = { totalTurns: 0, totalInputTokens: 0, totalOutputTokens: 0, totalCachedTokens: 0 };
|
|
175
|
+
/* ── Dashboard stream state (polling-friendly snapshots) ── */
|
|
176
|
+
streamSnapshots = new Map();
|
|
177
|
+
snapshotCleanupTimers = new Map();
|
|
178
|
+
/** Maps promoted session keys (old → new) so poll endpoints can resolve pending IDs. */
|
|
179
|
+
promotedSessionKeys = new Map();
|
|
180
|
+
/** Reverse map (new → old[]) so pushSnapshotToSSE can broadcast on promoted-from aliases. */
|
|
181
|
+
promotedFromAliases = new Map();
|
|
182
|
+
/**
|
|
183
|
+
* Walk the promotion chain so callers passing a stale (pending or
|
|
184
|
+
* pre-rotation) key always resolve to the current canonical key for the same
|
|
185
|
+
* logical session. Multi-hop chains (pending → id_a → id_b after Claude
|
|
186
|
+
* `--resume` rotates twice) are followed end-to-end. Used by every
|
|
187
|
+
* sessionStates / streamSnapshots lookup so the rest of the codebase never
|
|
188
|
+
* has to special-case promotion.
|
|
189
|
+
*/
|
|
190
|
+
resolveSessionKey(sessionKey) {
|
|
191
|
+
let key = sessionKey;
|
|
192
|
+
const seen = new Set();
|
|
193
|
+
while (!seen.has(key)) {
|
|
194
|
+
const next = this.promotedSessionKeys.get(key);
|
|
195
|
+
if (!next || next === key)
|
|
196
|
+
break;
|
|
197
|
+
seen.add(key);
|
|
198
|
+
key = next;
|
|
199
|
+
}
|
|
200
|
+
return key;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Drop all promotion bookkeeping that pointed at a now-retired canonical key.
|
|
204
|
+
* `promotedFromAliases.get(key)` is exactly the set of stale keys whose forward
|
|
205
|
+
* `promotedSessionKeys` entries resolve to `key`, so clearing them here keeps
|
|
206
|
+
* that map from growing for the whole process lifetime (otherwise one entry
|
|
207
|
+
* leaks per new session + per Claude `--resume` rotation, never reclaimed).
|
|
208
|
+
*/
|
|
209
|
+
forgetPromotion(canonicalKey) {
|
|
210
|
+
const aliases = this.promotedFromAliases.get(canonicalKey);
|
|
211
|
+
if (aliases)
|
|
212
|
+
for (const alias of aliases)
|
|
213
|
+
this.promotedSessionKeys.delete(alias);
|
|
214
|
+
this.promotedFromAliases.delete(canonicalKey);
|
|
215
|
+
}
|
|
216
|
+
/** Get the current streaming snapshot for a session (used by polling endpoint).
|
|
217
|
+
* Follows the promotion chain so a pending or pre-rotation key still resolves. */
|
|
218
|
+
getStreamSnapshot(sessionKey) {
|
|
219
|
+
const snap = this.streamSnapshots.get(this.resolveSessionKey(sessionKey));
|
|
220
|
+
return snap ? this.enrichSnapshot(snap) : null;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Attach per-queued-task prompts and refresh interaction `currentIndex` from
|
|
224
|
+
* live prompt state — both are derived data the cached snapshot can't carry
|
|
225
|
+
* on its own (queued prompts come from RunningTask records; currentIndex
|
|
226
|
+
* advances asynchronously after select/skip/text without re-emitting the
|
|
227
|
+
* interaction event).
|
|
228
|
+
*/
|
|
229
|
+
enrichSnapshot(snap) {
|
|
230
|
+
let next = snap;
|
|
231
|
+
if (next.queuedTaskIds?.length) {
|
|
232
|
+
const queuedTasks = next.queuedTaskIds.map(taskId => {
|
|
233
|
+
const raw = this.activeTasks.get(taskId)?.prompt || '';
|
|
234
|
+
// Show `/skillname` instead of the long expansion we synthesized for the
|
|
235
|
+
// agent — matches what the user actually typed in the queued row.
|
|
236
|
+
return { taskId, prompt: collapseSkillPrompt(raw) ?? raw };
|
|
237
|
+
});
|
|
238
|
+
next = { ...next, queuedTasks };
|
|
239
|
+
}
|
|
240
|
+
if (next.interactions?.length) {
|
|
241
|
+
const refreshed = next.interactions.map(snapshotEntry => {
|
|
242
|
+
const live = this.humanLoopPrompts.get(snapshotEntry.promptId);
|
|
243
|
+
if (!live)
|
|
244
|
+
return snapshotEntry;
|
|
245
|
+
return { ...snapshotEntry, currentIndex: live.currentIndex };
|
|
246
|
+
});
|
|
247
|
+
next = { ...next, interactions: refreshed };
|
|
248
|
+
}
|
|
249
|
+
return next;
|
|
250
|
+
}
|
|
251
|
+
/* ── Dashboard SSE push (injected by dashboard layer to avoid circular import) ── */
|
|
252
|
+
_onStreamSnapshot = null;
|
|
253
|
+
streamPushTimers = new Map();
|
|
254
|
+
streamPushPending = new Map();
|
|
255
|
+
/** Called by the dashboard layer to subscribe to stream snapshot changes. */
|
|
256
|
+
onStreamSnapshot(cb) {
|
|
257
|
+
this._onStreamSnapshot = cb;
|
|
258
|
+
}
|
|
259
|
+
pushSnapshotToSSE(sessionKey, immediate) {
|
|
260
|
+
if (!this._onStreamSnapshot)
|
|
261
|
+
return;
|
|
262
|
+
const snap = this.streamSnapshots.get(sessionKey) ?? null;
|
|
263
|
+
const cb = this._onStreamSnapshot;
|
|
264
|
+
const emitAll = () => {
|
|
265
|
+
const enriched = snap ? this.enrichSnapshot(snap) : null;
|
|
266
|
+
cb(sessionKey, enriched);
|
|
267
|
+
// Also broadcast on promoted-from aliases so clients still listening
|
|
268
|
+
// on the old (pending) key receive updates after session promotion.
|
|
269
|
+
const aliases = this.promotedFromAliases.get(sessionKey);
|
|
270
|
+
if (aliases)
|
|
271
|
+
for (const alias of aliases)
|
|
272
|
+
cb(alias, enriched ? { ...enriched } : null);
|
|
273
|
+
};
|
|
274
|
+
if (immediate) {
|
|
275
|
+
const timer = this.streamPushTimers.get(sessionKey);
|
|
276
|
+
if (timer) {
|
|
277
|
+
clearTimeout(timer);
|
|
278
|
+
this.streamPushTimers.delete(sessionKey);
|
|
279
|
+
}
|
|
280
|
+
this.streamPushPending.delete(sessionKey);
|
|
281
|
+
emitAll();
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
// Coalesce: if a timer is pending, just mark dirty
|
|
285
|
+
this.streamPushPending.set(sessionKey, true);
|
|
286
|
+
if (this.streamPushTimers.has(sessionKey))
|
|
287
|
+
return;
|
|
288
|
+
this.streamPushTimers.set(sessionKey, setTimeout(() => {
|
|
289
|
+
this.streamPushTimers.delete(sessionKey);
|
|
290
|
+
if (this.streamPushPending.get(sessionKey)) {
|
|
291
|
+
this.streamPushPending.delete(sessionKey);
|
|
292
|
+
emitAll();
|
|
293
|
+
}
|
|
294
|
+
}, 80));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/** Emit a streaming event — updates the polling snapshot. */
|
|
298
|
+
emitStream(sessionKey, event) {
|
|
299
|
+
// Clear any pending cleanup timer
|
|
300
|
+
const pending = this.snapshotCleanupTimers.get(sessionKey);
|
|
301
|
+
if (pending) {
|
|
302
|
+
clearTimeout(pending);
|
|
303
|
+
this.snapshotCleanupTimers.delete(sessionKey);
|
|
304
|
+
}
|
|
305
|
+
const now = Date.now();
|
|
306
|
+
switch (event.type) {
|
|
307
|
+
case 'queued': {
|
|
308
|
+
const existing = this.streamSnapshots.get(sessionKey);
|
|
309
|
+
if (existing && (existing.phase === 'streaming' || existing.phase === 'done')) {
|
|
310
|
+
// Don't overwrite active stream — append to the queued list (deduped).
|
|
311
|
+
const list = existing.queuedTaskIds ? [...existing.queuedTaskIds] : [];
|
|
312
|
+
if (existing.taskId !== event.taskId && !list.includes(event.taskId))
|
|
313
|
+
list.push(event.taskId);
|
|
314
|
+
existing.queuedTaskIds = list.length ? list : undefined;
|
|
315
|
+
existing.updatedAt = now;
|
|
316
|
+
}
|
|
317
|
+
else if (existing && existing.phase === 'queued') {
|
|
318
|
+
// Already in queued phase with no active task — append additional queued IDs.
|
|
319
|
+
const list = existing.queuedTaskIds ? [...existing.queuedTaskIds] : [];
|
|
320
|
+
if (existing.taskId !== event.taskId && !list.includes(event.taskId))
|
|
321
|
+
list.push(event.taskId);
|
|
322
|
+
existing.queuedTaskIds = list.length ? list : undefined;
|
|
323
|
+
existing.updatedAt = now;
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
this.streamSnapshots.set(sessionKey, { phase: 'queued', taskId: event.taskId, updatedAt: now });
|
|
327
|
+
}
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
case 'start': {
|
|
331
|
+
// Preserve any tasks still queued behind the new active one. Drop the
|
|
332
|
+
// task that's now starting from that list since it has graduated.
|
|
333
|
+
const prev = this.streamSnapshots.get(sessionKey);
|
|
334
|
+
const remainingQueued = prev?.queuedTaskIds?.filter(id => id !== event.taskId);
|
|
335
|
+
this.streamSnapshots.set(sessionKey, {
|
|
336
|
+
phase: 'streaming', taskId: event.taskId,
|
|
337
|
+
text: '', thinking: '', activity: '', plan: null, sessionId: event.sessionId, updatedAt: now,
|
|
338
|
+
model: event.model, effort: event.effort, previewMeta: null,
|
|
339
|
+
startedAt: now,
|
|
340
|
+
queuedTaskIds: remainingQueued && remainingQueued.length ? remainingQueued : undefined,
|
|
341
|
+
});
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
case 'text': {
|
|
345
|
+
const snap = this.streamSnapshots.get(sessionKey);
|
|
346
|
+
if (snap) {
|
|
347
|
+
snap.text = event.text;
|
|
348
|
+
snap.thinking = event.thinking;
|
|
349
|
+
snap.activity = event.activity;
|
|
350
|
+
snap.plan = event.plan?.steps?.length ? event.plan : null;
|
|
351
|
+
if (event.previewMeta)
|
|
352
|
+
snap.previewMeta = event.previewMeta;
|
|
353
|
+
snap.updatedAt = now;
|
|
354
|
+
}
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
case 'done': {
|
|
358
|
+
const prev = this.streamSnapshots.get(sessionKey);
|
|
359
|
+
this.streamSnapshots.set(sessionKey, {
|
|
360
|
+
phase: 'done',
|
|
361
|
+
taskId: event.taskId,
|
|
362
|
+
sessionId: event.sessionId,
|
|
363
|
+
incomplete: !!event.incomplete,
|
|
364
|
+
text: prev?.text || '',
|
|
365
|
+
thinking: prev?.thinking || '',
|
|
366
|
+
activity: prev?.activity || '',
|
|
367
|
+
error: event.error,
|
|
368
|
+
plan: prev?.plan ?? null,
|
|
369
|
+
model: prev?.model ?? null,
|
|
370
|
+
effort: prev?.effort ?? null,
|
|
371
|
+
previewMeta: prev?.previewMeta ?? null,
|
|
372
|
+
startedAt: prev?.startedAt,
|
|
373
|
+
queuedTaskIds: prev?.queuedTaskIds,
|
|
374
|
+
updatedAt: now,
|
|
375
|
+
});
|
|
376
|
+
// Auto-clean 'done' snapshot after 30s so stale state doesn't linger.
|
|
377
|
+
// Extended from 10s to give clients time to pick up the final state
|
|
378
|
+
// after session promotion or WS reconnects.
|
|
379
|
+
this.snapshotCleanupTimers.set(sessionKey, setTimeout(() => {
|
|
380
|
+
this.streamSnapshots.delete(sessionKey);
|
|
381
|
+
this.snapshotCleanupTimers.delete(sessionKey);
|
|
382
|
+
this.forgetPromotion(sessionKey);
|
|
383
|
+
}, 30_000));
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
case 'cancelled': {
|
|
387
|
+
const snap = this.streamSnapshots.get(sessionKey);
|
|
388
|
+
if (!snap)
|
|
389
|
+
break;
|
|
390
|
+
if (snap.queuedTaskIds?.includes(event.taskId)) {
|
|
391
|
+
// Cancelled one of the queued-behind tasks — keep the running/done
|
|
392
|
+
// snapshot, just remove this entry from the list.
|
|
393
|
+
const next = snap.queuedTaskIds.filter(id => id !== event.taskId);
|
|
394
|
+
snap.queuedTaskIds = next.length ? next : undefined;
|
|
395
|
+
snap.updatedAt = now;
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
// Cancelled the currently displayed task — drop the whole snapshot.
|
|
399
|
+
this.streamSnapshots.delete(sessionKey);
|
|
400
|
+
this.forgetPromotion(sessionKey);
|
|
401
|
+
}
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
case 'interaction': {
|
|
405
|
+
const snap = this.streamSnapshots.get(sessionKey);
|
|
406
|
+
if (snap) {
|
|
407
|
+
const list = snap.interactions || [];
|
|
408
|
+
list.push(event.interaction);
|
|
409
|
+
snap.interactions = list;
|
|
410
|
+
snap.updatedAt = now;
|
|
411
|
+
}
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
case 'interaction-resolved': {
|
|
415
|
+
const snap = this.streamSnapshots.get(sessionKey);
|
|
416
|
+
if (snap?.interactions) {
|
|
417
|
+
snap.interactions = snap.interactions.filter(i => i.promptId !== event.promptId);
|
|
418
|
+
if (!snap.interactions.length)
|
|
419
|
+
delete snap.interactions;
|
|
420
|
+
snap.updatedAt = now;
|
|
421
|
+
}
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// Push to dashboard SSE — throttle text events, push everything else immediately
|
|
426
|
+
try {
|
|
427
|
+
this.pushSnapshotToSSE(sessionKey, event.type !== 'text');
|
|
428
|
+
}
|
|
429
|
+
catch { /* dashboard not loaded yet — ignore */ }
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Stream-lifecycle helpers. The dashboard mirrors a running turn by reading
|
|
433
|
+
* `streamSnapshots`, which is built exclusively from `emitStream` calls.
|
|
434
|
+
* IM channels run `runStream` directly (not via `submitSessionTask`), so
|
|
435
|
+
* without these calls the dashboard never sees IM-initiated turns. Routing
|
|
436
|
+
* every IM handler through these helpers (and refactoring submitSessionTask
|
|
437
|
+
* to use them) keeps the two surfaces consistent: each side can observe
|
|
438
|
+
* whatever the other side started.
|
|
439
|
+
*
|
|
440
|
+
* Each helper resolves the live `task.sessionKey` so the event lands on the
|
|
441
|
+
* current snapshot after a pending→native session id promotion.
|
|
442
|
+
*/
|
|
443
|
+
liveSessionKey(taskId, fallback) {
|
|
444
|
+
return this.activeTasks.get(taskId)?.sessionKey || fallback;
|
|
445
|
+
}
|
|
446
|
+
emitStreamQueued(sessionKey, taskId) {
|
|
447
|
+
this.emitStream(sessionKey, { type: 'queued', taskId, position: this.getQueuePosition(sessionKey, taskId) });
|
|
448
|
+
}
|
|
449
|
+
emitStreamStart(taskId, session) {
|
|
450
|
+
const cfg = this.resolveSessionStreamConfig(session);
|
|
451
|
+
const key = this.liveSessionKey(taskId, session.key);
|
|
452
|
+
this.debug(`[stream-lifecycle] start task=${taskId} key=${key} sessionId=${session.sessionId || '(pending)'} model=${cfg.model || '-'}`);
|
|
453
|
+
this.emitStream(key, {
|
|
454
|
+
type: 'start', taskId, agent: session.agent, sessionId: session.sessionId,
|
|
455
|
+
model: cfg.model, effort: cfg.effort,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
emitStreamText(taskId, fallbackKey, text, thinking, activity = '', meta, plan) {
|
|
459
|
+
const key = this.liveSessionKey(taskId, fallbackKey);
|
|
460
|
+
const snap = this.streamSnapshots.get(key);
|
|
461
|
+
this.debug(`[stream-lifecycle] text task=${taskId} key=${key} bytes=${text.length}/${thinking.length} snap=${snap ? snap.phase : 'NONE'}`);
|
|
462
|
+
this.emitStream(key, {
|
|
463
|
+
type: 'text', text, thinking, activity, plan: plan ?? null, previewMeta: meta ?? null,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
emitStreamDone(taskId, fallbackKey, opts) {
|
|
467
|
+
const key = this.liveSessionKey(taskId, fallbackKey);
|
|
468
|
+
this.debug(`[stream-lifecycle] done task=${taskId} key=${key} sessionId=${opts.sessionId || '(none)'} incomplete=${opts.incomplete}`);
|
|
469
|
+
this.emitStream(key, {
|
|
470
|
+
type: 'done', taskId,
|
|
471
|
+
sessionId: opts.sessionId,
|
|
472
|
+
incomplete: opts.incomplete,
|
|
473
|
+
...(opts.error ? { error: opts.error } : {}),
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
emitStreamCancelled(taskId, fallbackKey) {
|
|
477
|
+
this.emitStream(this.liveSessionKey(taskId, fallbackKey), { type: 'cancelled', taskId });
|
|
478
|
+
}
|
|
479
|
+
keepAliveProc = null;
|
|
480
|
+
keepAlivePulseTimer = null;
|
|
481
|
+
sessionChains = new Map();
|
|
482
|
+
userConfigUnsubscribe = null;
|
|
483
|
+
taskKeysBySourceMessage = new Map();
|
|
484
|
+
taskKeysByActionId = new Map();
|
|
485
|
+
withdrawnSourceMessages = new Set();
|
|
486
|
+
nextTaskActionId = 1;
|
|
487
|
+
humanLoopPrompts = new Map();
|
|
488
|
+
humanLoopPromptIdsByChat = new Map();
|
|
489
|
+
nextHumanLoopPromptId = 1;
|
|
490
|
+
constructor() {
|
|
491
|
+
this.workdir = resolveUserWorkdir();
|
|
492
|
+
ensureGitignore(this.workdir);
|
|
493
|
+
initializeProjectSkills(this.workdir);
|
|
494
|
+
const config = getActiveUserConfig();
|
|
495
|
+
// Initialize per-agent configs
|
|
496
|
+
this.agentConfigs = {
|
|
497
|
+
codex: {
|
|
498
|
+
model: resolveAgentModel(config, 'codex'),
|
|
499
|
+
reasoningEffort: resolveAgentEffort(config, 'codex') || 'xhigh',
|
|
500
|
+
fullAccess: envBool('CODEX_FULL_ACCESS', true),
|
|
501
|
+
extraArgs: shellSplit(process.env.CODEX_EXTRA_ARGS || ''),
|
|
502
|
+
},
|
|
503
|
+
claude: {
|
|
504
|
+
model: resolveAgentModel(config, 'claude'),
|
|
505
|
+
reasoningEffort: resolveAgentEffort(config, 'claude') || 'high',
|
|
506
|
+
permissionMode: (process.env.CLAUDE_PERMISSION_MODE || 'bypassPermissions').trim(),
|
|
507
|
+
// Workflow orchestration is a per-session/per-turn choice (composer
|
|
508
|
+
// toggle / IM /mode), never a persisted default — always boot off.
|
|
509
|
+
workflowEnabled: false,
|
|
510
|
+
// Access mode (TUI subscription vs `claude -p` Agent SDK credits) IS a
|
|
511
|
+
// persisted preference — hydrate it from config so the boot value
|
|
512
|
+
// matches the dashboard toggle / env default.
|
|
513
|
+
accessMode: resolveClaudeAccessMode(config),
|
|
514
|
+
extraArgs: shellSplit(process.env.CLAUDE_EXTRA_ARGS || ''),
|
|
515
|
+
},
|
|
516
|
+
gemini: {
|
|
517
|
+
model: resolveAgentModel(config, 'gemini'),
|
|
518
|
+
approvalMode: envString('GEMINI_APPROVAL_MODE', 'yolo'),
|
|
519
|
+
sandbox: envBool('GEMINI_SANDBOX', false),
|
|
520
|
+
extraArgs: shellSplit(process.env.GEMINI_EXTRA_ARGS || ''),
|
|
521
|
+
},
|
|
522
|
+
// Hermes was missing from this map for a long time. Without an entry,
|
|
523
|
+
// `modelForAgent('hermes')` returned '' and `setModelForAgent('hermes',
|
|
524
|
+
// ...)` silently no-op'd because `if (config)` short-circuited — so any
|
|
525
|
+
// /models switch in IM looked successful in the log but never reached
|
|
526
|
+
// the hermes driver. Adding the entry lets the same machinery the other
|
|
527
|
+
// three agents already rely on apply to hermes too.
|
|
528
|
+
hermes: {
|
|
529
|
+
model: resolveAgentModel(config, 'hermes'),
|
|
530
|
+
reasoningEffort: resolveAgentEffort(config, 'hermes') || 'medium',
|
|
531
|
+
extraArgs: shellSplit(process.env.HERMES_EXTRA_ARGS || ''),
|
|
532
|
+
},
|
|
533
|
+
};
|
|
534
|
+
this.defaultAgent = normalizeAgent('codex');
|
|
535
|
+
this.runTimeout = envInt('PIKILOOP_TIMEOUT', DEFAULT_RUN_TIMEOUT_S);
|
|
536
|
+
this.allowedChatIds = parseAllowedChatIds(process.env.PIKILOOP_ALLOWED_IDS || '');
|
|
537
|
+
this.refreshManagedConfig(getActiveUserConfig(), { initial: true });
|
|
538
|
+
this.userConfigUnsubscribe = onUserConfigChange(config => this.refreshManagedConfig(config));
|
|
539
|
+
}
|
|
540
|
+
log(msg, level = 'info') {
|
|
541
|
+
writeScopedLog('pikiloop', msg, { level });
|
|
542
|
+
}
|
|
543
|
+
debug(msg) {
|
|
544
|
+
this.log(msg, 'debug');
|
|
545
|
+
}
|
|
546
|
+
warn(msg) {
|
|
547
|
+
this.log(msg, 'warn');
|
|
548
|
+
}
|
|
549
|
+
error(msg) {
|
|
550
|
+
this.log(msg, 'error');
|
|
551
|
+
}
|
|
552
|
+
chat(chatId) {
|
|
553
|
+
let s = this.chats.get(chatId);
|
|
554
|
+
if (!s) {
|
|
555
|
+
s = { agent: this.defaultAgent, sessionId: null, activeSessionKey: null, activeThreadId: null, modelId: null };
|
|
556
|
+
this.chats.set(chatId, s);
|
|
557
|
+
}
|
|
558
|
+
return s;
|
|
559
|
+
}
|
|
560
|
+
/** Effective workdir for a chat — per-chat override or global fallback. */
|
|
561
|
+
chatWorkdir(chatId) {
|
|
562
|
+
return this.chats.get(chatId)?.workdir || this.workdir;
|
|
563
|
+
}
|
|
564
|
+
sessionKey(agent, sessionId) {
|
|
565
|
+
return `${agent}:${sessionId}`;
|
|
566
|
+
}
|
|
567
|
+
getSessionRuntimeByKey(sessionKey, opts = {}) {
|
|
568
|
+
if (!sessionKey)
|
|
569
|
+
return null;
|
|
570
|
+
const runtime = this.sessionStates.get(this.resolveSessionKey(sessionKey)) || null;
|
|
571
|
+
if (!runtime)
|
|
572
|
+
return null;
|
|
573
|
+
if (!opts.allowAnyWorkdir && runtime.workdir !== this.workdir)
|
|
574
|
+
return null;
|
|
575
|
+
return runtime;
|
|
576
|
+
}
|
|
577
|
+
getSelectedSession(cs) {
|
|
578
|
+
return this.getSessionRuntimeByKey(cs.activeSessionKey, { allowAnyWorkdir: true });
|
|
579
|
+
}
|
|
580
|
+
hydrateSessionRuntime(session) {
|
|
581
|
+
if (!session.sessionId)
|
|
582
|
+
return null;
|
|
583
|
+
return this.upsertSessionRuntime({
|
|
584
|
+
agent: session.agent,
|
|
585
|
+
sessionId: session.sessionId,
|
|
586
|
+
workdir: session.workdir || this.workdir,
|
|
587
|
+
workspacePath: session.workspacePath ?? null,
|
|
588
|
+
threadId: session.threadId ?? null,
|
|
589
|
+
codexCumulative: session.codexCumulative,
|
|
590
|
+
modelId: session.modelId ?? null,
|
|
591
|
+
thinkingEffort: session.thinkingEffort ?? null,
|
|
592
|
+
handoverFrom: session.handoverFrom ?? null,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
upsertSessionRuntime(session) {
|
|
596
|
+
const workdir = path.resolve(session.workdir || this.workdir);
|
|
597
|
+
const requestedKey = this.sessionKey(session.agent, session.sessionId);
|
|
598
|
+
// Follow the promotion chain. Without this, an insertion that races a
|
|
599
|
+
// pending→native promotion would `new` a phantom runtime under the stale
|
|
600
|
+
// pending key and the queued message would land in a session the dashboard
|
|
601
|
+
// can never reach again.
|
|
602
|
+
const resolvedKey = this.resolveSessionKey(requestedKey);
|
|
603
|
+
const existing = this.sessionStates.get(resolvedKey);
|
|
604
|
+
if (existing) {
|
|
605
|
+
existing.workdir = workdir;
|
|
606
|
+
// Do NOT overwrite agent/sessionId/key here — the existing record IS the
|
|
607
|
+
// canonical identity post-promotion. Letting upserts re-stamp the old
|
|
608
|
+
// pending id back over the native id would unwind the promotion.
|
|
609
|
+
if (session.workspacePath !== undefined)
|
|
610
|
+
existing.workspacePath = session.workspacePath ?? null;
|
|
611
|
+
if (session.threadId !== undefined)
|
|
612
|
+
existing.threadId = session.threadId ?? null;
|
|
613
|
+
if (session.codexCumulative !== undefined)
|
|
614
|
+
existing.codexCumulative = session.codexCumulative;
|
|
615
|
+
if (session.modelId !== undefined)
|
|
616
|
+
existing.modelId = session.modelId ?? null;
|
|
617
|
+
if (session.thinkingEffort !== undefined)
|
|
618
|
+
existing.thinkingEffort = session.thinkingEffort ?? null;
|
|
619
|
+
// handoverFrom is one-shot: only set if not already set (the first staging wins).
|
|
620
|
+
if (session.handoverFrom !== undefined && !existing.handoverFrom) {
|
|
621
|
+
existing.handoverFrom = session.handoverFrom;
|
|
622
|
+
}
|
|
623
|
+
return existing;
|
|
624
|
+
}
|
|
625
|
+
const runtime = {
|
|
626
|
+
key: requestedKey,
|
|
627
|
+
workdir,
|
|
628
|
+
agent: session.agent,
|
|
629
|
+
sessionId: session.sessionId,
|
|
630
|
+
workspacePath: session.workspacePath ?? null,
|
|
631
|
+
threadId: session.threadId ?? null,
|
|
632
|
+
codexCumulative: session.codexCumulative,
|
|
633
|
+
modelId: session.modelId ?? null,
|
|
634
|
+
thinkingEffort: session.thinkingEffort ?? null,
|
|
635
|
+
runningTaskIds: new Set(),
|
|
636
|
+
handoverFrom: session.handoverFrom ?? null,
|
|
637
|
+
};
|
|
638
|
+
this.sessionStates.set(requestedKey, runtime);
|
|
639
|
+
return runtime;
|
|
640
|
+
}
|
|
641
|
+
applySessionSelection(cs, session, opts = {}) {
|
|
642
|
+
const previousSessionKey = cs.activeSessionKey ?? null;
|
|
643
|
+
cs.activeSessionKey = session?.key ?? null;
|
|
644
|
+
if (session) {
|
|
645
|
+
cs.agent = session.agent;
|
|
646
|
+
cs.sessionId = session.sessionId;
|
|
647
|
+
cs.workspacePath = session.workspacePath;
|
|
648
|
+
cs.activeThreadId = session.threadId;
|
|
649
|
+
cs.codexCumulative = session.codexCumulative;
|
|
650
|
+
cs.modelId = session.modelId ?? null;
|
|
651
|
+
cs.workdir = session.workdir;
|
|
652
|
+
if (previousSessionKey && previousSessionKey !== session.key)
|
|
653
|
+
this.maybeEvictSessionRuntime(previousSessionKey);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
cs.sessionId = null;
|
|
657
|
+
cs.workspacePath = null;
|
|
658
|
+
if (!opts.preserveThread)
|
|
659
|
+
cs.activeThreadId = null;
|
|
660
|
+
cs.codexCumulative = undefined;
|
|
661
|
+
cs.modelId = null;
|
|
662
|
+
if (previousSessionKey)
|
|
663
|
+
this.maybeEvictSessionRuntime(previousSessionKey);
|
|
664
|
+
}
|
|
665
|
+
resetChatConversation(cs, opts) {
|
|
666
|
+
this.applySessionSelection(cs, null, { preserveThread: opts?.clearThread === false });
|
|
667
|
+
if (opts?.clearWorkdir)
|
|
668
|
+
cs.workdir = null;
|
|
669
|
+
}
|
|
670
|
+
adoptSession(cs, session) {
|
|
671
|
+
if (!session.sessionId) {
|
|
672
|
+
this.applySessionSelection(cs, null);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
const managed = ensureManagedSession({
|
|
676
|
+
agent: session.agent,
|
|
677
|
+
sessionId: session.sessionId,
|
|
678
|
+
workdir: 'workdir' in session && session.workdir ? session.workdir : this.workdir,
|
|
679
|
+
title: session.title ?? null,
|
|
680
|
+
model: session.model ?? null,
|
|
681
|
+
thinkingEffort: session.thinkingEffort ?? null,
|
|
682
|
+
profileId: session.profileId ?? null,
|
|
683
|
+
threadId: session.threadId ?? null,
|
|
684
|
+
});
|
|
685
|
+
const runtime = this.hydrateSessionRuntime({
|
|
686
|
+
agent: session.agent,
|
|
687
|
+
sessionId: session.sessionId,
|
|
688
|
+
workdir: 'workdir' in session ? session.workdir : null,
|
|
689
|
+
workspacePath: managed.workspacePath ?? session.workspacePath ?? null,
|
|
690
|
+
threadId: managed.threadId ?? session.threadId ?? null,
|
|
691
|
+
modelId: session.model ?? managed.model ?? null,
|
|
692
|
+
thinkingEffort: session.thinkingEffort ?? managed.thinkingEffort ?? null,
|
|
693
|
+
});
|
|
694
|
+
if (!runtime) {
|
|
695
|
+
this.applySessionSelection(cs, null);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
// Adopting an existing session is an explicit user pick — drop any
|
|
699
|
+
// queued handover from a prior agent toggle so we don't accidentally
|
|
700
|
+
// prepend the wrong context to the resumed session's next turn.
|
|
701
|
+
cs.pendingHandoverFrom = null;
|
|
702
|
+
this.applySessionSelection(cs, runtime);
|
|
703
|
+
}
|
|
704
|
+
syncSelectedChats(session) {
|
|
705
|
+
for (const [, cs] of this.chats) {
|
|
706
|
+
if (cs.activeSessionKey !== session.key)
|
|
707
|
+
continue;
|
|
708
|
+
this.applySessionSelection(cs, session);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
moveSessionStreamSnapshot(previousKey, nextKey) {
|
|
712
|
+
if (!previousKey || !nextKey || previousKey === nextKey)
|
|
713
|
+
return;
|
|
714
|
+
const previousSnapshot = this.streamSnapshots.get(previousKey) || null;
|
|
715
|
+
const nextSnapshot = this.streamSnapshots.get(nextKey) || null;
|
|
716
|
+
const mergedSnapshot = previousSnapshot && (!nextSnapshot || previousSnapshot.updatedAt >= nextSnapshot.updatedAt)
|
|
717
|
+
? previousSnapshot
|
|
718
|
+
: nextSnapshot;
|
|
719
|
+
this.streamSnapshots.delete(previousKey);
|
|
720
|
+
if (mergedSnapshot)
|
|
721
|
+
this.streamSnapshots.set(nextKey, mergedSnapshot);
|
|
722
|
+
const previousTimer = this.snapshotCleanupTimers.get(previousKey);
|
|
723
|
+
if (previousTimer) {
|
|
724
|
+
clearTimeout(previousTimer);
|
|
725
|
+
this.snapshotCleanupTimers.delete(previousKey);
|
|
726
|
+
if (mergedSnapshot?.phase === 'done') {
|
|
727
|
+
this.snapshotCleanupTimers.set(nextKey, setTimeout(() => {
|
|
728
|
+
this.streamSnapshots.delete(nextKey);
|
|
729
|
+
this.snapshotCleanupTimers.delete(nextKey);
|
|
730
|
+
}, 10_000));
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
promoteSessionRuntime(session, nextSessionId) {
|
|
735
|
+
const resolvedSessionId = nextSessionId.trim();
|
|
736
|
+
if (!resolvedSessionId || session.sessionId === resolvedSessionId)
|
|
737
|
+
return session;
|
|
738
|
+
const previousKey = session.key;
|
|
739
|
+
const previousSessionId = session.sessionId;
|
|
740
|
+
const nextKey = this.sessionKey(session.agent, resolvedSessionId);
|
|
741
|
+
const existing = this.sessionStates.get(nextKey);
|
|
742
|
+
if (existing && existing !== session) {
|
|
743
|
+
session.workspacePath = session.workspacePath ?? existing.workspacePath;
|
|
744
|
+
session.threadId = session.threadId ?? existing.threadId;
|
|
745
|
+
session.codexCumulative = session.codexCumulative ?? existing.codexCumulative;
|
|
746
|
+
session.modelId = session.modelId ?? existing.modelId ?? null;
|
|
747
|
+
session.thinkingEffort = session.thinkingEffort ?? existing.thinkingEffort ?? null;
|
|
748
|
+
for (const taskId of existing.runningTaskIds)
|
|
749
|
+
session.runningTaskIds.add(taskId);
|
|
750
|
+
}
|
|
751
|
+
this.sessionStates.delete(previousKey);
|
|
752
|
+
this.sessionStates.delete(nextKey);
|
|
753
|
+
session.sessionId = resolvedSessionId;
|
|
754
|
+
session.key = nextKey;
|
|
755
|
+
this.sessionStates.set(nextKey, session);
|
|
756
|
+
for (const [, task] of this.activeTasks) {
|
|
757
|
+
if (task.sessionKey === previousKey)
|
|
758
|
+
task.sessionKey = nextKey;
|
|
759
|
+
}
|
|
760
|
+
const previousChain = this.sessionChains.get(previousKey);
|
|
761
|
+
const nextChain = this.sessionChains.get(nextKey);
|
|
762
|
+
if (previousChain)
|
|
763
|
+
this.sessionChains.delete(previousKey);
|
|
764
|
+
if (previousChain || nextChain) {
|
|
765
|
+
const mergedChain = previousChain && nextChain && previousChain !== nextChain
|
|
766
|
+
? Promise.allSettled([previousChain, nextChain]).then(() => { })
|
|
767
|
+
: (previousChain || nextChain);
|
|
768
|
+
this.sessionChains.set(nextKey, mergedChain);
|
|
769
|
+
}
|
|
770
|
+
this.moveSessionStreamSnapshot(previousKey, nextKey);
|
|
771
|
+
// Track promotion so poll endpoints + insertions can resolve pending →
|
|
772
|
+
// native. When the chain hops more than once (Claude `--resume` rotating
|
|
773
|
+
// session ids back-to-back), pull ancestor aliases forward AND re-point
|
|
774
|
+
// them at the latest key so a single lookup is O(1) and every WS listener
|
|
775
|
+
// that subscribed to any earlier key still receives updates.
|
|
776
|
+
this.promotedSessionKeys.set(previousKey, nextKey);
|
|
777
|
+
const aliases = new Set(this.promotedFromAliases.get(nextKey) || []);
|
|
778
|
+
aliases.add(previousKey);
|
|
779
|
+
const ancestorAliases = this.promotedFromAliases.get(previousKey);
|
|
780
|
+
if (ancestorAliases) {
|
|
781
|
+
for (const alias of ancestorAliases) {
|
|
782
|
+
aliases.add(alias);
|
|
783
|
+
this.promotedSessionKeys.set(alias, nextKey);
|
|
784
|
+
}
|
|
785
|
+
this.promotedFromAliases.delete(previousKey);
|
|
786
|
+
}
|
|
787
|
+
this.promotedFromAliases.set(nextKey, [...aliases]);
|
|
788
|
+
// Update the promoted snapshot's sessionId to reflect the native ID
|
|
789
|
+
const promotedSnap = this.streamSnapshots.get(nextKey);
|
|
790
|
+
if (promotedSnap)
|
|
791
|
+
promotedSnap.sessionId = resolvedSessionId;
|
|
792
|
+
// Notify dashboard clients still tracking the old (pending) key via SSE
|
|
793
|
+
// so they can detect the promotion and navigate to the correct session
|
|
794
|
+
if (this._onStreamSnapshot && promotedSnap) {
|
|
795
|
+
this._onStreamSnapshot(previousKey, this.enrichSnapshot(promotedSnap));
|
|
796
|
+
}
|
|
797
|
+
for (const [, cs] of this.chats) {
|
|
798
|
+
const matchesPreviousSelection = cs.activeSessionKey === previousKey;
|
|
799
|
+
const matchesNextSelection = cs.activeSessionKey === nextKey;
|
|
800
|
+
const matchesSessionId = cs.agent === session.agent && ((previousSessionId ? cs.sessionId === previousSessionId : false)
|
|
801
|
+
|| cs.sessionId === resolvedSessionId);
|
|
802
|
+
if (!matchesPreviousSelection && !matchesNextSelection && !matchesSessionId)
|
|
803
|
+
continue;
|
|
804
|
+
this.applySessionSelection(cs, session);
|
|
805
|
+
}
|
|
806
|
+
return session;
|
|
807
|
+
}
|
|
808
|
+
isSessionSelected(sessionKey) {
|
|
809
|
+
if (!sessionKey)
|
|
810
|
+
return false;
|
|
811
|
+
for (const [, cs] of this.chats) {
|
|
812
|
+
if (cs.activeSessionKey === sessionKey)
|
|
813
|
+
return true;
|
|
814
|
+
}
|
|
815
|
+
return false;
|
|
816
|
+
}
|
|
817
|
+
maybeEvictSessionRuntime(sessionKey) {
|
|
818
|
+
const session = this.getSessionRuntimeByKey(sessionKey, { allowAnyWorkdir: true });
|
|
819
|
+
if (!session)
|
|
820
|
+
return;
|
|
821
|
+
if (session.runningTaskIds.size)
|
|
822
|
+
return;
|
|
823
|
+
if (session.workdir === this.workdir)
|
|
824
|
+
return;
|
|
825
|
+
if (this.isSessionSelected(session.key))
|
|
826
|
+
return;
|
|
827
|
+
this.sessionStates.delete(session.key);
|
|
828
|
+
}
|
|
829
|
+
findThreadSessionRuntime(chatId, threadId, agent) {
|
|
830
|
+
if (!threadId)
|
|
831
|
+
return null;
|
|
832
|
+
const managed = findManagedThreadSession(this.chatWorkdir(chatId), threadId, agent);
|
|
833
|
+
if (!managed?.sessionId)
|
|
834
|
+
return null;
|
|
835
|
+
return this.hydrateSessionRuntime({
|
|
836
|
+
agent: managed.agent,
|
|
837
|
+
sessionId: managed.sessionId,
|
|
838
|
+
workdir: managed.workdir || this.chatWorkdir(chatId),
|
|
839
|
+
workspacePath: managed.workspacePath ?? null,
|
|
840
|
+
threadId: managed.threadId ?? threadId,
|
|
841
|
+
modelId: managed.model ?? null,
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
ensureSessionForChat(chatId, title, files) {
|
|
845
|
+
const cs = this.chat(chatId);
|
|
846
|
+
const selected = this.getSelectedSession(cs);
|
|
847
|
+
if (selected)
|
|
848
|
+
return selected;
|
|
849
|
+
// Auto-resume an existing same-thread session of this agent (back-and-forth
|
|
850
|
+
// toggling). The handover queued on `cs.pendingHandoverFrom` is intentionally
|
|
851
|
+
// dropped here — the resumed session already has its own history; replaying
|
|
852
|
+
// an external handover on top would just be duplicate context.
|
|
853
|
+
const resumed = this.findThreadSessionRuntime(chatId, cs.activeThreadId, cs.agent);
|
|
854
|
+
if (resumed) {
|
|
855
|
+
cs.pendingHandoverFrom = null;
|
|
856
|
+
this.applySessionSelection(cs, resumed);
|
|
857
|
+
return resumed;
|
|
858
|
+
}
|
|
859
|
+
const wd = this.chatWorkdir(chatId);
|
|
860
|
+
const handoverFrom = cs.pendingHandoverFrom ?? null;
|
|
861
|
+
cs.pendingHandoverFrom = null;
|
|
862
|
+
const staged = stageSessionFiles({
|
|
863
|
+
agent: cs.agent,
|
|
864
|
+
workdir: wd,
|
|
865
|
+
files: [],
|
|
866
|
+
sessionId: null,
|
|
867
|
+
title: title || 'New session',
|
|
868
|
+
threadId: cs.activeThreadId ?? null,
|
|
869
|
+
handoverFrom,
|
|
870
|
+
});
|
|
871
|
+
const runtime = this.upsertSessionRuntime({
|
|
872
|
+
agent: cs.agent,
|
|
873
|
+
sessionId: staged.sessionId,
|
|
874
|
+
workspacePath: staged.workspacePath,
|
|
875
|
+
threadId: staged.threadId,
|
|
876
|
+
modelId: this.modelForAgent(cs.agent),
|
|
877
|
+
thinkingEffort: this.effortForAgent(cs.agent),
|
|
878
|
+
handoverFrom: staged.handoverFrom,
|
|
879
|
+
});
|
|
880
|
+
this.applySessionSelection(cs, runtime);
|
|
881
|
+
return runtime;
|
|
882
|
+
}
|
|
883
|
+
beginTask(task) {
|
|
884
|
+
const nextTask = {
|
|
885
|
+
...task,
|
|
886
|
+
actionId: task.actionId || `t${(this.nextTaskActionId++).toString(36)}`,
|
|
887
|
+
status: 'queued',
|
|
888
|
+
cancelled: false,
|
|
889
|
+
abort: null,
|
|
890
|
+
placeholderMessageIds: [...(task.placeholderMessageIds || [])],
|
|
891
|
+
};
|
|
892
|
+
this.activeTasks.set(nextTask.taskId, nextTask);
|
|
893
|
+
this.taskKeysBySourceMessage.set(this.sourceMessageKey(task.chatId, task.sourceMessageId), nextTask.taskId);
|
|
894
|
+
this.taskKeysByActionId.set(String(nextTask.actionId), nextTask.taskId);
|
|
895
|
+
const session = this.getSessionRuntimeByKey(task.sessionKey, { allowAnyWorkdir: true });
|
|
896
|
+
session?.runningTaskIds.add(nextTask.taskId);
|
|
897
|
+
}
|
|
898
|
+
finishTask(taskId) {
|
|
899
|
+
for (const prompt of [...this.humanLoopPrompts.values()]) {
|
|
900
|
+
if (prompt.taskId !== taskId)
|
|
901
|
+
continue;
|
|
902
|
+
this.clearHumanLoopPrompt(prompt.promptId, new Error('Task finished before prompt was answered.'));
|
|
903
|
+
}
|
|
904
|
+
const task = this.activeTasks.get(taskId);
|
|
905
|
+
if (!task)
|
|
906
|
+
return;
|
|
907
|
+
this.activeTasks.delete(taskId);
|
|
908
|
+
this.taskKeysBySourceMessage.delete(this.sourceMessageKey(task.chatId, task.sourceMessageId));
|
|
909
|
+
if (task.actionId)
|
|
910
|
+
this.taskKeysByActionId.delete(String(task.actionId));
|
|
911
|
+
this.withdrawnSourceMessages.delete(this.sourceMessageKey(task.chatId, task.sourceMessageId));
|
|
912
|
+
const session = this.getSessionRuntimeByKey(task.sessionKey, { allowAnyWorkdir: true });
|
|
913
|
+
if (!session)
|
|
914
|
+
return;
|
|
915
|
+
session.runningTaskIds.delete(taskId);
|
|
916
|
+
this.maybeEvictSessionRuntime(session.key);
|
|
917
|
+
}
|
|
918
|
+
runningTaskForSession(sessionKey) {
|
|
919
|
+
const session = this.getSessionRuntimeByKey(sessionKey, { allowAnyWorkdir: true });
|
|
920
|
+
if (!session || !session.runningTaskIds.size)
|
|
921
|
+
return null;
|
|
922
|
+
let running = null;
|
|
923
|
+
for (const taskId of session.runningTaskIds) {
|
|
924
|
+
const task = this.activeTasks.get(taskId);
|
|
925
|
+
if (!task || task.status !== 'running')
|
|
926
|
+
continue;
|
|
927
|
+
if (!running || task.startedAt < running.startedAt)
|
|
928
|
+
running = task;
|
|
929
|
+
}
|
|
930
|
+
return running;
|
|
931
|
+
}
|
|
932
|
+
markTaskRunning(taskId, abort) {
|
|
933
|
+
const task = this.activeTasks.get(taskId);
|
|
934
|
+
if (!task)
|
|
935
|
+
return null;
|
|
936
|
+
if (task.cancelled)
|
|
937
|
+
return task;
|
|
938
|
+
task.status = 'running';
|
|
939
|
+
task.abort = abort || null;
|
|
940
|
+
task.steer = null;
|
|
941
|
+
task.freezePreviewOnAbort = false;
|
|
942
|
+
return task;
|
|
943
|
+
}
|
|
944
|
+
registerTaskPlaceholders(taskId, messageIds) {
|
|
945
|
+
const task = this.activeTasks.get(taskId);
|
|
946
|
+
if (!task)
|
|
947
|
+
return;
|
|
948
|
+
if (!task.placeholderMessageIds)
|
|
949
|
+
task.placeholderMessageIds = [];
|
|
950
|
+
for (const messageId of messageIds) {
|
|
951
|
+
if (messageId == null)
|
|
952
|
+
continue;
|
|
953
|
+
if (!task.placeholderMessageIds.includes(messageId))
|
|
954
|
+
task.placeholderMessageIds.push(messageId);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
isSourceMessageWithdrawn(chatId, sourceMessageId) {
|
|
958
|
+
return this.withdrawnSourceMessages.has(this.sourceMessageKey(chatId, sourceMessageId));
|
|
959
|
+
}
|
|
960
|
+
actionIdForTask(taskId) {
|
|
961
|
+
return this.activeTasks.get(taskId)?.actionId || null;
|
|
962
|
+
}
|
|
963
|
+
withdrawQueuedTaskBySourceMessage(chatId, sourceMessageId) {
|
|
964
|
+
const sourceKey = this.sourceMessageKey(chatId, sourceMessageId);
|
|
965
|
+
this.withdrawnSourceMessages.add(sourceKey);
|
|
966
|
+
const taskId = this.taskKeysBySourceMessage.get(sourceKey);
|
|
967
|
+
if (!taskId)
|
|
968
|
+
return null;
|
|
969
|
+
const task = this.activeTasks.get(taskId);
|
|
970
|
+
if (!task || task.status !== 'queued')
|
|
971
|
+
return null;
|
|
972
|
+
task.cancelled = true;
|
|
973
|
+
return task;
|
|
974
|
+
}
|
|
975
|
+
stopTasksForSession(sessionKey) {
|
|
976
|
+
const session = this.getSessionRuntimeByKey(sessionKey, { allowAnyWorkdir: true });
|
|
977
|
+
if (!session)
|
|
978
|
+
return { interrupted: false, cancelledQueued: 0 };
|
|
979
|
+
let interrupted = false;
|
|
980
|
+
for (const taskId of session.runningTaskIds) {
|
|
981
|
+
const task = this.activeTasks.get(taskId);
|
|
982
|
+
if (!task)
|
|
983
|
+
continue;
|
|
984
|
+
if (!interrupted && task.status === 'running') {
|
|
985
|
+
interrupted = true;
|
|
986
|
+
task.cancelled = true;
|
|
987
|
+
try {
|
|
988
|
+
task.abort?.();
|
|
989
|
+
}
|
|
990
|
+
catch { }
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
return { interrupted, cancelledQueued: 0 };
|
|
994
|
+
}
|
|
995
|
+
stopTaskByActionId(actionId) {
|
|
996
|
+
const taskId = this.taskKeysByActionId.get(String(actionId));
|
|
997
|
+
if (!taskId)
|
|
998
|
+
return { task: null, interrupted: false, cancelled: false };
|
|
999
|
+
const task = this.activeTasks.get(taskId) || null;
|
|
1000
|
+
if (!task)
|
|
1001
|
+
return { task: null, interrupted: false, cancelled: false };
|
|
1002
|
+
if (task.status === 'queued') {
|
|
1003
|
+
task.cancelled = true;
|
|
1004
|
+
return { task, interrupted: false, cancelled: true };
|
|
1005
|
+
}
|
|
1006
|
+
if (task.status === 'running') {
|
|
1007
|
+
try {
|
|
1008
|
+
task.abort?.();
|
|
1009
|
+
}
|
|
1010
|
+
catch { }
|
|
1011
|
+
return { task, interrupted: true, cancelled: false };
|
|
1012
|
+
}
|
|
1013
|
+
return { task, interrupted: false, cancelled: false };
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Mark all queued tasks ahead of `targetTaskId` (in this session) so their
|
|
1017
|
+
* chain wrappers re-enqueue and yield to the steered task. Repeated steers
|
|
1018
|
+
* reset prior defer flags so only the latest target's predecessors defer.
|
|
1019
|
+
*/
|
|
1020
|
+
markQueueDeferralsForSteer(targetTaskId) {
|
|
1021
|
+
const target = this.activeTasks.get(targetTaskId);
|
|
1022
|
+
if (!target)
|
|
1023
|
+
return;
|
|
1024
|
+
const snapshot = this.streamSnapshots.get(target.sessionKey);
|
|
1025
|
+
const queuedIds = snapshot?.queuedTaskIds || [];
|
|
1026
|
+
// Reset any previous defer flags for this session's queued tasks first so
|
|
1027
|
+
// a new steer call doesn't stack on top of an earlier (now-stale) decision.
|
|
1028
|
+
for (const id of queuedIds) {
|
|
1029
|
+
const t = this.activeTasks.get(id);
|
|
1030
|
+
if (t)
|
|
1031
|
+
t.deferForSteer = false;
|
|
1032
|
+
}
|
|
1033
|
+
const targetIdx = queuedIds.indexOf(targetTaskId);
|
|
1034
|
+
for (let i = 0; i < targetIdx; i++) {
|
|
1035
|
+
const t = this.activeTasks.get(queuedIds[i]);
|
|
1036
|
+
if (t && t.status === 'queued' && !t.cancelled)
|
|
1037
|
+
t.deferForSteer = true;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Steer hands off to the queued task's own placeholder card. Interrupt the
|
|
1042
|
+
* active task so the queued task can run next and the current preview can be
|
|
1043
|
+
* frozen in place instead of being rewritten as an error.
|
|
1044
|
+
*/
|
|
1045
|
+
async steerTaskByActionId(actionId) {
|
|
1046
|
+
const taskId = this.taskKeysByActionId.get(String(actionId));
|
|
1047
|
+
if (!taskId)
|
|
1048
|
+
return { task: null, interrupted: false, steered: false };
|
|
1049
|
+
const task = this.activeTasks.get(taskId) || null;
|
|
1050
|
+
if (!task || task.status !== 'queued')
|
|
1051
|
+
return { task, interrupted: false, steered: false };
|
|
1052
|
+
this.markQueueDeferralsForSteer(taskId);
|
|
1053
|
+
const interrupted = this.interruptRunningTask(task.sessionKey, { freezePreview: true });
|
|
1054
|
+
return { task, interrupted, steered: false };
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Interrupt only the currently running task for a session, leaving queued tasks intact.
|
|
1058
|
+
* Used by the "Steer" action to let a queued task run next.
|
|
1059
|
+
*/
|
|
1060
|
+
interruptRunningTask(sessionKey, opts = {}) {
|
|
1061
|
+
const session = this.getSessionRuntimeByKey(sessionKey, { allowAnyWorkdir: true });
|
|
1062
|
+
if (!session)
|
|
1063
|
+
return false;
|
|
1064
|
+
for (const taskId of session.runningTaskIds) {
|
|
1065
|
+
const task = this.activeTasks.get(taskId);
|
|
1066
|
+
if (!task || task.status !== 'running')
|
|
1067
|
+
continue;
|
|
1068
|
+
task.freezePreviewOnAbort = !!opts.freezePreview;
|
|
1069
|
+
try {
|
|
1070
|
+
task.abort?.();
|
|
1071
|
+
}
|
|
1072
|
+
catch { }
|
|
1073
|
+
return true;
|
|
1074
|
+
}
|
|
1075
|
+
return false;
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Return the number of tasks ahead of the given task in its session queue.
|
|
1079
|
+
* Counts running + queued (non-cancelled) tasks that were started before this one.
|
|
1080
|
+
*/
|
|
1081
|
+
getQueuePosition(sessionKey, taskId) {
|
|
1082
|
+
const session = this.getSessionRuntimeByKey(sessionKey, { allowAnyWorkdir: true });
|
|
1083
|
+
if (!session)
|
|
1084
|
+
return 0;
|
|
1085
|
+
let ahead = 0;
|
|
1086
|
+
for (const otherId of session.runningTaskIds) {
|
|
1087
|
+
if (otherId === taskId)
|
|
1088
|
+
continue;
|
|
1089
|
+
const other = this.activeTasks.get(otherId);
|
|
1090
|
+
if (!other || other.cancelled)
|
|
1091
|
+
continue;
|
|
1092
|
+
if (other.status === 'running' || other.status === 'queued')
|
|
1093
|
+
ahead++;
|
|
1094
|
+
}
|
|
1095
|
+
return ahead;
|
|
1096
|
+
}
|
|
1097
|
+
sourceMessageKey(chatId, sourceMessageId) {
|
|
1098
|
+
return `${String(chatId)}:${String(sourceMessageId)}`;
|
|
1099
|
+
}
|
|
1100
|
+
queueSessionTask(session, task, taskId) {
|
|
1101
|
+
// Wrap the user task with a defer check. When steerTask() flags this task
|
|
1102
|
+
// to yield its chain slot to a steered task, the wrapper re-enqueues the
|
|
1103
|
+
// same fn at the tail and returns immediately so the next chain wrapper
|
|
1104
|
+
// (the steered task's) fires next. Tasks without a taskId (e.g. file
|
|
1105
|
+
// staging) skip the check.
|
|
1106
|
+
const runner = async () => {
|
|
1107
|
+
if (taskId) {
|
|
1108
|
+
const t = this.activeTasks.get(taskId);
|
|
1109
|
+
if (t?.deferForSteer && !t.cancelled) {
|
|
1110
|
+
t.deferForSteer = false;
|
|
1111
|
+
// Re-enqueue at the tail. Don't await — let the current slot finish
|
|
1112
|
+
// immediately so the chain advances to the steered task. The new
|
|
1113
|
+
// wrapper preserves the original fn so the deferred task still runs
|
|
1114
|
+
// (just after the steered one).
|
|
1115
|
+
void this.queueSessionTask(session, task, taskId);
|
|
1116
|
+
return undefined;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
return await task();
|
|
1120
|
+
};
|
|
1121
|
+
const prev = this.sessionChains.get(session.key) || Promise.resolve();
|
|
1122
|
+
const current = prev.catch(() => { }).then(runner);
|
|
1123
|
+
const settled = current.then(() => { }, () => { });
|
|
1124
|
+
const chained = settled.finally(() => {
|
|
1125
|
+
if (this.sessionChains.get(session.key) === chained)
|
|
1126
|
+
this.sessionChains.delete(session.key);
|
|
1127
|
+
});
|
|
1128
|
+
this.sessionChains.set(session.key, chained);
|
|
1129
|
+
return current;
|
|
1130
|
+
}
|
|
1131
|
+
sessionHasPendingWork(session) {
|
|
1132
|
+
return this.sessionChains.has(session.key);
|
|
1133
|
+
}
|
|
1134
|
+
beginHumanLoopPrompt(opts) {
|
|
1135
|
+
const promptId = `h${(this.nextHumanLoopPromptId++).toString(36)}`;
|
|
1136
|
+
let resolvePrompt;
|
|
1137
|
+
let rejectPrompt;
|
|
1138
|
+
const result = new Promise((resolve, reject) => {
|
|
1139
|
+
resolvePrompt = resolve;
|
|
1140
|
+
rejectPrompt = reject;
|
|
1141
|
+
});
|
|
1142
|
+
const answers = {};
|
|
1143
|
+
for (const question of opts.questions)
|
|
1144
|
+
answers[question.id] = createEmptyHumanLoopAnswer();
|
|
1145
|
+
const prompt = {
|
|
1146
|
+
promptId,
|
|
1147
|
+
taskId: opts.taskId,
|
|
1148
|
+
chatId: opts.chatId,
|
|
1149
|
+
title: opts.title,
|
|
1150
|
+
detail: opts.detail ?? null,
|
|
1151
|
+
hint: opts.hint ?? null,
|
|
1152
|
+
questions: opts.questions,
|
|
1153
|
+
currentIndex: 0,
|
|
1154
|
+
answers,
|
|
1155
|
+
resolveWith: opts.resolveWith,
|
|
1156
|
+
resolve: resolvePrompt,
|
|
1157
|
+
reject: rejectPrompt,
|
|
1158
|
+
messageIds: [],
|
|
1159
|
+
silent: opts.silent,
|
|
1160
|
+
};
|
|
1161
|
+
this.humanLoopPrompts.set(promptId, prompt);
|
|
1162
|
+
const chatKey = String(opts.chatId);
|
|
1163
|
+
const promptIds = this.humanLoopPromptIdsByChat.get(chatKey) || [];
|
|
1164
|
+
promptIds.push(promptId);
|
|
1165
|
+
this.humanLoopPromptIdsByChat.set(chatKey, promptIds);
|
|
1166
|
+
return { prompt, result };
|
|
1167
|
+
}
|
|
1168
|
+
pendingHumanLoopPrompt(chatId) {
|
|
1169
|
+
const promptIds = this.humanLoopPromptIdsByChat.get(String(chatId)) || [];
|
|
1170
|
+
for (let i = promptIds.length - 1; i >= 0; i--) {
|
|
1171
|
+
const prompt = this.humanLoopPrompts.get(promptIds[i]) || null;
|
|
1172
|
+
if (prompt && isHumanLoopAwaitingText(prompt))
|
|
1173
|
+
return prompt;
|
|
1174
|
+
}
|
|
1175
|
+
const promptId = promptIds[promptIds.length - 1];
|
|
1176
|
+
return promptId ? (this.humanLoopPrompts.get(promptId) || null) : null;
|
|
1177
|
+
}
|
|
1178
|
+
registerHumanLoopMessage(promptId, messageId) {
|
|
1179
|
+
if (messageId == null)
|
|
1180
|
+
return;
|
|
1181
|
+
const prompt = this.humanLoopPrompts.get(promptId);
|
|
1182
|
+
if (!prompt)
|
|
1183
|
+
return;
|
|
1184
|
+
if (!prompt.messageIds.includes(messageId))
|
|
1185
|
+
prompt.messageIds.push(messageId);
|
|
1186
|
+
}
|
|
1187
|
+
resolveHumanLoopPrompt(promptId) {
|
|
1188
|
+
const prompt = this.humanLoopPrompts.get(promptId) || null;
|
|
1189
|
+
if (!prompt)
|
|
1190
|
+
return null;
|
|
1191
|
+
this.humanLoopPrompts.delete(promptId);
|
|
1192
|
+
this.removeHumanLoopPromptFromChat(prompt.chatId, promptId);
|
|
1193
|
+
prompt.resolve(buildHumanLoopResponse(prompt));
|
|
1194
|
+
this.emitInteractionResolved(prompt.taskId, promptId);
|
|
1195
|
+
this.fireInteractionAnswered(prompt, 'answered');
|
|
1196
|
+
return prompt;
|
|
1197
|
+
}
|
|
1198
|
+
clearHumanLoopPrompt(promptId, error) {
|
|
1199
|
+
const prompt = this.humanLoopPrompts.get(promptId) || null;
|
|
1200
|
+
if (!prompt)
|
|
1201
|
+
return null;
|
|
1202
|
+
this.humanLoopPrompts.delete(promptId);
|
|
1203
|
+
this.removeHumanLoopPromptFromChat(prompt.chatId, promptId);
|
|
1204
|
+
if (error)
|
|
1205
|
+
prompt.reject(error);
|
|
1206
|
+
this.emitInteractionResolved(prompt.taskId, promptId);
|
|
1207
|
+
this.fireInteractionAnswered(prompt, 'cancelled');
|
|
1208
|
+
return prompt;
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Unified post-resolution hook for human-loop prompts. Each IM channel
|
|
1212
|
+
* overrides `onInteractionAnswered` to (1) collapse the original prompt card
|
|
1213
|
+
* to an answered/cancelled state and (2) echo the decision as a new chat
|
|
1214
|
+
* message so scrolling back shows what the user picked. Dashboard sessions
|
|
1215
|
+
* (chatId='dashboard') and channels that opt out remain silent.
|
|
1216
|
+
*/
|
|
1217
|
+
fireInteractionAnswered(prompt, status) {
|
|
1218
|
+
if (prompt.silent)
|
|
1219
|
+
return;
|
|
1220
|
+
if (prompt.chatId === 'dashboard')
|
|
1221
|
+
return;
|
|
1222
|
+
const summary = summarizeResolvedHumanLoopAnswers(prompt, status);
|
|
1223
|
+
void Promise.resolve()
|
|
1224
|
+
.then(() => this.onInteractionAnswered(prompt, summary))
|
|
1225
|
+
.catch(err => this.warn(`onInteractionAnswered failed: ${err?.message || err}`));
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* Channel hook fired after a human-loop prompt resolves (answered or
|
|
1229
|
+
* cancelled). Default: no-op. Override in channel subclasses to update the
|
|
1230
|
+
* original card and post a decision-echo message.
|
|
1231
|
+
*/
|
|
1232
|
+
async onInteractionAnswered(_prompt, _summary) {
|
|
1233
|
+
// Default: no-op.
|
|
1234
|
+
}
|
|
1235
|
+
emitInteractionResolved(taskId, promptId) {
|
|
1236
|
+
const task = this.activeTasks.get(taskId);
|
|
1237
|
+
if (task)
|
|
1238
|
+
this.emitStream(task.sessionKey, { type: 'interaction-resolved', promptId });
|
|
1239
|
+
}
|
|
1240
|
+
humanLoopSelectOption(promptId, optionValue, opts = {}) {
|
|
1241
|
+
const prompt = this.humanLoopPrompts.get(promptId) || null;
|
|
1242
|
+
if (!prompt)
|
|
1243
|
+
return null;
|
|
1244
|
+
const result = setHumanLoopOption(prompt, optionValue, opts);
|
|
1245
|
+
if (result.completed)
|
|
1246
|
+
this.resolveHumanLoopPrompt(promptId);
|
|
1247
|
+
return { prompt, ...result };
|
|
1248
|
+
}
|
|
1249
|
+
humanLoopSkip(promptId) {
|
|
1250
|
+
const prompt = this.humanLoopPrompts.get(promptId) || null;
|
|
1251
|
+
if (!prompt)
|
|
1252
|
+
return null;
|
|
1253
|
+
const result = skipHumanLoopQuestion(prompt);
|
|
1254
|
+
if (result.completed)
|
|
1255
|
+
this.resolveHumanLoopPrompt(promptId);
|
|
1256
|
+
return { prompt, ...result };
|
|
1257
|
+
}
|
|
1258
|
+
humanLoopSubmitText(chatId, text) {
|
|
1259
|
+
const prompt = this.pendingHumanLoopPrompt(chatId);
|
|
1260
|
+
if (!prompt)
|
|
1261
|
+
return null;
|
|
1262
|
+
if (!isHumanLoopAwaitingText(prompt))
|
|
1263
|
+
return null;
|
|
1264
|
+
const result = setHumanLoopText(prompt, text);
|
|
1265
|
+
if (result.completed)
|
|
1266
|
+
this.resolveHumanLoopPrompt(prompt.promptId);
|
|
1267
|
+
return { prompt, ...result };
|
|
1268
|
+
}
|
|
1269
|
+
humanLoopCancel(promptId, reason = 'Prompt cancelled.') {
|
|
1270
|
+
return this.clearHumanLoopPrompt(promptId, new Error(reason));
|
|
1271
|
+
}
|
|
1272
|
+
humanLoopCurrentQuestion(promptId) {
|
|
1273
|
+
const prompt = this.humanLoopPrompts.get(promptId);
|
|
1274
|
+
return prompt ? currentHumanLoopQuestion(prompt) : null;
|
|
1275
|
+
}
|
|
1276
|
+
humanLoopPrompt(promptId) {
|
|
1277
|
+
return this.humanLoopPrompts.get(promptId) || null;
|
|
1278
|
+
}
|
|
1279
|
+
removeHumanLoopPromptFromChat(chatId, promptId) {
|
|
1280
|
+
const chatKey = String(chatId);
|
|
1281
|
+
const promptIds = this.humanLoopPromptIdsByChat.get(chatKey) || [];
|
|
1282
|
+
const next = promptIds.filter(id => id !== promptId);
|
|
1283
|
+
if (next.length)
|
|
1284
|
+
this.humanLoopPromptIdsByChat.set(chatKey, next);
|
|
1285
|
+
else
|
|
1286
|
+
this.humanLoopPromptIdsByChat.delete(chatKey);
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Create an interaction handler that bridges agent requests to the human-loop
|
|
1290
|
+
* state machine and pushes SSE events to the dashboard.
|
|
1291
|
+
*
|
|
1292
|
+
* IM channel subclasses override `renderInteractionPrompt()` to render
|
|
1293
|
+
* buttons/cards in their native UI. Dashboard clients receive the
|
|
1294
|
+
* `interaction` SSE event and respond via REST.
|
|
1295
|
+
*/
|
|
1296
|
+
createInteractionHandler(chatId, taskId) {
|
|
1297
|
+
return async (request) => {
|
|
1298
|
+
const active = this.beginHumanLoopPrompt({
|
|
1299
|
+
taskId,
|
|
1300
|
+
chatId,
|
|
1301
|
+
title: request.title,
|
|
1302
|
+
hint: request.hint,
|
|
1303
|
+
questions: request.questions,
|
|
1304
|
+
resolveWith: request.resolveWith,
|
|
1305
|
+
});
|
|
1306
|
+
const interactionSnapshot = {
|
|
1307
|
+
promptId: active.prompt.promptId,
|
|
1308
|
+
kind: request.kind,
|
|
1309
|
+
title: request.title,
|
|
1310
|
+
hint: request.hint,
|
|
1311
|
+
questions: request.questions,
|
|
1312
|
+
currentIndex: active.prompt.currentIndex,
|
|
1313
|
+
};
|
|
1314
|
+
// Resolve sessionKey live at emit time — the task entry tracks promotion
|
|
1315
|
+
// (pending → native id), so a key captured at handler-creation time would
|
|
1316
|
+
// go stale on the very first turn of a fresh session and the dashboard
|
|
1317
|
+
// SSE event would land on an already-moved snapshot.
|
|
1318
|
+
const task = this.activeTasks.get(taskId);
|
|
1319
|
+
if (task)
|
|
1320
|
+
this.emitStream(task.sessionKey, { type: 'interaction', taskId, interaction: interactionSnapshot });
|
|
1321
|
+
// Dashboard sessions reply through SSE + REST (no IM render). When an IM
|
|
1322
|
+
// bot is also attached, its renderInteractionPrompt override would still
|
|
1323
|
+
// fire here with chatId='dashboard' — sending the prompt to the IM API
|
|
1324
|
+
// with an invalid receive_id, which surfaces as "Request failed with
|
|
1325
|
+
// status code 400" from the axios-based SDK. Skip the render for
|
|
1326
|
+
// dashboard chats; the SSE event is the canonical delivery.
|
|
1327
|
+
if (chatId !== 'dashboard') {
|
|
1328
|
+
try {
|
|
1329
|
+
await this.renderInteractionPrompt(active.prompt, chatId);
|
|
1330
|
+
}
|
|
1331
|
+
catch (error) {
|
|
1332
|
+
this.humanLoopCancel(active.prompt.promptId, error?.message || 'Failed to send prompt.');
|
|
1333
|
+
throw error;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
return active.result;
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* Render an interaction prompt in the IM channel.
|
|
1341
|
+
* Override in channel subclasses (Telegram, Feishu, etc.).
|
|
1342
|
+
* Dashboard-only sessions (chatId='dashboard') are a no-op by default.
|
|
1343
|
+
*/
|
|
1344
|
+
async renderInteractionPrompt(_prompt, _chatId) {
|
|
1345
|
+
// Default: no-op (dashboard-only sessions use SSE events instead)
|
|
1346
|
+
}
|
|
1347
|
+
// ---- Public interaction API (used by dashboard routes) --------------------
|
|
1348
|
+
/** Respond to a pending interaction prompt with a selected option. */
|
|
1349
|
+
interactionSelectOption(promptId, optionValue, opts) {
|
|
1350
|
+
return this.humanLoopSelectOption(promptId, optionValue, opts);
|
|
1351
|
+
}
|
|
1352
|
+
/** Submit freeform text to a pending interaction prompt. */
|
|
1353
|
+
interactionSubmitText(promptId, text) {
|
|
1354
|
+
const prompt = this.humanLoopPrompt(promptId);
|
|
1355
|
+
if (!prompt)
|
|
1356
|
+
return null;
|
|
1357
|
+
// The dashboard modal submits a custom answer via an explicit Submit button,
|
|
1358
|
+
// so accept the text whenever the current question PERMITS freeform — not
|
|
1359
|
+
// only after an "Other" chip flipped `awaitingFreeform` (the IM-card flow,
|
|
1360
|
+
// which `isHumanLoopAwaitingText` gates on). An options question only allows
|
|
1361
|
+
// it when `allowFreeform` is set; an option-less question is freeform by
|
|
1362
|
+
// definition. (The IM passive-text path keeps the stricter check so a normal
|
|
1363
|
+
// chat message isn't silently captured as an answer.)
|
|
1364
|
+
const question = currentHumanLoopQuestion(prompt);
|
|
1365
|
+
if (!question)
|
|
1366
|
+
return null;
|
|
1367
|
+
const hasOptions = !!question.options?.length;
|
|
1368
|
+
const freeformAllowed = !hasOptions || question.allowFreeform !== false;
|
|
1369
|
+
if (!freeformAllowed && !isHumanLoopAwaitingText(prompt))
|
|
1370
|
+
return null;
|
|
1371
|
+
const result = setHumanLoopText(prompt, text);
|
|
1372
|
+
if (result.completed)
|
|
1373
|
+
this.resolveHumanLoopPrompt(prompt.promptId);
|
|
1374
|
+
return { prompt, ...result };
|
|
1375
|
+
}
|
|
1376
|
+
/** Skip the current question in a pending interaction prompt. */
|
|
1377
|
+
interactionSkip(promptId) {
|
|
1378
|
+
return this.humanLoopSkip(promptId);
|
|
1379
|
+
}
|
|
1380
|
+
/** Cancel a pending interaction prompt. */
|
|
1381
|
+
interactionCancel(promptId, reason = 'Cancelled from dashboard.') {
|
|
1382
|
+
return this.humanLoopCancel(promptId, reason);
|
|
1383
|
+
}
|
|
1384
|
+
/** Get a specific interaction prompt by ID. */
|
|
1385
|
+
interactionPrompt(promptId) {
|
|
1386
|
+
return this.humanLoopPrompt(promptId);
|
|
1387
|
+
}
|
|
1388
|
+
selectedSession(chatId) {
|
|
1389
|
+
return this.getSelectedSession(this.chat(chatId));
|
|
1390
|
+
}
|
|
1391
|
+
submitSessionTask(opts) {
|
|
1392
|
+
const session = this.upsertSessionRuntime({
|
|
1393
|
+
agent: opts.agent,
|
|
1394
|
+
sessionId: opts.sessionId,
|
|
1395
|
+
workdir: opts.workdir,
|
|
1396
|
+
workspacePath: null,
|
|
1397
|
+
// Only override when explicitly provided — undefined skips the overwrite in upsertSessionRuntime
|
|
1398
|
+
...(opts.modelId !== undefined ? { modelId: opts.modelId } : {}),
|
|
1399
|
+
...(opts.thinkingEffort !== undefined ? { thinkingEffort: opts.thinkingEffort } : {}),
|
|
1400
|
+
...(opts.handoverFrom !== undefined ? { handoverFrom: opts.handoverFrom } : {}),
|
|
1401
|
+
});
|
|
1402
|
+
const taskId = `ext-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1403
|
+
const prompt = opts.prompt.trim();
|
|
1404
|
+
const attachments = opts.attachments || [];
|
|
1405
|
+
const chatId = opts.chatId ?? 'dashboard';
|
|
1406
|
+
this.beginTask({
|
|
1407
|
+
taskId,
|
|
1408
|
+
chatId,
|
|
1409
|
+
agent: session.agent,
|
|
1410
|
+
sessionKey: session.key,
|
|
1411
|
+
prompt,
|
|
1412
|
+
attachments,
|
|
1413
|
+
startedAt: Date.now(),
|
|
1414
|
+
sourceMessageId: opts.sourceMessageId ?? taskId,
|
|
1415
|
+
});
|
|
1416
|
+
this.emitStreamQueued(session.key, taskId);
|
|
1417
|
+
void this.queueSessionTask(session, async () => {
|
|
1418
|
+
const abortController = new AbortController();
|
|
1419
|
+
const task = this.markTaskRunning(taskId, () => abortController.abort());
|
|
1420
|
+
if (task?.cancelled) {
|
|
1421
|
+
this.emitStreamCancelled(taskId, session.key);
|
|
1422
|
+
this.finishTask(taskId);
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
this.emitStreamStart(taskId, session);
|
|
1426
|
+
// Wire up IM rendering for non-dashboard chats so /goal-driven tasks stream
|
|
1427
|
+
// to the same channel that submitted them, matching handleMessage's UX.
|
|
1428
|
+
const presenter = chatId !== 'dashboard'
|
|
1429
|
+
? await this.createImTaskPresenter({
|
|
1430
|
+
chatId, taskId, session, agent: session.agent, prompt, attachments,
|
|
1431
|
+
}).catch(err => {
|
|
1432
|
+
this.warn(`[submitSessionTask] presenter setup failed task=${taskId}: ${err?.message || err}`);
|
|
1433
|
+
return null;
|
|
1434
|
+
})
|
|
1435
|
+
: null;
|
|
1436
|
+
try {
|
|
1437
|
+
const result = await this.runStream(prompt, session, attachments, (text, thinking, activity, meta, plan) => {
|
|
1438
|
+
opts.onText?.(text, thinking, activity, meta, plan);
|
|
1439
|
+
presenter?.onText(text, thinking, activity, meta, plan);
|
|
1440
|
+
this.emitStreamText(taskId, session.key, text, thinking, activity, meta, plan);
|
|
1441
|
+
}, undefined, undefined, abortController.signal, this.createInteractionHandler(chatId, taskId), undefined, undefined, (opts.forkOf || opts.workflowEnabled !== undefined)
|
|
1442
|
+
? { ...(opts.forkOf ? { forkOf: opts.forkOf } : {}), ...(opts.workflowEnabled !== undefined ? { workflowEnabled: opts.workflowEnabled } : {}) }
|
|
1443
|
+
: undefined);
|
|
1444
|
+
this.emitStreamDone(taskId, session.key, {
|
|
1445
|
+
sessionId: result.sessionId || session.sessionId,
|
|
1446
|
+
incomplete: !!result.incomplete,
|
|
1447
|
+
...(result.ok ? {} : { error: result.error || result.message }),
|
|
1448
|
+
});
|
|
1449
|
+
if (presenter) {
|
|
1450
|
+
try {
|
|
1451
|
+
await presenter.onSuccess(result);
|
|
1452
|
+
}
|
|
1453
|
+
catch (e) {
|
|
1454
|
+
this.warn(`[submitSessionTask] presenter onSuccess failed task=${taskId}: ${e?.message || e}`);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
try {
|
|
1458
|
+
this.maybeEnqueueGoalContinuation(session, opts, result);
|
|
1459
|
+
}
|
|
1460
|
+
catch (err) {
|
|
1461
|
+
this.debug(`[goal-continuation] enqueue failed: ${err?.message || err}`);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
catch (error) {
|
|
1465
|
+
const errMsg = error?.message || String(error);
|
|
1466
|
+
this.emitStreamDone(taskId, session.key, {
|
|
1467
|
+
sessionId: session.sessionId,
|
|
1468
|
+
incomplete: true,
|
|
1469
|
+
error: errMsg,
|
|
1470
|
+
});
|
|
1471
|
+
if (presenter) {
|
|
1472
|
+
try {
|
|
1473
|
+
await presenter.onFailure(errMsg);
|
|
1474
|
+
}
|
|
1475
|
+
catch (e) {
|
|
1476
|
+
this.warn(`[submitSessionTask] presenter onFailure failed task=${taskId}: ${e?.message || e}`);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
finally {
|
|
1481
|
+
presenter?.dispose();
|
|
1482
|
+
this.finishTask(taskId);
|
|
1483
|
+
this.syncSelectedChats(session);
|
|
1484
|
+
}
|
|
1485
|
+
}, taskId).catch(error => {
|
|
1486
|
+
this.finishTask(taskId);
|
|
1487
|
+
this.error(`[submitSessionTask] queue failed task=${taskId} error=${error?.message || error}`);
|
|
1488
|
+
});
|
|
1489
|
+
return { ok: true, taskId, sessionKey: session.key, queued: true };
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Channel hook — returns a presenter that streams the task's runStream
|
|
1493
|
+
* output to the IM chat that submitted it. Default: null (dashboard-only
|
|
1494
|
+
* chats and channels that haven't opted in stay silent in IM).
|
|
1495
|
+
*/
|
|
1496
|
+
async createImTaskPresenter(_opts) {
|
|
1497
|
+
return null;
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* Goal continuation: after a turn ends, if a goal is still active for the
|
|
1501
|
+
* session, account token + wall-clock usage, then enqueue one more task with
|
|
1502
|
+
* the rendered continuation prompt. If the budget was just crossed, enqueue a
|
|
1503
|
+
* single wrap-up turn with the budget-limit prompt instead. Goal-continuation
|
|
1504
|
+
* tasks that get cancelled or errored auto-pause the goal so the loop does
|
|
1505
|
+
* not silently resume on the user's next message.
|
|
1506
|
+
*
|
|
1507
|
+
* Codex and Claude sessions short-circuit: each runs its own native `/goal`
|
|
1508
|
+
* lifecycle (codex's app-server state machine; claude's in-process Stop
|
|
1509
|
+
* hook), so pikiloop stays out to avoid a double loop. See setSessionGoal
|
|
1510
|
+
* et al — they bridge to codex's `thread/goal/*` RPC and to claude's
|
|
1511
|
+
* `/goal <condition>` slash command instead of writing pikiloop's goal.json.
|
|
1512
|
+
*/
|
|
1513
|
+
maybeEnqueueGoalContinuation(session, opts, result) {
|
|
1514
|
+
if (session.agent === 'codex' || session.agent === 'claude')
|
|
1515
|
+
return;
|
|
1516
|
+
const sessionId = (result.sessionId || session.sessionId || '').trim();
|
|
1517
|
+
if (!sessionId || isPendingSessionId(sessionId))
|
|
1518
|
+
return;
|
|
1519
|
+
const workdir = session.workdir;
|
|
1520
|
+
const agent = session.agent;
|
|
1521
|
+
const goalBefore = readGoal(workdir, agent, sessionId);
|
|
1522
|
+
if (!goalBefore)
|
|
1523
|
+
return;
|
|
1524
|
+
if (!result.ok || result.incomplete) {
|
|
1525
|
+
if (opts.goalContinuation && goalBefore.status === 'active') {
|
|
1526
|
+
pauseGoal(workdir, agent, sessionId);
|
|
1527
|
+
this.debug(`[goal-continuation] paused goal=${goalBefore.goalId} after failed continuation`);
|
|
1528
|
+
}
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
if (goalBefore.status !== 'active')
|
|
1532
|
+
return;
|
|
1533
|
+
const usedTokens = Math.max(0, (result.inputTokens || 0) + (result.outputTokens || 0));
|
|
1534
|
+
const seconds = Math.max(0, Math.floor(result.elapsedS || 0));
|
|
1535
|
+
const { goal, budgetJustCrossed } = accountTurn(workdir, agent, sessionId, {
|
|
1536
|
+
tokens: usedTokens,
|
|
1537
|
+
seconds,
|
|
1538
|
+
});
|
|
1539
|
+
if (!goal)
|
|
1540
|
+
return;
|
|
1541
|
+
if (budgetJustCrossed) {
|
|
1542
|
+
const prompt = renderBudgetLimitPrompt(goal);
|
|
1543
|
+
this.debug(`[goal-continuation] budget exhausted goal=${goal.goalId} — enqueue wrap-up turn`);
|
|
1544
|
+
this.submitSessionTask({
|
|
1545
|
+
agent,
|
|
1546
|
+
sessionId,
|
|
1547
|
+
workdir,
|
|
1548
|
+
prompt,
|
|
1549
|
+
chatId: opts.chatId,
|
|
1550
|
+
modelId: opts.modelId,
|
|
1551
|
+
thinkingEffort: opts.thinkingEffort,
|
|
1552
|
+
goalContinuation: { kind: 'budget_wrapup', goalId: goal.goalId },
|
|
1553
|
+
});
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
const decision = shouldContinueAfterTurn(goal);
|
|
1557
|
+
if (!decision.shouldContinue) {
|
|
1558
|
+
this.debug(`[goal-continuation] stop goal=${goal.goalId} reason=${decision.reason}`);
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
const updated = bumpContinuationCount(workdir, agent, sessionId);
|
|
1562
|
+
if (!updated)
|
|
1563
|
+
return;
|
|
1564
|
+
const prompt = renderContinuationPrompt(updated);
|
|
1565
|
+
this.debug(`[goal-continuation] continue goal=${updated.goalId} count=${updated.continuationCount} tokens=${updated.tokensUsed}/${updated.tokenBudget ?? '∞'}`);
|
|
1566
|
+
this.submitSessionTask({
|
|
1567
|
+
agent,
|
|
1568
|
+
sessionId,
|
|
1569
|
+
workdir,
|
|
1570
|
+
prompt,
|
|
1571
|
+
chatId: opts.chatId,
|
|
1572
|
+
modelId: opts.modelId,
|
|
1573
|
+
thinkingEffort: opts.thinkingEffort,
|
|
1574
|
+
goalContinuation: { kind: 'continuation', goalId: updated.goalId },
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* Normalized goal view used by IM/dashboard renderers — same shape regardless
|
|
1579
|
+
* of whether the source is pikiloop's goal.json (claude / gemini / …) or
|
|
1580
|
+
* codex's native SQLite state machine.
|
|
1581
|
+
*/
|
|
1582
|
+
// SessionGoalView is exported below the class.
|
|
1583
|
+
/**
|
|
1584
|
+
* Read the current goal for a session. For codex this hits codex's native
|
|
1585
|
+
* `thread/goal/get`; for other drivers, reads goal.json.
|
|
1586
|
+
*/
|
|
1587
|
+
async getSessionGoal(workdir, agent, sessionId) {
|
|
1588
|
+
if (agent === 'codex') {
|
|
1589
|
+
if (!sessionId || isPendingSessionId(sessionId))
|
|
1590
|
+
return null;
|
|
1591
|
+
const goal = await getCodexGoal(sessionId);
|
|
1592
|
+
return goal ? normalizeFromCodex(goal) : null;
|
|
1593
|
+
}
|
|
1594
|
+
if (agent === 'claude') {
|
|
1595
|
+
if (!sessionId || isPendingSessionId(sessionId))
|
|
1596
|
+
return null;
|
|
1597
|
+
const goal = getClaudeNativeGoal(workdir, sessionId);
|
|
1598
|
+
return goal ? normalizeFromClaudeNative(goal) : null;
|
|
1599
|
+
}
|
|
1600
|
+
const goal = readGoal(workdir, agent, sessionId);
|
|
1601
|
+
return goal ? normalizeFromPikiloop(goal) : null;
|
|
1602
|
+
}
|
|
1603
|
+
/**
|
|
1604
|
+
* Set (or replace) the goal for a session. For codex this routes through
|
|
1605
|
+
* codex's native `thread/goal/set` and codex auto-starts a continuation turn
|
|
1606
|
+
* internally. For other drivers, pikiloop writes goal.json and enqueues the
|
|
1607
|
+
* first continuation turn so the agent starts working immediately.
|
|
1608
|
+
*/
|
|
1609
|
+
async setSessionGoal(workdir, agent, sessionId, opts) {
|
|
1610
|
+
if (agent === 'codex') {
|
|
1611
|
+
if (!sessionId || isPendingSessionId(sessionId)) {
|
|
1612
|
+
throw new Error('codex session must exist before /goal — send a first message to create the thread');
|
|
1613
|
+
}
|
|
1614
|
+
const resp = await setCodexGoal({
|
|
1615
|
+
threadId: sessionId,
|
|
1616
|
+
objective: opts.objective,
|
|
1617
|
+
status: 'active',
|
|
1618
|
+
tokenBudget: opts.tokenBudget ?? null,
|
|
1619
|
+
});
|
|
1620
|
+
if (!resp.ok)
|
|
1621
|
+
throw new Error(resp.error);
|
|
1622
|
+
// codex returns a snapshot; if for some reason it's null, re-fetch.
|
|
1623
|
+
const goal = resp.goal ?? (await getCodexGoal(sessionId));
|
|
1624
|
+
if (!goal)
|
|
1625
|
+
throw new Error('codex did not return a goal snapshot');
|
|
1626
|
+
return normalizeFromCodex(goal);
|
|
1627
|
+
}
|
|
1628
|
+
if (agent === 'claude') {
|
|
1629
|
+
if (!sessionId || isPendingSessionId(sessionId)) {
|
|
1630
|
+
throw new Error('claude session must exist before /goal — send a first message to create the transcript');
|
|
1631
|
+
}
|
|
1632
|
+
// Native /goal owns its own continuation engine (Stop hook). pikiloop
|
|
1633
|
+
// just submits the slash command as the next task; claude internally
|
|
1634
|
+
// sets up the goal_status attachment, injects its meta directive, and
|
|
1635
|
+
// keeps looping until the Haiku completion check returns met. Token
|
|
1636
|
+
// budget is accepted in the API for shape parity with codex/portable
|
|
1637
|
+
// but ignored — claude native /goal has no budget concept.
|
|
1638
|
+
const objective = opts.objective.trim();
|
|
1639
|
+
if (!objective)
|
|
1640
|
+
throw new Error('objective must be non-empty');
|
|
1641
|
+
this.submitSessionTask({
|
|
1642
|
+
agent,
|
|
1643
|
+
sessionId,
|
|
1644
|
+
workdir,
|
|
1645
|
+
prompt: buildClaudeSetGoalPrompt(objective),
|
|
1646
|
+
chatId: opts.chatId,
|
|
1647
|
+
modelId: opts.modelId,
|
|
1648
|
+
thinkingEffort: opts.thinkingEffort,
|
|
1649
|
+
});
|
|
1650
|
+
// Return an optimistic snapshot — the actual goal_status attachment is
|
|
1651
|
+
// written by claude during the task; readers can poll getSessionGoal.
|
|
1652
|
+
return normalizeFromClaudeNative({
|
|
1653
|
+
condition: objective,
|
|
1654
|
+
status: 'active',
|
|
1655
|
+
met: false,
|
|
1656
|
+
updatedAtMs: Date.now(),
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
const goal = setGoalState(workdir, agent, sessionId, {
|
|
1660
|
+
objective: opts.objective,
|
|
1661
|
+
tokenBudget: opts.tokenBudget ?? null,
|
|
1662
|
+
});
|
|
1663
|
+
if (!isPendingSessionId(sessionId)) {
|
|
1664
|
+
const prompt = renderContinuationPrompt(goal);
|
|
1665
|
+
this.submitSessionTask({
|
|
1666
|
+
agent,
|
|
1667
|
+
sessionId,
|
|
1668
|
+
workdir,
|
|
1669
|
+
prompt,
|
|
1670
|
+
chatId: opts.chatId,
|
|
1671
|
+
modelId: opts.modelId,
|
|
1672
|
+
thinkingEffort: opts.thinkingEffort,
|
|
1673
|
+
goalContinuation: { kind: 'continuation', goalId: goal.goalId },
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
return normalizeFromPikiloop(goal);
|
|
1677
|
+
}
|
|
1678
|
+
async pauseSessionGoal(workdir, agent, sessionId) {
|
|
1679
|
+
if (agent === 'codex') {
|
|
1680
|
+
if (!sessionId || isPendingSessionId(sessionId))
|
|
1681
|
+
return null;
|
|
1682
|
+
const resp = await pauseCodexGoal(sessionId);
|
|
1683
|
+
if (!resp.ok)
|
|
1684
|
+
throw new Error(resp.error);
|
|
1685
|
+
const goal = resp.goal ?? (await getCodexGoal(sessionId));
|
|
1686
|
+
return goal ? normalizeFromCodex(goal) : null;
|
|
1687
|
+
}
|
|
1688
|
+
if (agent === 'claude') {
|
|
1689
|
+
// Claude's native /goal exposes no pause/resume — only set and clear.
|
|
1690
|
+
// Surface a clear error so the IM layer can render a friendly message.
|
|
1691
|
+
throw new Error('Claude native /goal does not support pause/resume — only `/goal clear`. Re-issue `/goal <objective>` to start fresh.');
|
|
1692
|
+
}
|
|
1693
|
+
const goal = pauseGoal(workdir, agent, sessionId);
|
|
1694
|
+
return goal ? normalizeFromPikiloop(goal) : null;
|
|
1695
|
+
}
|
|
1696
|
+
async resumeSessionGoal(workdir, agent, sessionId, opts = {}) {
|
|
1697
|
+
if (agent === 'codex') {
|
|
1698
|
+
if (!sessionId || isPendingSessionId(sessionId))
|
|
1699
|
+
return null;
|
|
1700
|
+
const resp = await resumeCodexGoal(sessionId);
|
|
1701
|
+
if (!resp.ok)
|
|
1702
|
+
throw new Error(resp.error);
|
|
1703
|
+
const goal = resp.goal ?? (await getCodexGoal(sessionId));
|
|
1704
|
+
return goal ? normalizeFromCodex(goal) : null;
|
|
1705
|
+
}
|
|
1706
|
+
if (agent === 'claude') {
|
|
1707
|
+
throw new Error('Claude native /goal does not support pause/resume — re-issue `/goal <objective>` to start fresh.');
|
|
1708
|
+
}
|
|
1709
|
+
const goal = resumeGoal(workdir, agent, sessionId);
|
|
1710
|
+
if (!goal || goal.status !== 'active')
|
|
1711
|
+
return goal ? normalizeFromPikiloop(goal) : null;
|
|
1712
|
+
if (!isPendingSessionId(sessionId)) {
|
|
1713
|
+
const prompt = renderContinuationPrompt(goal);
|
|
1714
|
+
this.submitSessionTask({
|
|
1715
|
+
agent,
|
|
1716
|
+
sessionId,
|
|
1717
|
+
workdir,
|
|
1718
|
+
prompt,
|
|
1719
|
+
chatId: opts.chatId,
|
|
1720
|
+
modelId: opts.modelId,
|
|
1721
|
+
thinkingEffort: opts.thinkingEffort,
|
|
1722
|
+
goalContinuation: { kind: 'continuation', goalId: goal.goalId },
|
|
1723
|
+
});
|
|
1724
|
+
}
|
|
1725
|
+
return normalizeFromPikiloop(goal);
|
|
1726
|
+
}
|
|
1727
|
+
async clearSessionGoal(workdir, agent, sessionId, opts = {}) {
|
|
1728
|
+
if (agent === 'codex') {
|
|
1729
|
+
if (!sessionId || isPendingSessionId(sessionId))
|
|
1730
|
+
return;
|
|
1731
|
+
const resp = await clearCodexGoal(sessionId);
|
|
1732
|
+
if (!resp.ok)
|
|
1733
|
+
throw new Error(resp.error);
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
if (agent === 'claude') {
|
|
1737
|
+
if (!sessionId || isPendingSessionId(sessionId))
|
|
1738
|
+
return;
|
|
1739
|
+
// Read goal-status first to avoid spawning a no-op turn when nothing is set.
|
|
1740
|
+
const existing = getClaudeNativeGoal(workdir, sessionId);
|
|
1741
|
+
if (!existing)
|
|
1742
|
+
return;
|
|
1743
|
+
this.submitSessionTask({
|
|
1744
|
+
agent,
|
|
1745
|
+
sessionId,
|
|
1746
|
+
workdir,
|
|
1747
|
+
prompt: buildClaudeClearGoalPrompt(),
|
|
1748
|
+
chatId: opts.chatId,
|
|
1749
|
+
modelId: opts.modelId,
|
|
1750
|
+
thinkingEffort: opts.thinkingEffort,
|
|
1751
|
+
});
|
|
1752
|
+
return;
|
|
1753
|
+
}
|
|
1754
|
+
clearGoalState(workdir, agent, sessionId);
|
|
1755
|
+
}
|
|
1756
|
+
cancelTask(taskId) {
|
|
1757
|
+
const task = this.activeTasks.get(taskId) || null;
|
|
1758
|
+
if (!task)
|
|
1759
|
+
return { task: null, interrupted: false, cancelled: false };
|
|
1760
|
+
if (task.status === 'queued') {
|
|
1761
|
+
task.cancelled = true;
|
|
1762
|
+
this.emitStream(task.sessionKey, { type: 'cancelled', taskId });
|
|
1763
|
+
return { task, interrupted: false, cancelled: true };
|
|
1764
|
+
}
|
|
1765
|
+
if (task.status === 'running') {
|
|
1766
|
+
task.cancelled = true;
|
|
1767
|
+
try {
|
|
1768
|
+
task.abort?.();
|
|
1769
|
+
}
|
|
1770
|
+
catch { }
|
|
1771
|
+
return { task, interrupted: true, cancelled: false };
|
|
1772
|
+
}
|
|
1773
|
+
return { task, interrupted: false, cancelled: false };
|
|
1774
|
+
}
|
|
1775
|
+
async steerTask(taskId) {
|
|
1776
|
+
const task = this.activeTasks.get(taskId) || null;
|
|
1777
|
+
if (!task || task.status !== 'queued')
|
|
1778
|
+
return { task, interrupted: false, steered: false };
|
|
1779
|
+
this.markQueueDeferralsForSteer(taskId);
|
|
1780
|
+
const interrupted = this.interruptRunningTask(task.sessionKey, { freezePreview: true });
|
|
1781
|
+
return { task, interrupted, steered: interrupted || !!task };
|
|
1782
|
+
}
|
|
1783
|
+
/**
|
|
1784
|
+
* Stop only the currently running task for a session. Queued tasks are
|
|
1785
|
+
* intentionally left intact and run normally once the chain advances — the
|
|
1786
|
+
* stop button means "abort what's running right now", not "throw away the
|
|
1787
|
+
* queue". To drop a specific queued entry, use the per-row × button which
|
|
1788
|
+
* routes through `cancelTask`.
|
|
1789
|
+
*/
|
|
1790
|
+
stopAllSessionTasks(sessionKey) {
|
|
1791
|
+
return this.stopTasksForSession(sessionKey);
|
|
1792
|
+
}
|
|
1793
|
+
/**
|
|
1794
|
+
* Public "start a fresh session" entry point — wired to the "+ New" button
|
|
1795
|
+
* and the `/new` command. Only clears the chat's session selection so the
|
|
1796
|
+
* next user message lands in a fresh session; the previously selected
|
|
1797
|
+
* session keeps running independently (matching dashboard behaviour, where
|
|
1798
|
+
* each session is its own card and is never aborted by creating another).
|
|
1799
|
+
* Use `cancelTask` / `/stop` to actually interrupt a running task.
|
|
1800
|
+
*/
|
|
1801
|
+
resetConversationForChat(chatId) {
|
|
1802
|
+
const cs = this.chat(chatId);
|
|
1803
|
+
this.resetChatConversation(cs);
|
|
1804
|
+
}
|
|
1805
|
+
adoptExistingSessionForChat(chatId, session) {
|
|
1806
|
+
const cs = this.chat(chatId);
|
|
1807
|
+
this.adoptSession(cs, session);
|
|
1808
|
+
return this.getSelectedSession(cs);
|
|
1809
|
+
}
|
|
1810
|
+
/**
|
|
1811
|
+
* Resume an existing session in a chat and restore the agent's persistent
|
|
1812
|
+
* model / effort / BYOK Profile binding so the next stream — and the IM
|
|
1813
|
+
* picker chips — match the session that was just adopted. This is the
|
|
1814
|
+
* shared "click a row from the workspace list" path used by both the
|
|
1815
|
+
* interactive selector and the text-command `/sessions <#>` flow.
|
|
1816
|
+
*/
|
|
1817
|
+
resumeSessionForChat(chatId, session) {
|
|
1818
|
+
const runtime = this.adoptExistingSessionForChat(chatId, session);
|
|
1819
|
+
if (session.model) {
|
|
1820
|
+
this.switchModelForChat(chatId, session.model, session.profileId ?? null);
|
|
1821
|
+
}
|
|
1822
|
+
else if (session.profileId !== undefined) {
|
|
1823
|
+
this.switchModelForChat(chatId, this.modelForAgent(session.agent), null);
|
|
1824
|
+
}
|
|
1825
|
+
if (session.thinkingEffort) {
|
|
1826
|
+
this.switchEffortForChat(chatId, session.thinkingEffort);
|
|
1827
|
+
}
|
|
1828
|
+
return runtime;
|
|
1829
|
+
}
|
|
1830
|
+
switchAgentForChat(chatId, agent) {
|
|
1831
|
+
const cs = this.chat(chatId);
|
|
1832
|
+
if (cs.agent === agent)
|
|
1833
|
+
return false;
|
|
1834
|
+
// Capture the live session of the *outgoing* agent so the next message to
|
|
1835
|
+
// the new agent can replay it as a handover. We capture BEFORE flipping
|
|
1836
|
+
// cs.agent so the ref is honest about which agent it points at.
|
|
1837
|
+
const prevAgent = cs.agent;
|
|
1838
|
+
const prevSessionId = cs.sessionId && !isPendingSessionId(cs.sessionId) ? cs.sessionId : null;
|
|
1839
|
+
cs.agent = agent;
|
|
1840
|
+
// Pre-existing session of the new agent in this thread — back-and-forth
|
|
1841
|
+
// toggling resumes it without handover. The user's intent is "continue what
|
|
1842
|
+
// I had", not "translate cross-agent".
|
|
1843
|
+
const resumed = this.findThreadSessionRuntime(chatId, cs.activeThreadId, agent);
|
|
1844
|
+
if (resumed) {
|
|
1845
|
+
cs.pendingHandoverFrom = null;
|
|
1846
|
+
this.applySessionSelection(cs, resumed);
|
|
1847
|
+
this.log(`agent switched to ${agent} chat=${chatId} resumed=${resumed.sessionId}`);
|
|
1848
|
+
return true;
|
|
1849
|
+
}
|
|
1850
|
+
// No existing session of the new agent → next message will stage a fresh
|
|
1851
|
+
// one. Park the outgoing session as the handover source. If the outgoing
|
|
1852
|
+
// agent had no live session (e.g. the user is rapidly toggling agents
|
|
1853
|
+
// before sending anything), keep any already-pending handover so the
|
|
1854
|
+
// original source isn't lost across intermediate switches.
|
|
1855
|
+
if (prevSessionId) {
|
|
1856
|
+
cs.pendingHandoverFrom = { agent: prevAgent, sessionId: prevSessionId };
|
|
1857
|
+
}
|
|
1858
|
+
this.resetChatConversation(cs, { clearThread: false });
|
|
1859
|
+
this.log(`agent switched to ${agent} chat=${chatId} handoverFrom=${describeHandoverRef(cs.pendingHandoverFrom)}`);
|
|
1860
|
+
return true;
|
|
1861
|
+
}
|
|
1862
|
+
/**
|
|
1863
|
+
* Switch the active model for a chat. Supports both native (agent CLI's own
|
|
1864
|
+
* auth) and BYOK Profile selections:
|
|
1865
|
+
* - `profileId === undefined` (default) — set native model only; pre-union
|
|
1866
|
+
* callers (text-command channels) keep working unchanged.
|
|
1867
|
+
* - `profileId === null` — explicit clear: drop any active Profile, fall
|
|
1868
|
+
* back to native model.
|
|
1869
|
+
* - `profileId === '<uuid>'` — bind that Profile; `modelId` should match
|
|
1870
|
+
* the Profile's modelId so display surfaces stay in sync.
|
|
1871
|
+
*
|
|
1872
|
+
* The native model field (`agentConfigs[agent].model`) always tracks the
|
|
1873
|
+
* effective model id used by the agent CLI — when a Profile is bound, this
|
|
1874
|
+
* lets `modelForAgent()` return the right display string without an extra
|
|
1875
|
+
* lookup. When unbinding, we leave the field alone so the user's prior
|
|
1876
|
+
* native pick is preserved.
|
|
1877
|
+
*/
|
|
1878
|
+
switchModelForChat(chatId, modelId, profileId) {
|
|
1879
|
+
const cs = this.chat(chatId);
|
|
1880
|
+
// Update activeProfileByAgent first — resolveSessionStreamConfig downstream
|
|
1881
|
+
// reads it via getActiveProfile() during spawn.
|
|
1882
|
+
if (profileId !== undefined) {
|
|
1883
|
+
setActiveProfile(cs.agent, profileId || null);
|
|
1884
|
+
}
|
|
1885
|
+
this.setModelForAgent(cs.agent, modelId);
|
|
1886
|
+
cs.modelId = modelId;
|
|
1887
|
+
const session = this.getSelectedSession(cs);
|
|
1888
|
+
if (session)
|
|
1889
|
+
session.modelId = modelId;
|
|
1890
|
+
this.persistAgentPreference(cs.agent, 'model', modelId);
|
|
1891
|
+
const profileTag = profileId === undefined
|
|
1892
|
+
? ''
|
|
1893
|
+
: profileId
|
|
1894
|
+
? ` profile=${profileId}`
|
|
1895
|
+
: ' profile=(cleared)';
|
|
1896
|
+
this.log(`model switched to ${modelId} for ${cs.agent} chat=${chatId} session=${cs.activeSessionKey || '(none)'}${profileTag}`);
|
|
1897
|
+
}
|
|
1898
|
+
/**
|
|
1899
|
+
* The Profile id currently bound to this agent, if any. Used by the IM
|
|
1900
|
+
* picker to flag "current selection" when the user has a Profile bound —
|
|
1901
|
+
* since multiple Profiles may share the same modelId, a model-id match
|
|
1902
|
+
* alone is ambiguous.
|
|
1903
|
+
*/
|
|
1904
|
+
activeProfileIdForAgent(agent) {
|
|
1905
|
+
return getActiveProfileId(agent);
|
|
1906
|
+
}
|
|
1907
|
+
switchEffortForChat(chatId, effort) {
|
|
1908
|
+
const cs = this.chat(chatId);
|
|
1909
|
+
// "ultra" is a synthetic top rung in the effort picker, NOT a real --effort
|
|
1910
|
+
// value (the claude CLI rejects anything outside low|medium|high|xhigh|max).
|
|
1911
|
+
// It bundles "max reasoning depth + permit multi-agent Workflow
|
|
1912
|
+
// orchestration" — the same pairing as Claude's own `ultracode` mode. Decode
|
|
1913
|
+
// it here, the single apply choke point, so the rest of the pipeline only
|
|
1914
|
+
// ever sees a concrete effort value plus the orthogonal workflow flag.
|
|
1915
|
+
// Because the rungs are mutually exclusive, picking any concrete level also
|
|
1916
|
+
// clears the workflow opt-in (capability-gated — only claude advertises it).
|
|
1917
|
+
const ultra = effort === 'ultra';
|
|
1918
|
+
const realEffort = ultra ? 'max' : effort;
|
|
1919
|
+
this.setEffortForAgent(cs.agent, realEffort);
|
|
1920
|
+
const session = this.getSelectedSession(cs);
|
|
1921
|
+
if (session)
|
|
1922
|
+
session.thinkingEffort = realEffort;
|
|
1923
|
+
this.persistAgentPreference(cs.agent, 'effort', realEffort);
|
|
1924
|
+
if (getDriverCapabilities(cs.agent).workflow) {
|
|
1925
|
+
this.setWorkflowEnabledForAgent(cs.agent, ultra);
|
|
1926
|
+
this.persistAgentPreference(cs.agent, 'workflow', ultra ? '1' : '0');
|
|
1927
|
+
}
|
|
1928
|
+
this.log(`effort switched to ${effort} (effort=${realEffort}, workflow=${ultra}) for ${cs.agent} chat=${chatId}`);
|
|
1929
|
+
}
|
|
1930
|
+
/**
|
|
1931
|
+
* Effort value to *display* in the picker. Workflow is orthogonal under the
|
|
1932
|
+
* hood, but the UI folds "max depth + workflow on" into the single synthetic
|
|
1933
|
+
* `ultra` rung (see {@link switchEffortForChat}), so report it as current when
|
|
1934
|
+
* the agent has orchestration enabled. Mirrors the decomposition above.
|
|
1935
|
+
*/
|
|
1936
|
+
effortSelectionForAgent(agent) {
|
|
1937
|
+
const effort = this.effortForAgent(agent);
|
|
1938
|
+
if (!effort)
|
|
1939
|
+
return null;
|
|
1940
|
+
if (getDriverCapabilities(agent).workflow && this.workflowEnabledForAgent(agent))
|
|
1941
|
+
return 'ultra';
|
|
1942
|
+
return effort;
|
|
1943
|
+
}
|
|
1944
|
+
switchPermissionModeForChat(chatId, mode) {
|
|
1945
|
+
const cs = this.chat(chatId);
|
|
1946
|
+
if (cs.agent === 'claude') {
|
|
1947
|
+
this.agentConfigs.claude.permissionMode = mode;
|
|
1948
|
+
this.resetChatConversation(cs);
|
|
1949
|
+
this.log(`permission mode switched to ${mode} for claude chat=${chatId}`);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
/**
|
|
1953
|
+
* Toggle multi-agent Workflow orchestration for the chat's current agent.
|
|
1954
|
+
* Unlike permission-mode it does NOT reset the conversation — the tool set is
|
|
1955
|
+
* resolved per-invocation, so the change cleanly takes effect on the next
|
|
1956
|
+
* turn without invalidating the session transcript. No-op for agents whose
|
|
1957
|
+
* driver doesn't advertise the capability.
|
|
1958
|
+
*/
|
|
1959
|
+
switchWorkflowForChat(chatId, enabled) {
|
|
1960
|
+
const cs = this.chat(chatId);
|
|
1961
|
+
if (!getDriverCapabilities(cs.agent).workflow) {
|
|
1962
|
+
this.log(`workflow toggle ignored: ${cs.agent} does not support orchestration`);
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
this.setWorkflowEnabledForAgent(cs.agent, enabled);
|
|
1966
|
+
this.persistAgentPreference(cs.agent, 'workflow', enabled ? '1' : '0');
|
|
1967
|
+
this.log(`workflow ${enabled ? 'enabled' : 'disabled'} for ${cs.agent} chat=${chatId}`);
|
|
1968
|
+
}
|
|
1969
|
+
modelForAgent(agent) {
|
|
1970
|
+
// For agents whose CLIs cannot switch model via flags (Hermes uses ACP
|
|
1971
|
+
// session/set_model, which only fires when a BYOK Profile is bound), the
|
|
1972
|
+
// active Profile is the only meaningful source of truth — falling back to
|
|
1973
|
+
// `agentConfigs[agent].model` would surface a stale value the runtime
|
|
1974
|
+
// never actually uses. For agents with native model selectors
|
|
1975
|
+
// (Claude/Codex/Gemini), the user-config field is still authoritative.
|
|
1976
|
+
if (agent === 'hermes') {
|
|
1977
|
+
const bound = getAgentBoundModelId('hermes');
|
|
1978
|
+
if (bound)
|
|
1979
|
+
return bound;
|
|
1980
|
+
}
|
|
1981
|
+
return this.agentConfigs[agent]?.model || '';
|
|
1982
|
+
}
|
|
1983
|
+
/**
|
|
1984
|
+
* Resolve the effective model + thinking effort that a stream for `cs` will run with.
|
|
1985
|
+
* Mirrors the fallback chain used inside runStream() so callers (e.g. submitSessionTask
|
|
1986
|
+
* emitting a 'start' event) can label the active turn before runStream resolves it.
|
|
1987
|
+
*/
|
|
1988
|
+
resolveSessionStreamConfig(cs) {
|
|
1989
|
+
const agentConfig = this.agentConfigs[cs.agent] || {};
|
|
1990
|
+
const sessionWorkdir = cs.workdir || this.workdir;
|
|
1991
|
+
const storedConfig = cs.sessionId && !isPendingSessionId(cs.sessionId)
|
|
1992
|
+
? getSessionStoredConfig(sessionWorkdir, cs.agent, cs.sessionId)
|
|
1993
|
+
: null;
|
|
1994
|
+
const model = (cs.modelId && cs.modelId.trim())
|
|
1995
|
+
|| (storedConfig?.model || '')
|
|
1996
|
+
|| this.modelForAgent(cs.agent)
|
|
1997
|
+
|| null;
|
|
1998
|
+
const effortRaw = (cs.thinkingEffort && cs.thinkingEffort.trim().toLowerCase())
|
|
1999
|
+
|| (storedConfig?.thinkingEffort || '')
|
|
2000
|
+
|| agentConfig.reasoningEffort
|
|
2001
|
+
|| 'high';
|
|
2002
|
+
const effort = cs.agent === 'gemini' ? null : (effortRaw || null);
|
|
2003
|
+
return { model: model || null, effort };
|
|
2004
|
+
}
|
|
2005
|
+
fetchSessions(agent, workdir) {
|
|
2006
|
+
return querySessions({ agent, workdir: workdir || this.workdir });
|
|
2007
|
+
}
|
|
2008
|
+
fetchSessionTail(agent, sessionId, limit, workdir = this.workdir) {
|
|
2009
|
+
return querySessionTail({ agent, sessionId, workdir, limit });
|
|
2010
|
+
}
|
|
2011
|
+
fetchAgents(options = {}) {
|
|
2012
|
+
return listAgents(options);
|
|
2013
|
+
}
|
|
2014
|
+
fetchSkills(workdir) {
|
|
2015
|
+
const wd = workdir || this.workdir;
|
|
2016
|
+
initializeProjectSkills(wd);
|
|
2017
|
+
return listSkills(wd);
|
|
2018
|
+
}
|
|
2019
|
+
fetchModels(agent, workdir) {
|
|
2020
|
+
const wd = workdir || this.workdir;
|
|
2021
|
+
// Provider-aware: when the agent is bound to a BYOK Profile, the
|
|
2022
|
+
// returned model list is the provider's enumerable models. This keeps
|
|
2023
|
+
// IM /models consistent with the dashboard agent card.
|
|
2024
|
+
return resolveAgentModels(agent, { workdir: wd, currentModel: this.modelForAgent(agent) });
|
|
2025
|
+
}
|
|
2026
|
+
setDefaultAgent(agent) {
|
|
2027
|
+
const next = normalizeAgent(agent);
|
|
2028
|
+
const prev = this.defaultAgent;
|
|
2029
|
+
this.defaultAgent = next;
|
|
2030
|
+
for (const [, cs] of this.chats) {
|
|
2031
|
+
if (cs.activeSessionKey || cs.sessionId)
|
|
2032
|
+
continue;
|
|
2033
|
+
if (cs.agent === prev)
|
|
2034
|
+
cs.agent = next;
|
|
2035
|
+
}
|
|
2036
|
+
this.log(`default agent changed to ${next}`);
|
|
2037
|
+
}
|
|
2038
|
+
setModelForAgent(agent, modelId) {
|
|
2039
|
+
const config = this.agentConfigs[agent];
|
|
2040
|
+
if (config)
|
|
2041
|
+
config.model = modelId;
|
|
2042
|
+
this.log(`model for ${agent} changed to ${modelId}`);
|
|
2043
|
+
}
|
|
2044
|
+
effortForAgent(agent) {
|
|
2045
|
+
return this.agentConfigs[agent]?.reasoningEffort || 'high';
|
|
2046
|
+
}
|
|
2047
|
+
setEffortForAgent(agent, effort) {
|
|
2048
|
+
const config = this.agentConfigs[agent];
|
|
2049
|
+
if (config)
|
|
2050
|
+
config.reasoningEffort = effort;
|
|
2051
|
+
this.log(`effort for ${agent} changed to ${effort}`);
|
|
2052
|
+
}
|
|
2053
|
+
workflowEnabledForAgent(agent) {
|
|
2054
|
+
return this.agentConfigs[agent]?.workflowEnabled ?? false;
|
|
2055
|
+
}
|
|
2056
|
+
setWorkflowEnabledForAgent(agent, enabled) {
|
|
2057
|
+
const config = this.agentConfigs[agent];
|
|
2058
|
+
if (config)
|
|
2059
|
+
config.workflowEnabled = enabled;
|
|
2060
|
+
this.log(`workflow for ${agent} changed to ${enabled}`);
|
|
2061
|
+
}
|
|
2062
|
+
/**
|
|
2063
|
+
* Switch Claude's access mode (subscription TUI vs `claude -p` Agent SDK
|
|
2064
|
+
* credits). Persisted preference — takes effect on the NEXT spawned turn
|
|
2065
|
+
* (in-flight streams keep their own opts); does not reset any conversation
|
|
2066
|
+
* since both modes resume the same native session transcript.
|
|
2067
|
+
*/
|
|
2068
|
+
setClaudeAccessMode(mode) {
|
|
2069
|
+
const config = this.agentConfigs.claude;
|
|
2070
|
+
if (config)
|
|
2071
|
+
config.accessMode = mode;
|
|
2072
|
+
this.log(`claude access mode changed to ${mode}`);
|
|
2073
|
+
}
|
|
2074
|
+
persistAgentPreference(agent, kind, value) {
|
|
2075
|
+
try {
|
|
2076
|
+
// Hermes model writes go to the active BYOK Profile (the runtime's only
|
|
2077
|
+
// model-switching surface). Falls through to the legacy `hermesModel`
|
|
2078
|
+
// user-config field when no Profile is bound.
|
|
2079
|
+
if (kind === 'model' && agent === 'hermes' && setAgentBoundModelId('hermes', value))
|
|
2080
|
+
return;
|
|
2081
|
+
// Workflow orchestration opt-in is a boolean field, and only claude
|
|
2082
|
+
// advertises the capability, so it bypasses the string patch below.
|
|
2083
|
+
if (kind === 'workflow') {
|
|
2084
|
+
if (agent === 'claude')
|
|
2085
|
+
updateUserConfig({ claudeWorkflowEnabled: value === '1' });
|
|
2086
|
+
return;
|
|
2087
|
+
}
|
|
2088
|
+
const patch = {};
|
|
2089
|
+
if (kind === 'model') {
|
|
2090
|
+
if (agent === 'claude')
|
|
2091
|
+
patch.claudeModel = value;
|
|
2092
|
+
else if (agent === 'codex')
|
|
2093
|
+
patch.codexModel = value;
|
|
2094
|
+
else if (agent === 'gemini')
|
|
2095
|
+
patch.geminiModel = value;
|
|
2096
|
+
else if (agent === 'hermes')
|
|
2097
|
+
patch.hermesModel = value;
|
|
2098
|
+
}
|
|
2099
|
+
else {
|
|
2100
|
+
if (agent === 'claude')
|
|
2101
|
+
patch.claudeReasoningEffort = value;
|
|
2102
|
+
else if (agent === 'codex')
|
|
2103
|
+
patch.codexReasoningEffort = value;
|
|
2104
|
+
else if (agent === 'gemini')
|
|
2105
|
+
patch.geminiReasoningEffort = value;
|
|
2106
|
+
else if (agent === 'hermes')
|
|
2107
|
+
patch.hermesReasoningEffort = value;
|
|
2108
|
+
}
|
|
2109
|
+
if (Object.keys(patch).length)
|
|
2110
|
+
updateUserConfig(patch);
|
|
2111
|
+
}
|
|
2112
|
+
catch (e) {
|
|
2113
|
+
this.warn(`persistAgentPreference failed: ${e?.message || e}`);
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
getStatusData(chatId) {
|
|
2117
|
+
const cs = this.chat(chatId);
|
|
2118
|
+
const selectedSession = this.getSelectedSession(cs);
|
|
2119
|
+
const selectedTask = this.runningTaskForSession(selectedSession?.key ?? null);
|
|
2120
|
+
const fallbackTask = selectedTask || [...this.activeTasks.values()]
|
|
2121
|
+
.sort((a, b) => a.startedAt - b.startedAt)[0] || null;
|
|
2122
|
+
const model = selectedSession?.modelId || this.modelForAgent(cs.agent);
|
|
2123
|
+
const mem = process.memoryUsage();
|
|
2124
|
+
return {
|
|
2125
|
+
version: VERSION, uptime: Date.now() - this.startedAt,
|
|
2126
|
+
memRss: mem.rss, memHeap: mem.heapUsed, pid: process.pid,
|
|
2127
|
+
workdir: this.chatWorkdir(chatId), agent: cs.agent, model, sessionId: cs.sessionId,
|
|
2128
|
+
workspacePath: cs.workspacePath ?? null,
|
|
2129
|
+
running: fallbackTask, activeTasksCount: this.activeTasks.size, stats: this.stats,
|
|
2130
|
+
usage: getUsage({ agent: cs.agent, model }),
|
|
2131
|
+
};
|
|
2132
|
+
}
|
|
2133
|
+
getHostData() {
|
|
2134
|
+
const cpus = os.cpus();
|
|
2135
|
+
const totalMem = os.totalmem(), freeMem = os.freemem();
|
|
2136
|
+
const memory = getHostMemoryUsageData(totalMem, freeMem);
|
|
2137
|
+
const cpuUsage = getHostCpuUsageData();
|
|
2138
|
+
const [loadOne, loadFive, loadFifteen] = os.loadavg();
|
|
2139
|
+
let disk = null;
|
|
2140
|
+
const battery = getHostBatteryData();
|
|
2141
|
+
try {
|
|
2142
|
+
if (process.platform === 'win32') {
|
|
2143
|
+
const driveLetter = this.workdir.charAt(0).toUpperCase();
|
|
2144
|
+
const psOut = execSync(`powershell -NoProfile -Command "Get-PSDrive -Name ${driveLetter} | ForEach-Object { [PSCustomObject]@{Used=$_.Used;Free=$_.Free} } | ConvertTo-Json"`, { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
2145
|
+
const info = JSON.parse(psOut);
|
|
2146
|
+
if (info.Used != null && info.Free != null) {
|
|
2147
|
+
const used = Number(info.Used), free = Number(info.Free), total = used + free;
|
|
2148
|
+
const fmt = (b) => b >= 1e12 ? `${(b / 1e12).toFixed(1)}T` : b >= 1e9 ? `${(b / 1e9).toFixed(1)}G` : b >= 1e6 ? `${(b / 1e6).toFixed(1)}M` : `${Math.round(b / 1e3)}K`;
|
|
2149
|
+
disk = { used: fmt(used), total: fmt(total), percent: `${Math.round(used / total * 100)}%` };
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
else {
|
|
2153
|
+
const df = execSync(`df -h "${this.workdir}" | tail -1`, { encoding: 'utf-8', timeout: 3000 }).trim().split(/\s+/);
|
|
2154
|
+
if (df.length >= 5)
|
|
2155
|
+
disk = { used: df[2], total: df[1], percent: df[4] };
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
catch { }
|
|
2159
|
+
let topProcs = [];
|
|
2160
|
+
try {
|
|
2161
|
+
if (process.platform === 'win32') {
|
|
2162
|
+
topProcs = execSync(`powershell -NoProfile -Command "Get-Process | Sort-Object -Property CPU -Descending | Select-Object -First 5 | ForEach-Object { \\"$($_.Id) $([math]::Round($_.CPU)) $([math]::Round($_.WorkingSet64/1MB)) $($_.ProcessName)\\"" }"`, { encoding: 'utf-8', timeout: 5000 }).trim().split('\n');
|
|
2163
|
+
}
|
|
2164
|
+
else {
|
|
2165
|
+
topProcs = execSync(`ps -eo pid,pcpu,pmem,comm --sort=-pcpu 2>/dev/null | head -6 || ps -eo pid,%cpu,%mem,comm -r 2>/dev/null | head -6`, { encoding: 'utf-8', timeout: 3000 }).trim().split('\n');
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
catch { }
|
|
2169
|
+
const mem = process.memoryUsage();
|
|
2170
|
+
return {
|
|
2171
|
+
hostName: getHostDisplayName(),
|
|
2172
|
+
cpuModel: cpus[0]?.model || 'unknown', cpuCount: cpus.length,
|
|
2173
|
+
cpuUsage,
|
|
2174
|
+
loadAverage: { one: loadOne, five: loadFive, fifteen: loadFifteen },
|
|
2175
|
+
totalMem, freeMem, memoryUsed: memory.usedBytes, memoryAvailable: memory.availableBytes, memoryPercent: memory.percent, memorySource: memory.source,
|
|
2176
|
+
disk, battery, topProcs,
|
|
2177
|
+
selfPid: process.pid, selfRss: mem.rss, selfHeap: mem.heapUsed,
|
|
2178
|
+
};
|
|
2179
|
+
}
|
|
2180
|
+
switchWorkdir(newPath, opts = {}) {
|
|
2181
|
+
const old = this.workdir;
|
|
2182
|
+
const resolvedPath = path.resolve(expandTilde(newPath));
|
|
2183
|
+
if (opts.persist !== false) {
|
|
2184
|
+
setUserWorkdir(resolvedPath, { notify: false });
|
|
2185
|
+
}
|
|
2186
|
+
else {
|
|
2187
|
+
process.env.PIKILOOP_WORKDIR = resolvedPath;
|
|
2188
|
+
}
|
|
2189
|
+
this.workdir = resolvedPath;
|
|
2190
|
+
for (const [, cs] of this.chats) {
|
|
2191
|
+
this.resetChatConversation(cs, { clearWorkdir: true });
|
|
2192
|
+
}
|
|
2193
|
+
for (const [key, session] of this.sessionStates) {
|
|
2194
|
+
if (session.workdir === old && !session.runningTaskIds.size)
|
|
2195
|
+
this.sessionStates.delete(key);
|
|
2196
|
+
}
|
|
2197
|
+
ensureGitignore(resolvedPath);
|
|
2198
|
+
initializeProjectSkills(resolvedPath);
|
|
2199
|
+
this.log(`switch workdir: ${old} -> ${resolvedPath}`);
|
|
2200
|
+
this.afterSwitchWorkdir(old, resolvedPath);
|
|
2201
|
+
return old;
|
|
2202
|
+
}
|
|
2203
|
+
afterSwitchWorkdir(_oldPath, _newPath) { }
|
|
2204
|
+
onManagedConfigChange(_config, _opts = {}) { }
|
|
2205
|
+
/**
|
|
2206
|
+
* Subclass entry point — connect to the channel and block on its
|
|
2207
|
+
* listen loop. Each channel implementation overrides this; calling it
|
|
2208
|
+
* on the base class is a programming error.
|
|
2209
|
+
*/
|
|
2210
|
+
run() {
|
|
2211
|
+
throw new Error('Bot.run() must be implemented by a channel subclass');
|
|
2212
|
+
}
|
|
2213
|
+
/**
|
|
2214
|
+
* Subclass hook: tear down the channel transport so `run()` can resolve.
|
|
2215
|
+
* Subclasses override to disconnect their specific channel — the base
|
|
2216
|
+
* implementation only cleans up the bot-level subscriptions that don't
|
|
2217
|
+
* belong to any one channel.
|
|
2218
|
+
*
|
|
2219
|
+
* Used by ChannelSupervisor when a channel must be stopped or replaced
|
|
2220
|
+
* in-process (channel removal, credential rotation) without restarting
|
|
2221
|
+
* the entire pikiloop runtime.
|
|
2222
|
+
*/
|
|
2223
|
+
requestStop() {
|
|
2224
|
+
this.userConfigUnsubscribe?.();
|
|
2225
|
+
this.userConfigUnsubscribe = null;
|
|
2226
|
+
}
|
|
2227
|
+
/**
|
|
2228
|
+
* Scan registered workspaces + the active workdir for sessions stuck in
|
|
2229
|
+
* 'running' state after a crash/restart and downgrade them to 'incomplete'.
|
|
2230
|
+
* Safe to call at any time — only touches records whose owning process is
|
|
2231
|
+
* no longer alive (or that have gone stale past the age threshold).
|
|
2232
|
+
*/
|
|
2233
|
+
reconcileStaleRunningSessions() {
|
|
2234
|
+
const seen = new Set();
|
|
2235
|
+
const candidates = [this.workdir];
|
|
2236
|
+
try {
|
|
2237
|
+
for (const ws of loadWorkspaces())
|
|
2238
|
+
candidates.push(ws.path);
|
|
2239
|
+
}
|
|
2240
|
+
catch { }
|
|
2241
|
+
for (const candidate of candidates) {
|
|
2242
|
+
const resolved = path.resolve(candidate);
|
|
2243
|
+
if (seen.has(resolved))
|
|
2244
|
+
continue;
|
|
2245
|
+
seen.add(resolved);
|
|
2246
|
+
try {
|
|
2247
|
+
reconcileOrphanedRunningSessions(resolved);
|
|
2248
|
+
}
|
|
2249
|
+
catch { }
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
refreshManagedConfig(config, opts = {}) {
|
|
2253
|
+
const nextWorkdir = resolveUserWorkdir({ config });
|
|
2254
|
+
if (opts.initial) {
|
|
2255
|
+
this.workdir = nextWorkdir;
|
|
2256
|
+
ensureGitignore(this.workdir);
|
|
2257
|
+
initializeProjectSkills(this.workdir);
|
|
2258
|
+
this.reconcileStaleRunningSessions();
|
|
2259
|
+
}
|
|
2260
|
+
else if (nextWorkdir !== this.workdir) {
|
|
2261
|
+
this.switchWorkdir(nextWorkdir, { persist: false });
|
|
2262
|
+
}
|
|
2263
|
+
// The configured value is a *preference* (baseline 'codex' when unset);
|
|
2264
|
+
// clamp it to an installed agent so a fresh machine whose preferred CLI
|
|
2265
|
+
// isn't installed still routes new conversations to one that can actually
|
|
2266
|
+
// run, instead of surfacing an uninstalled default.
|
|
2267
|
+
const nextDefaultAgent = resolveDefaultAgent(config.defaultAgent || 'codex', listAgents().agents);
|
|
2268
|
+
if (opts.initial)
|
|
2269
|
+
this.defaultAgent = nextDefaultAgent;
|
|
2270
|
+
else if (nextDefaultAgent !== this.defaultAgent)
|
|
2271
|
+
this.setDefaultAgent(nextDefaultAgent);
|
|
2272
|
+
for (const agent of ['claude', 'codex', 'gemini', 'hermes']) {
|
|
2273
|
+
const nextModel = resolveAgentModel(config, agent);
|
|
2274
|
+
if (nextModel && this.modelForAgent(agent) !== nextModel) {
|
|
2275
|
+
if (opts.initial)
|
|
2276
|
+
this.agentConfigs[agent].model = nextModel;
|
|
2277
|
+
else
|
|
2278
|
+
this.setModelForAgent(agent, nextModel);
|
|
2279
|
+
}
|
|
2280
|
+
const nextEffort = resolveAgentEffort(config, agent);
|
|
2281
|
+
if (nextEffort && this.effortForAgent(agent) !== nextEffort) {
|
|
2282
|
+
if (opts.initial)
|
|
2283
|
+
this.agentConfigs[agent].reasoningEffort = nextEffort;
|
|
2284
|
+
else
|
|
2285
|
+
this.setEffortForAgent(agent, nextEffort);
|
|
2286
|
+
}
|
|
2287
|
+
// Access mode (claude only) IS reconciled — unlike workflow, it's a
|
|
2288
|
+
// persisted preference, so an external setting.json edit or a dashboard
|
|
2289
|
+
// save (which both flow through here via onUserConfigChange) must push
|
|
2290
|
+
// the new value onto the running bot so the next turn spawns accordingly.
|
|
2291
|
+
if (agent === 'claude') {
|
|
2292
|
+
const nextAccessMode = resolveClaudeAccessMode(config);
|
|
2293
|
+
if (this.claudeAccessMode !== nextAccessMode) {
|
|
2294
|
+
if (opts.initial)
|
|
2295
|
+
this.agentConfigs.claude.accessMode = nextAccessMode;
|
|
2296
|
+
else
|
|
2297
|
+
this.setClaudeAccessMode(nextAccessMode);
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
// Workflow is intentionally NOT reconciled from config here: it's an
|
|
2301
|
+
// in-memory per-session toggle (composer / IM /mode), so a config-sync
|
|
2302
|
+
// tick must not clobber a deliberate in-session choice.
|
|
2303
|
+
}
|
|
2304
|
+
if (!opts.initial)
|
|
2305
|
+
this.onManagedConfigChange(config, opts);
|
|
2306
|
+
}
|
|
2307
|
+
async runStream(prompt, cs, attachments, onText, systemPrompt, mcpSendFile, abortSignal, onInteraction, onSteerReady, onCodexTurnReady, extras) {
|
|
2308
|
+
const agentConfig = this.agentConfigs[cs.agent] || {};
|
|
2309
|
+
// Session-level config stored on disk — used as fallback between explicit override and global defaults
|
|
2310
|
+
const sessionWorkdirForConfig = 'workdir' in cs && typeof cs.workdir === 'string' && cs.workdir ? cs.workdir : this.workdir;
|
|
2311
|
+
const storedConfig = cs.sessionId && !isPendingSessionId(cs.sessionId)
|
|
2312
|
+
? getSessionStoredConfig(sessionWorkdirForConfig, cs.agent, cs.sessionId)
|
|
2313
|
+
: null;
|
|
2314
|
+
const resolvedModel = cs.modelId || storedConfig?.model || this.modelForAgent(cs.agent);
|
|
2315
|
+
const resolvedThinkingEffort = ('thinkingEffort' in cs && typeof cs.thinkingEffort === 'string' && cs.thinkingEffort.trim())
|
|
2316
|
+
? cs.thinkingEffort.trim().toLowerCase()
|
|
2317
|
+
: (storedConfig?.thinkingEffort || agentConfig.reasoningEffort || 'high');
|
|
2318
|
+
const extraArgs = agentConfig.extraArgs || [];
|
|
2319
|
+
const browserEnabled = resolveGuiIntegrationConfig(getActiveUserConfig()).browserEnabled;
|
|
2320
|
+
const sessionWorkdir = 'workdir' in cs && typeof cs.workdir === 'string' && cs.workdir
|
|
2321
|
+
? path.resolve(cs.workdir)
|
|
2322
|
+
: this.workdir;
|
|
2323
|
+
this.debug(`[runStream] agent=${cs.agent} session=${cs.sessionId || '(new)'} workdir=${sessionWorkdir} timeout=${this.runTimeout}s attachments=${attachments.length}`);
|
|
2324
|
+
this.debug(`[runStream] ${cs.agent} config: model=${resolvedModel} extraArgs=[${extraArgs.join(' ')}]`);
|
|
2325
|
+
const isFirstTurnOfSession = !cs.sessionId || isPendingSessionId(cs.sessionId);
|
|
2326
|
+
// ── Cross-agent handover ──
|
|
2327
|
+
// First turn of a session created by an agent switch: read the prior agent's
|
|
2328
|
+
// session, compact it, and prepend the seed to this turn's prompt. After this
|
|
2329
|
+
// single injection the new agent owns the canonical session file and `--resume`
|
|
2330
|
+
// takes over. See agent/handover.ts.
|
|
2331
|
+
const handoverFrom = ('handoverFrom' in cs && cs.handoverFrom) ? cs.handoverFrom : null;
|
|
2332
|
+
if (isFirstTurnOfSession && handoverFrom) {
|
|
2333
|
+
try {
|
|
2334
|
+
const result = await compactForHandover({
|
|
2335
|
+
fromAgent: handoverFrom.agent,
|
|
2336
|
+
fromSessionId: handoverFrom.sessionId,
|
|
2337
|
+
workdir: sessionWorkdir,
|
|
2338
|
+
toAgent: cs.agent,
|
|
2339
|
+
toModel: resolvedModel,
|
|
2340
|
+
});
|
|
2341
|
+
if (result.ok && result.seed) {
|
|
2342
|
+
prompt = result.seed + '\n\n' + prompt;
|
|
2343
|
+
this.debug(`[runStream] handover ${describeHandoverRef(handoverFrom)} → ${cs.agent} `
|
|
2344
|
+
+ `mode=${result.mode} msgs=${result.messagesIncluded}/${result.messagesTotal} `
|
|
2345
|
+
+ `turnsTotal=${result.turnsTotal} chars=${result.charsIncluded}/${result.budgetChars}`);
|
|
2346
|
+
}
|
|
2347
|
+
else {
|
|
2348
|
+
this.warn(`[runStream] handover ${describeHandoverRef(handoverFrom)} → ${cs.agent} `
|
|
2349
|
+
+ `failed (${result.error || 'unknown'}); proceeding without prior context`);
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
catch (e) {
|
|
2353
|
+
this.warn(`[runStream] handover threw: ${e?.message || e}; proceeding without prior context`);
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
// Per-turn workflow opt-in (dashboard composer passes it explicitly);
|
|
2357
|
+
// falls back to the agent's in-memory flag (IM /mode) when unspecified.
|
|
2358
|
+
// Default off — never read from a persisted config default.
|
|
2359
|
+
const workflowEnabled = cs.agent === 'claude' && (extras?.workflowEnabled ?? this.claudeWorkflowEnabled);
|
|
2360
|
+
const mcpSystemPrompt = appendExtraPrompt(appendExtraPrompt(appendExtraPrompt(mcpSendFile ? buildMcpDeliveryPrompt() : '', onInteraction && cs.agent === 'claude' ? buildClaudeAskUserPrompt() : ''), buildBrowserAutomationPrompt(browserEnabled)), workflowEnabled ? buildWorkflowOptInPrompt() : '');
|
|
2361
|
+
// mcpSystemPrompt carries behaviour directives (use im_ask_user instead of
|
|
2362
|
+
// built-in AskUserQuestion, browser automation status, artifact delivery)
|
|
2363
|
+
// that must apply on every turn, not just the first — on resume the CLI
|
|
2364
|
+
// does not automatically re-inject the previous --append-system-prompt
|
|
2365
|
+
// contents, so Claude silently regresses to the built-in tools on turn 2+.
|
|
2366
|
+
// The caller-supplied `systemPrompt` (per-task scaffolding) remains
|
|
2367
|
+
// first-turn-only since later turns inherit it via the session transcript.
|
|
2368
|
+
const effectiveSystemPrompt = isFirstTurnOfSession
|
|
2369
|
+
? appendExtraPrompt(systemPrompt, mcpSystemPrompt)
|
|
2370
|
+
: (mcpSystemPrompt || undefined);
|
|
2371
|
+
const syncNativeSessionId = (nativeSessionId) => {
|
|
2372
|
+
const resolvedSessionId = nativeSessionId.trim();
|
|
2373
|
+
if (!resolvedSessionId)
|
|
2374
|
+
return;
|
|
2375
|
+
if ('key' in cs && typeof cs.key === 'string') {
|
|
2376
|
+
const runtime = this.getSessionRuntimeByKey(cs.key, { allowAnyWorkdir: true });
|
|
2377
|
+
if (runtime) {
|
|
2378
|
+
this.promoteSessionRuntime(runtime, resolvedSessionId);
|
|
2379
|
+
return;
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
cs.sessionId = resolvedSessionId;
|
|
2383
|
+
};
|
|
2384
|
+
const opts = {
|
|
2385
|
+
agent: cs.agent, prompt, workdir: sessionWorkdir, timeout: this.runTimeout,
|
|
2386
|
+
sessionId: cs.sessionId, model: null,
|
|
2387
|
+
thinkingEffort: resolvedThinkingEffort, onText,
|
|
2388
|
+
onSessionId: syncNativeSessionId,
|
|
2389
|
+
attachments: attachments.length ? attachments : undefined,
|
|
2390
|
+
// codex-specific
|
|
2391
|
+
codexModel: cs.agent === 'codex' ? resolvedModel : this.codexModel,
|
|
2392
|
+
codexFullAccess: this.codexFullAccess,
|
|
2393
|
+
codexDeveloperInstructions: effectiveSystemPrompt || undefined,
|
|
2394
|
+
codexExtraArgs: this.codexExtraArgs.length ? this.codexExtraArgs : undefined,
|
|
2395
|
+
codexPrevCumulative: cs.codexCumulative,
|
|
2396
|
+
// claude-specific
|
|
2397
|
+
claudeModel: cs.agent === 'claude' ? resolvedModel : this.claudeModel,
|
|
2398
|
+
claudePermissionMode: this.claudePermissionMode,
|
|
2399
|
+
claudeWorkflowEnabled: workflowEnabled,
|
|
2400
|
+
// Resolved per-stream so a live access-mode switch applies to new turns
|
|
2401
|
+
// while in-flight streams keep the mode they spawned with.
|
|
2402
|
+
claudeAccessMode: cs.agent === 'claude' ? this.claudeAccessMode : undefined,
|
|
2403
|
+
claudeAppendSystemPrompt: effectiveSystemPrompt || undefined,
|
|
2404
|
+
claudeExtraArgs: this.claudeExtraArgs.length ? this.claudeExtraArgs : undefined,
|
|
2405
|
+
// gemini-specific
|
|
2406
|
+
geminiModel: cs.agent === 'gemini' ? resolvedModel : (this.agentConfigs.gemini?.model || ''),
|
|
2407
|
+
geminiApprovalMode: this.geminiApprovalMode,
|
|
2408
|
+
geminiSandbox: this.geminiSandbox,
|
|
2409
|
+
geminiSystemInstruction: effectiveSystemPrompt || undefined,
|
|
2410
|
+
geminiExtraArgs: this.geminiExtraArgs.length ? this.geminiExtraArgs : undefined,
|
|
2411
|
+
// hermes-specific. Wire the chat's current model so /models switching in
|
|
2412
|
+
// IM takes effect even without a BYOK Profile (the BYOK injector in
|
|
2413
|
+
// stream.ts overrides this with the ACP-encoded `provider:model` when
|
|
2414
|
+
// a Profile is bound).
|
|
2415
|
+
hermesModel: cs.agent === 'hermes' && resolvedModel ? resolvedModel : undefined,
|
|
2416
|
+
// MCP bridge
|
|
2417
|
+
mcpSendFile,
|
|
2418
|
+
abortSignal,
|
|
2419
|
+
onInteraction,
|
|
2420
|
+
onSteerReady,
|
|
2421
|
+
onCodexTurnReady,
|
|
2422
|
+
// Fork lineage — when set, the driver branches off the parent session.
|
|
2423
|
+
forkOf: extras?.forkOf,
|
|
2424
|
+
};
|
|
2425
|
+
const result = await doStream(opts);
|
|
2426
|
+
this.stats.totalTurns++;
|
|
2427
|
+
if (result.inputTokens)
|
|
2428
|
+
this.stats.totalInputTokens += result.inputTokens;
|
|
2429
|
+
if (result.outputTokens)
|
|
2430
|
+
this.stats.totalOutputTokens += result.outputTokens;
|
|
2431
|
+
if (result.cachedInputTokens)
|
|
2432
|
+
this.stats.totalCachedTokens += result.cachedInputTokens;
|
|
2433
|
+
if (result.codexCumulative)
|
|
2434
|
+
cs.codexCumulative = result.codexCumulative;
|
|
2435
|
+
if (result.sessionId)
|
|
2436
|
+
syncNativeSessionId(result.sessionId);
|
|
2437
|
+
if (result.workspacePath)
|
|
2438
|
+
cs.workspacePath = result.workspacePath;
|
|
2439
|
+
if (result.model)
|
|
2440
|
+
cs.modelId = result.model;
|
|
2441
|
+
if ('key' in cs && typeof cs.key === 'string') {
|
|
2442
|
+
const runtime = this.getSessionRuntimeByKey(cs.key, { allowAnyWorkdir: true });
|
|
2443
|
+
if (runtime)
|
|
2444
|
+
this.syncSelectedChats(runtime);
|
|
2445
|
+
}
|
|
2446
|
+
this.debug(`[runStream] completed turn=${this.stats.totalTurns} cumulative: in=${fmtTokens(this.stats.totalInputTokens)} out=${fmtTokens(this.stats.totalOutputTokens)} cached=${fmtTokens(this.stats.totalCachedTokens)}`);
|
|
2447
|
+
return result;
|
|
2448
|
+
}
|
|
2449
|
+
startKeepAlive() {
|
|
2450
|
+
if (process.platform === 'darwin') {
|
|
2451
|
+
if (this.keepAliveProc || this.keepAlivePulseTimer)
|
|
2452
|
+
return;
|
|
2453
|
+
const bin = whichSync('caffeinate');
|
|
2454
|
+
if (bin) {
|
|
2455
|
+
// `-dis` = prevent display sleep + idle sleep + system sleep. The `-d`
|
|
2456
|
+
// (display) flag is intentional: the agent uses macOS `screencapture`
|
|
2457
|
+
// for desktop screenshots, which returns a black frame once the
|
|
2458
|
+
// display sleeps. Users who would rather let the screen turn off
|
|
2459
|
+
// should drop brightness or close the lid against an external display.
|
|
2460
|
+
this.keepAliveProc = spawn('caffeinate', ['-dis'], { stdio: 'ignore', detached: true });
|
|
2461
|
+
this.keepAliveProc.unref();
|
|
2462
|
+
this.log(`keep-alive: caffeinate (PID ${this.keepAliveProc.pid})`);
|
|
2463
|
+
const pulseUserActivity = () => {
|
|
2464
|
+
const pulse = spawn('caffeinate', ['-u', '-t', String(MACOS_USER_ACTIVITY_PULSE_TIMEOUT_S)], {
|
|
2465
|
+
stdio: 'ignore',
|
|
2466
|
+
detached: true,
|
|
2467
|
+
});
|
|
2468
|
+
pulse.unref();
|
|
2469
|
+
};
|
|
2470
|
+
pulseUserActivity();
|
|
2471
|
+
this.keepAlivePulseTimer = setInterval(pulseUserActivity, MACOS_USER_ACTIVITY_PULSE_INTERVAL_MS);
|
|
2472
|
+
this.keepAlivePulseTimer.unref?.();
|
|
2473
|
+
this.log(`keep-alive: macOS user activity pulse every ${MACOS_USER_ACTIVITY_PULSE_INTERVAL_MS / 1000}s`);
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
else if (process.platform === 'linux') {
|
|
2477
|
+
if (this.keepAliveProc)
|
|
2478
|
+
return;
|
|
2479
|
+
const bin = whichSync('systemd-inhibit');
|
|
2480
|
+
if (bin) {
|
|
2481
|
+
this.keepAliveProc = spawn('systemd-inhibit', [
|
|
2482
|
+
'--what=idle', '--who=pikiloop', '--why=AI coding agent running', 'sleep', 'infinity',
|
|
2483
|
+
], { stdio: 'ignore', detached: true });
|
|
2484
|
+
this.keepAliveProc.unref();
|
|
2485
|
+
this.log(`keep-alive: systemd-inhibit (PID ${this.keepAliveProc.pid})`);
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
stopKeepAlive() {
|
|
2490
|
+
if (this.keepAlivePulseTimer) {
|
|
2491
|
+
clearInterval(this.keepAlivePulseTimer);
|
|
2492
|
+
this.keepAlivePulseTimer = null;
|
|
2493
|
+
}
|
|
2494
|
+
if (this.keepAliveProc) {
|
|
2495
|
+
terminateProcessTree(this.keepAliveProc, { signal: 'SIGTERM', forceSignal: 'SIGKILL', forceAfterMs: 2000 });
|
|
2496
|
+
this.keepAliveProc = null;
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
}
|