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
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared transport health tracker for IM channel listen / connect loops.
|
|
3
|
+
*
|
|
4
|
+
* Solves three problems each channel was reinventing:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Exponential backoff** — initial → max with doubling between attempts,
|
|
7
|
+
* reset on success.
|
|
8
|
+
* 2. **Log throttling** — continuous failures emit a log line only when the
|
|
9
|
+
* retry-delay bucket changes (1s → 2s → 4s → …), so identical failures
|
|
10
|
+
* don't flood the journal once per attempt.
|
|
11
|
+
* 3. **Sustained-failure notice** — after a configurable threshold (5 min
|
|
12
|
+
* by default) of uninterrupted failures, emit a one-shot warn-level
|
|
13
|
+
* line pointing the operator at the relevant config. Retries continue
|
|
14
|
+
* indefinitely — the notice is informational, not a stop signal.
|
|
15
|
+
*
|
|
16
|
+
* Usage (long-poll style):
|
|
17
|
+
*
|
|
18
|
+
* ```ts
|
|
19
|
+
* const health = new ChannelHealth({ label: 'Weixin', opAction: 'polling',
|
|
20
|
+
* initialDelayMs: 1_000, maxDelayMs: 60_000,
|
|
21
|
+
* sustainedFailureHint: 'verify baseUrl / token / accountId',
|
|
22
|
+
* log: (msg, level) => this.log(msg, level) });
|
|
23
|
+
*
|
|
24
|
+
* while (!stopping) {
|
|
25
|
+
* try {
|
|
26
|
+
* await poll();
|
|
27
|
+
* health.recordSuccess();
|
|
28
|
+
* // …process result…
|
|
29
|
+
* } catch (err) {
|
|
30
|
+
* if (stopping || isAbort(err)) break;
|
|
31
|
+
* await sleep(health.recordFailure(err));
|
|
32
|
+
* }
|
|
33
|
+
* }
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
function describeError(error) {
|
|
37
|
+
return error instanceof Error ? error.message : String(error ?? 'unknown error');
|
|
38
|
+
}
|
|
39
|
+
export class ChannelHealth {
|
|
40
|
+
label;
|
|
41
|
+
opAction;
|
|
42
|
+
initialDelayMs;
|
|
43
|
+
maxDelayMs;
|
|
44
|
+
log;
|
|
45
|
+
sustainedThresholdMs;
|
|
46
|
+
sustainedFailureHint;
|
|
47
|
+
transientFailureLevel;
|
|
48
|
+
delayMs;
|
|
49
|
+
consecutiveFailures = 0;
|
|
50
|
+
firstFailureAt = null;
|
|
51
|
+
lastLoggedDelayMs = 0;
|
|
52
|
+
sustainedNoticeFired = false;
|
|
53
|
+
constructor(opts) {
|
|
54
|
+
this.label = opts.label;
|
|
55
|
+
this.opAction = opts.opAction;
|
|
56
|
+
this.initialDelayMs = opts.initialDelayMs;
|
|
57
|
+
this.maxDelayMs = opts.maxDelayMs;
|
|
58
|
+
this.log = opts.log;
|
|
59
|
+
this.sustainedThresholdMs = opts.sustainedThresholdMs ?? 5 * 60_000;
|
|
60
|
+
this.sustainedFailureHint = opts.sustainedFailureHint ?? '';
|
|
61
|
+
this.transientFailureLevel = opts.transientFailureLevel ?? 'warn';
|
|
62
|
+
this.delayMs = opts.initialDelayMs;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Mark the operation as currently succeeding. Resets backoff state and,
|
|
66
|
+
* if there were prior failures, emits an info-level recovery line.
|
|
67
|
+
*/
|
|
68
|
+
recordSuccess() {
|
|
69
|
+
if (this.consecutiveFailures > 0 && this.firstFailureAt !== null) {
|
|
70
|
+
const downtimeMs = Date.now() - this.firstFailureAt;
|
|
71
|
+
this.log(`${this.label}: connection recovered after ${Math.round(downtimeMs / 1000)}s `
|
|
72
|
+
+ `(${this.consecutiveFailures} failed attempts)`, 'info');
|
|
73
|
+
}
|
|
74
|
+
this.delayMs = this.initialDelayMs;
|
|
75
|
+
this.consecutiveFailures = 0;
|
|
76
|
+
this.firstFailureAt = null;
|
|
77
|
+
this.lastLoggedDelayMs = 0;
|
|
78
|
+
this.sustainedNoticeFired = false;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Record a failure. Emits the per-bucket failure log (throttled) and the
|
|
82
|
+
* one-shot sustained-failure notice when applicable, then advances the
|
|
83
|
+
* backoff for the *next* failure. Returns the delay (ms) the caller
|
|
84
|
+
* should sleep before its next attempt.
|
|
85
|
+
*/
|
|
86
|
+
recordFailure(error) {
|
|
87
|
+
this.consecutiveFailures += 1;
|
|
88
|
+
if (this.firstFailureAt === null)
|
|
89
|
+
this.firstFailureAt = Date.now();
|
|
90
|
+
const elapsedMs = Date.now() - this.firstFailureAt;
|
|
91
|
+
const delayMs = this.delayMs;
|
|
92
|
+
if (delayMs !== this.lastLoggedDelayMs) {
|
|
93
|
+
this.log(`${this.label} ${this.opAction} failed (retrying in ${Math.ceil(delayMs / 1000)}s): ${describeError(error)}`, this.transientFailureLevel);
|
|
94
|
+
this.lastLoggedDelayMs = delayMs;
|
|
95
|
+
}
|
|
96
|
+
if (!this.sustainedNoticeFired && elapsedMs >= this.sustainedThresholdMs) {
|
|
97
|
+
this.sustainedNoticeFired = true;
|
|
98
|
+
const hint = this.sustainedFailureHint ? ` — ${this.sustainedFailureHint}` : '';
|
|
99
|
+
this.log(`⚠ ${this.label}: connection has been failing for ${Math.round(elapsedMs / 60_000)}+ min `
|
|
100
|
+
+ `(${this.consecutiveFailures} attempts)${hint}. Retries continue at ${Math.ceil(delayMs / 1000)}s intervals.`, 'warn');
|
|
101
|
+
}
|
|
102
|
+
this.delayMs = Math.min(this.delayMs * 2, this.maxDelayMs);
|
|
103
|
+
return delayMs;
|
|
104
|
+
}
|
|
105
|
+
/** Current consecutive failure count. */
|
|
106
|
+
get failureCount() {
|
|
107
|
+
return this.consecutiveFailures;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack bot orchestration.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors WeixinBot's command surface (/help, /status, /agent, /models, …)
|
|
5
|
+
* over Slack channels and DMs. Each Slack channel id is a chat key, threads
|
|
6
|
+
* are reused so the bot's reply lands in the same conversation as the trigger.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { Bot, buildPrompt, fmtUptime, fmtBytes, formatGitStatusLine, normalizeAgent, parseAllowedChatIds, } from '../../bot/bot.js';
|
|
12
|
+
import { BOT_SHUTDOWN_FORCE_EXIT_MS, buildSessionTaskId } from '../../bot/orchestration.js';
|
|
13
|
+
import { shutdownAllDrivers } from '../../agent/driver.js';
|
|
14
|
+
import { expandTilde } from '../../core/platform.js';
|
|
15
|
+
import { registerProcessRuntime, requestProcessRestart, } from '../../core/process-control.js';
|
|
16
|
+
import { getStatusDataAsync, getHostDataSync, getAgentsListData, getSkillsListData, getModelsListData, getSessionsPageData, getStartData, getWorkspacesData, } from '../../bot/commands.js';
|
|
17
|
+
import { SlackChannel } from './channel.js';
|
|
18
|
+
import { getActiveUserConfig } from '../../core/config/user-config.js';
|
|
19
|
+
const SHUTDOWN_EXIT_CODE = {
|
|
20
|
+
SIGINT: 130,
|
|
21
|
+
SIGTERM: 143,
|
|
22
|
+
};
|
|
23
|
+
function describeError(error) {
|
|
24
|
+
return error instanceof Error ? error.message : String(error ?? 'unknown error');
|
|
25
|
+
}
|
|
26
|
+
export class SlackBot extends Bot {
|
|
27
|
+
botToken;
|
|
28
|
+
appToken;
|
|
29
|
+
channel;
|
|
30
|
+
nextTaskId = 1;
|
|
31
|
+
shutdownInFlight = false;
|
|
32
|
+
shutdownExitCode = null;
|
|
33
|
+
shutdownForceExitTimer = null;
|
|
34
|
+
signalHandlers = {};
|
|
35
|
+
processRuntimeCleanup = null;
|
|
36
|
+
constructor() {
|
|
37
|
+
super();
|
|
38
|
+
const config = getActiveUserConfig();
|
|
39
|
+
if (process.env.SLACK_ALLOWED_CHANNEL_IDS) {
|
|
40
|
+
for (const id of parseAllowedChatIds(process.env.SLACK_ALLOWED_CHANNEL_IDS))
|
|
41
|
+
this.allowedChatIds.add(id);
|
|
42
|
+
}
|
|
43
|
+
this.botToken = String(config.slackBotToken || process.env.SLACK_BOT_TOKEN || '').trim();
|
|
44
|
+
this.appToken = String(config.slackAppToken || process.env.SLACK_APP_TOKEN || '').trim();
|
|
45
|
+
if (!this.botToken || !this.appToken) {
|
|
46
|
+
throw new Error('Missing Slack credentials. Configure slackBotToken and slackAppToken.');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
requestStop() {
|
|
50
|
+
super.requestStop();
|
|
51
|
+
try {
|
|
52
|
+
this.channel?.disconnect();
|
|
53
|
+
}
|
|
54
|
+
catch { }
|
|
55
|
+
}
|
|
56
|
+
onManagedConfigChange(config, opts = {}) {
|
|
57
|
+
const nextBotToken = String(config.slackBotToken || process.env.SLACK_BOT_TOKEN || '').trim();
|
|
58
|
+
const nextAppToken = String(config.slackAppToken || process.env.SLACK_APP_TOKEN || '').trim();
|
|
59
|
+
if (nextBotToken && nextBotToken !== this.botToken) {
|
|
60
|
+
this.botToken = nextBotToken;
|
|
61
|
+
if (!opts.initial)
|
|
62
|
+
this.log('slack botToken reloaded from setting.json');
|
|
63
|
+
}
|
|
64
|
+
if (nextAppToken && nextAppToken !== this.appToken) {
|
|
65
|
+
this.appToken = nextAppToken;
|
|
66
|
+
if (!opts.initial)
|
|
67
|
+
this.log('slack appToken reloaded from setting.json');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
installSignalHandlers() {
|
|
71
|
+
this.removeSignalHandlers();
|
|
72
|
+
const onSigint = () => this.beginShutdown('SIGINT');
|
|
73
|
+
const onSigterm = () => this.beginShutdown('SIGTERM');
|
|
74
|
+
this.signalHandlers = { SIGINT: onSigint, SIGTERM: onSigterm };
|
|
75
|
+
process.once('SIGINT', onSigint);
|
|
76
|
+
process.once('SIGTERM', onSigterm);
|
|
77
|
+
}
|
|
78
|
+
removeSignalHandlers() {
|
|
79
|
+
for (const signal of Object.keys(this.signalHandlers)) {
|
|
80
|
+
const handler = this.signalHandlers[signal];
|
|
81
|
+
if (handler)
|
|
82
|
+
process.off(signal, handler);
|
|
83
|
+
}
|
|
84
|
+
this.signalHandlers = {};
|
|
85
|
+
}
|
|
86
|
+
clearShutdownForceExitTimer() {
|
|
87
|
+
if (!this.shutdownForceExitTimer)
|
|
88
|
+
return;
|
|
89
|
+
clearTimeout(this.shutdownForceExitTimer);
|
|
90
|
+
this.shutdownForceExitTimer = null;
|
|
91
|
+
}
|
|
92
|
+
cleanupRuntimeForExit() {
|
|
93
|
+
try {
|
|
94
|
+
this.channel.disconnect();
|
|
95
|
+
}
|
|
96
|
+
catch { }
|
|
97
|
+
this.stopKeepAlive();
|
|
98
|
+
shutdownAllDrivers();
|
|
99
|
+
}
|
|
100
|
+
beginShutdown(signal) {
|
|
101
|
+
if (this.shutdownInFlight)
|
|
102
|
+
return;
|
|
103
|
+
this.shutdownInFlight = true;
|
|
104
|
+
this.shutdownExitCode = SHUTDOWN_EXIT_CODE[signal];
|
|
105
|
+
this.log(`${signal}, shutting down...`);
|
|
106
|
+
this.cleanupRuntimeForExit();
|
|
107
|
+
this.clearShutdownForceExitTimer();
|
|
108
|
+
this.shutdownForceExitTimer = setTimeout(() => {
|
|
109
|
+
this.log(`shutdown still pending after ${Math.floor(BOT_SHUTDOWN_FORCE_EXIT_MS / 1000)}s, forcing exit`);
|
|
110
|
+
process.exit(this.shutdownExitCode ?? 1);
|
|
111
|
+
}, BOT_SHUTDOWN_FORCE_EXIT_MS);
|
|
112
|
+
this.shutdownForceExitTimer.unref?.();
|
|
113
|
+
}
|
|
114
|
+
resolveSession(chatId, title, files) {
|
|
115
|
+
return this.ensureSessionForChat(chatId, title, files);
|
|
116
|
+
}
|
|
117
|
+
async handleCommand(text, ctx) {
|
|
118
|
+
const [rawCommand, ...rest] = text.trim().slice(1).split(/\s+/);
|
|
119
|
+
const command = rawCommand?.toLowerCase() || '';
|
|
120
|
+
const args = rest.join(' ').trim();
|
|
121
|
+
switch (command) {
|
|
122
|
+
case 'help':
|
|
123
|
+
await ctx.reply([
|
|
124
|
+
'/help - Show commands',
|
|
125
|
+
'/new - New session',
|
|
126
|
+
'/status - Session status',
|
|
127
|
+
'/host - Host system info',
|
|
128
|
+
'/agent [codex|claude|gemini] - Switch agent',
|
|
129
|
+
'/models [name|#] - Switch model',
|
|
130
|
+
'/mode [plan|code] - Toggle plan mode (claude only)',
|
|
131
|
+
'/switch [path] - Change workdir',
|
|
132
|
+
'/workspaces [#] - Pick saved workspace',
|
|
133
|
+
'/sessions [new|#] - List/switch sessions',
|
|
134
|
+
'/skills - List project skills',
|
|
135
|
+
'/stop - Stop current task',
|
|
136
|
+
'/restart - Restart pikiloop',
|
|
137
|
+
].join('\n'));
|
|
138
|
+
return true;
|
|
139
|
+
case 'new': {
|
|
140
|
+
this.resetConversationForChat(ctx.chatId);
|
|
141
|
+
await ctx.reply('Started a new session.');
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
case 'status':
|
|
145
|
+
await this.cmdStatus(ctx);
|
|
146
|
+
return true;
|
|
147
|
+
case 'host':
|
|
148
|
+
await this.cmdHost(ctx);
|
|
149
|
+
return true;
|
|
150
|
+
case 'agent':
|
|
151
|
+
await this.cmdAgent(ctx, args);
|
|
152
|
+
return true;
|
|
153
|
+
case 'models':
|
|
154
|
+
await this.cmdModels(ctx, args);
|
|
155
|
+
return true;
|
|
156
|
+
case 'mode':
|
|
157
|
+
await this.cmdMode(ctx, args);
|
|
158
|
+
return true;
|
|
159
|
+
case 'switch':
|
|
160
|
+
await this.cmdSwitch(ctx, args);
|
|
161
|
+
return true;
|
|
162
|
+
case 'workspaces':
|
|
163
|
+
await this.cmdWorkspaces(ctx, args);
|
|
164
|
+
return true;
|
|
165
|
+
case 'sessions':
|
|
166
|
+
await this.cmdSessions(ctx, args);
|
|
167
|
+
return true;
|
|
168
|
+
case 'skills':
|
|
169
|
+
await this.cmdSkills(ctx);
|
|
170
|
+
return true;
|
|
171
|
+
case 'stop':
|
|
172
|
+
await this.cmdStop(ctx);
|
|
173
|
+
return true;
|
|
174
|
+
case 'restart':
|
|
175
|
+
await this.cmdRestart(ctx);
|
|
176
|
+
return true;
|
|
177
|
+
case 'start':
|
|
178
|
+
await this.cmdStart(ctx);
|
|
179
|
+
return true;
|
|
180
|
+
default: return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async cmdStart(ctx) {
|
|
184
|
+
const d = getStartData(this, ctx.chatId);
|
|
185
|
+
const lines = [`pikiloop v${d.version}`, `Workdir: ${d.workdir}`, '', `Agent: ${d.agent}`];
|
|
186
|
+
for (const a of d.agentDetails) {
|
|
187
|
+
const parts = [` ${a.agent}: ${a.model}`];
|
|
188
|
+
if (a.effort)
|
|
189
|
+
parts[0] += ` (effort: ${a.effort})`;
|
|
190
|
+
lines.push(parts[0]);
|
|
191
|
+
}
|
|
192
|
+
lines.push('', 'Ready. Send a message to start.');
|
|
193
|
+
await ctx.reply(lines.join('\n'));
|
|
194
|
+
}
|
|
195
|
+
async cmdStatus(ctx) {
|
|
196
|
+
const d = await getStatusDataAsync(this, ctx.chatId);
|
|
197
|
+
const gitLine = formatGitStatusLine(d.git);
|
|
198
|
+
const lines = [
|
|
199
|
+
`pikiloop v${d.version}`,
|
|
200
|
+
`Uptime: ${fmtUptime(d.uptime)}`,
|
|
201
|
+
`PID: ${d.pid} | RSS: ${fmtBytes(d.memRss)} | Heap: ${fmtBytes(d.memHeap)}`,
|
|
202
|
+
`Workdir: ${d.workdir}`,
|
|
203
|
+
...(gitLine ? [`Git: ${gitLine}`] : []),
|
|
204
|
+
'',
|
|
205
|
+
`Agent: ${d.agent}`,
|
|
206
|
+
`Model: ${d.model || '-'}`,
|
|
207
|
+
`Session: ${d.sessionId ? d.sessionId.slice(0, 16) : '(new)'}`,
|
|
208
|
+
`Tasks: ${d.activeTasksCount}`,
|
|
209
|
+
];
|
|
210
|
+
if (d.running)
|
|
211
|
+
lines.push(`Running: ${fmtUptime(Date.now() - d.running.startedAt)}`);
|
|
212
|
+
await ctx.reply(lines.join('\n'));
|
|
213
|
+
}
|
|
214
|
+
async cmdHost(ctx) {
|
|
215
|
+
const d = getHostDataSync(this);
|
|
216
|
+
const lines = [`Host: ${d.hostName}`, `CPU: ${d.cpuModel} x${d.cpuCount}`];
|
|
217
|
+
if (d.cpuUsage) {
|
|
218
|
+
lines.push(`CPU Usage: ${d.cpuUsage.usedPercent.toFixed(1)}% (user ${d.cpuUsage.userPercent.toFixed(1)}%, sys ${d.cpuUsage.sysPercent.toFixed(1)}%)`);
|
|
219
|
+
}
|
|
220
|
+
lines.push(`Memory: ${fmtBytes(d.memoryUsed)} / ${fmtBytes(d.totalMem)} (${d.memoryPercent.toFixed(0)}%)`, `Available: ${fmtBytes(d.memoryAvailable)}`);
|
|
221
|
+
if (d.battery)
|
|
222
|
+
lines.push(`Battery: ${d.battery.percent} (${d.battery.state})`);
|
|
223
|
+
if (d.disk)
|
|
224
|
+
lines.push(`Disk: ${d.disk.used} / ${d.disk.total} (${d.disk.percent})`);
|
|
225
|
+
lines.push(`Process: PID ${d.selfPid} | RSS ${fmtBytes(d.selfRss)} | Heap ${fmtBytes(d.selfHeap)}`);
|
|
226
|
+
if (d.topProcs.length > 1) {
|
|
227
|
+
lines.push('', 'Top Processes:');
|
|
228
|
+
lines.push(...d.topProcs);
|
|
229
|
+
}
|
|
230
|
+
await ctx.reply(lines.join('\n'));
|
|
231
|
+
}
|
|
232
|
+
async cmdAgent(ctx, args) {
|
|
233
|
+
if (!args) {
|
|
234
|
+
const d = getAgentsListData(this, ctx.chatId);
|
|
235
|
+
const current = d.agents.find(a => a.isCurrent);
|
|
236
|
+
const lines = [];
|
|
237
|
+
lines.push(`Current: ${current ? current.label : d.currentAgent}`, '');
|
|
238
|
+
for (const a of d.agents) {
|
|
239
|
+
const tick = a.installed ? '✓' : '✗';
|
|
240
|
+
const head = a.versionShort ? `${tick} ${a.label} · v${a.versionShort}` : `${tick} ${a.label}`;
|
|
241
|
+
lines.push(a.isCurrent ? `${head} ← current` : head);
|
|
242
|
+
if (a.boundProvider && a.boundModel)
|
|
243
|
+
lines.push(` └ ${a.boundProvider} / ${a.boundModel}`);
|
|
244
|
+
}
|
|
245
|
+
const ids = d.agents.filter(a => a.installed).map(a => a.agent).join('|');
|
|
246
|
+
lines.push('', `Switch: /agent ${ids || 'codex|claude|gemini|hermes'}`);
|
|
247
|
+
await ctx.reply(lines.join('\n'));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const agent = normalizeAgent(args);
|
|
252
|
+
this.switchAgentForChat(ctx.chatId, agent);
|
|
253
|
+
await ctx.reply(`Agent switched to ${agent}.`);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
await ctx.reply('Unknown agent. Use: /agent codex|claude|gemini');
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
async cmdModels(ctx, args) {
|
|
260
|
+
const d = await getModelsListData(this, ctx.chatId);
|
|
261
|
+
if (args) {
|
|
262
|
+
const idx = parseInt(args, 10);
|
|
263
|
+
let modelId = null;
|
|
264
|
+
if (!isNaN(idx) && idx >= 1 && idx <= d.models.length)
|
|
265
|
+
modelId = d.models[idx - 1].id;
|
|
266
|
+
else {
|
|
267
|
+
const match = d.models.find(m => m.id === args || m.alias === args);
|
|
268
|
+
if (match)
|
|
269
|
+
modelId = match.id;
|
|
270
|
+
}
|
|
271
|
+
if (modelId) {
|
|
272
|
+
this.switchModelForChat(ctx.chatId, modelId);
|
|
273
|
+
await ctx.reply(`Model switched to ${modelId}.`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
await ctx.reply(`Unknown model: ${args}`);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const lines = [`Current: ${d.currentModel}`, ''];
|
|
280
|
+
d.models.forEach((m, i) => {
|
|
281
|
+
const alias = m.alias ? ` (${m.alias})` : '';
|
|
282
|
+
const mark = m.isCurrent ? ' ←' : '';
|
|
283
|
+
lines.push(`${i + 1}. ${m.id}${alias}${mark}`);
|
|
284
|
+
});
|
|
285
|
+
if (d.effort) {
|
|
286
|
+
lines.push('', `Effort: ${d.effort.current}`);
|
|
287
|
+
for (const lv of d.effort.levels) {
|
|
288
|
+
const mark = lv.isCurrent ? ' ←' : '';
|
|
289
|
+
lines.push(` ${lv.id} - ${lv.label}${mark}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
lines.push('', 'Usage: /models <name|number>');
|
|
293
|
+
await ctx.reply(lines.join('\n'));
|
|
294
|
+
}
|
|
295
|
+
async cmdMode(ctx, args) {
|
|
296
|
+
if (this.chat(ctx.chatId).agent !== 'claude') {
|
|
297
|
+
await ctx.reply('Mode toggle is only available for Claude agent.');
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const isPlan = this.agentConfigs.claude.permissionMode === 'plan';
|
|
301
|
+
if (args === 'plan') {
|
|
302
|
+
this.switchPermissionModeForChat(ctx.chatId, 'plan');
|
|
303
|
+
await ctx.reply('Mode: Plan (read-only)');
|
|
304
|
+
}
|
|
305
|
+
else if (args === 'code') {
|
|
306
|
+
this.switchPermissionModeForChat(ctx.chatId, 'bypassPermissions');
|
|
307
|
+
await ctx.reply('Mode: Code (full access)');
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
await ctx.reply(`Current: ${isPlan ? 'Plan (read-only)' : 'Code (full access)'}\n\nUsage: /mode plan|code`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
async cmdSwitch(ctx, args) {
|
|
314
|
+
const wd = this.chatWorkdir(ctx.chatId);
|
|
315
|
+
if (args) {
|
|
316
|
+
const resolvedPath = path.resolve(expandTilde(args));
|
|
317
|
+
if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isDirectory()) {
|
|
318
|
+
await ctx.reply(`Not a valid directory: ${resolvedPath}`);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const oldPath = this.switchWorkdir(resolvedPath);
|
|
322
|
+
await ctx.reply(`Workdir switched:\n${oldPath}\n→ ${resolvedPath}`);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const savedCount = getWorkspacesData(this, ctx.chatId).workspaces.length;
|
|
326
|
+
const hint = savedCount > 0
|
|
327
|
+
? `\n\nTip: ${savedCount} saved workspace${savedCount === 1 ? '' : 's'} — use /workspaces to pick one.`
|
|
328
|
+
: '';
|
|
329
|
+
await ctx.reply(`Current workdir: ${wd}\n\nUsage: /switch <path>${hint}`);
|
|
330
|
+
}
|
|
331
|
+
async cmdWorkspaces(ctx, args) {
|
|
332
|
+
const data = getWorkspacesData(this, ctx.chatId);
|
|
333
|
+
if (data.workspaces.length === 0) {
|
|
334
|
+
await ctx.reply('No saved workspaces yet. Add them from the dashboard, or use /switch <path>.');
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const trimmed = args.trim();
|
|
338
|
+
if (trimmed) {
|
|
339
|
+
const idx = parseInt(trimmed, 10);
|
|
340
|
+
if (Number.isNaN(idx) || idx < 1 || idx > data.workspaces.length) {
|
|
341
|
+
await ctx.reply(`Workspace #${trimmed} not found. Use /workspaces to list.`);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const ws = data.workspaces[idx - 1];
|
|
345
|
+
if (!ws.exists) {
|
|
346
|
+
await ctx.reply(`Workspace path is missing on disk:\n${ws.path}`);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const oldPath = this.switchWorkdir(ws.path);
|
|
350
|
+
await ctx.reply(`Workdir switched:\n${oldPath}\n→ ${ws.path}`);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const lines = ['Saved workspaces:', `Current: ${data.currentWorkdir}`, ''];
|
|
354
|
+
data.workspaces.forEach((ws, i) => {
|
|
355
|
+
const marker = ws.isCurrent ? '✓' : ws.exists ? ' ' : '⚠';
|
|
356
|
+
lines.push(`${marker} ${i + 1}. ${ws.name}`);
|
|
357
|
+
lines.push(` ${ws.path}`);
|
|
358
|
+
});
|
|
359
|
+
lines.push('', 'Usage: /workspaces <number> to switch.');
|
|
360
|
+
await ctx.reply(lines.join('\n'));
|
|
361
|
+
}
|
|
362
|
+
async cmdSessions(ctx, args) {
|
|
363
|
+
const arg = args.trim().toLowerCase();
|
|
364
|
+
if (arg === 'new') {
|
|
365
|
+
this.resetConversationForChat(ctx.chatId);
|
|
366
|
+
await ctx.reply('Started a new session.');
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const idx = parseInt(arg, 10);
|
|
370
|
+
if (!isNaN(idx) && idx >= 1) {
|
|
371
|
+
const d = await getSessionsPageData(this, ctx.chatId, 0, 100);
|
|
372
|
+
const target = d.sessions[idx - 1];
|
|
373
|
+
if (target) {
|
|
374
|
+
const result = await this.fetchSessions(undefined, this.chatWorkdir(ctx.chatId));
|
|
375
|
+
const session = result.sessions.find(s => s.sessionId === target.key);
|
|
376
|
+
if (session) {
|
|
377
|
+
this.resumeSessionForChat(ctx.chatId, session);
|
|
378
|
+
await ctx.reply(`Switched to [${session.agent}] ${target.title}`);
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
await ctx.reply('Session not found.');
|
|
382
|
+
}
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
await ctx.reply(`Session #${idx} not found.`);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const d = await getSessionsPageData(this, ctx.chatId, 0, 10);
|
|
389
|
+
if (!d.sessions.length) {
|
|
390
|
+
await ctx.reply('No sessions found.');
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const lines = [`Sessions (${d.total}):`, ''];
|
|
394
|
+
d.sessions.forEach((s, i) => {
|
|
395
|
+
const mark = s.isCurrent ? ' ←' : '';
|
|
396
|
+
const running = s.isRunning ? ' [running]' : '';
|
|
397
|
+
lines.push(`${i + 1}. [${s.agent}] ${s.title} · ${s.time}${mark}${running}`);
|
|
398
|
+
});
|
|
399
|
+
lines.push('', 'Usage: /sessions new | /sessions <#>');
|
|
400
|
+
await ctx.reply(lines.join('\n'));
|
|
401
|
+
}
|
|
402
|
+
async cmdSkills(ctx) {
|
|
403
|
+
const d = getSkillsListData(this, ctx.chatId);
|
|
404
|
+
if (!d.skills.length) {
|
|
405
|
+
await ctx.reply('No project skills found.');
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const lines = [`Skills (${d.agent}):`, ''];
|
|
409
|
+
for (const s of d.skills) {
|
|
410
|
+
const desc = s.description ? ` - ${s.description}` : '';
|
|
411
|
+
lines.push(`/${s.command} (${s.label})${desc}`);
|
|
412
|
+
}
|
|
413
|
+
await ctx.reply(lines.join('\n'));
|
|
414
|
+
}
|
|
415
|
+
async cmdStop(ctx) {
|
|
416
|
+
const session = this.selectedSession(ctx.chatId);
|
|
417
|
+
if (!session) {
|
|
418
|
+
await ctx.reply('No active session to stop.');
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const { interrupted, cancelledQueued } = this.stopTasksForSession(session.key);
|
|
422
|
+
if (!interrupted && cancelledQueued === 0) {
|
|
423
|
+
await ctx.reply('No running or queued work for the current session.');
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const parts = [];
|
|
427
|
+
if (interrupted)
|
|
428
|
+
parts.push('interrupted current run');
|
|
429
|
+
if (cancelledQueued > 0)
|
|
430
|
+
parts.push(`cancelled ${cancelledQueued} queued task(s)`);
|
|
431
|
+
await ctx.reply(`Stopped: ${parts.join(', ')}.`);
|
|
432
|
+
}
|
|
433
|
+
async cmdRestart(ctx) {
|
|
434
|
+
await ctx.reply('Restarting pikiloop...');
|
|
435
|
+
void requestProcessRestart({ log: msg => this.log(msg) });
|
|
436
|
+
}
|
|
437
|
+
createMcpSendFile(chatId) {
|
|
438
|
+
return async (filePath) => {
|
|
439
|
+
try {
|
|
440
|
+
await this.channel.send(chatId, `Artifact ready: ${path.basename(filePath)}\n${filePath}`);
|
|
441
|
+
return { ok: true };
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
return { ok: false, error: describeError(error) };
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
async sendResult(chatId, result) {
|
|
449
|
+
const text = result.ok
|
|
450
|
+
? (result.message.trim() || 'Task finished.')
|
|
451
|
+
: ['Task failed.', result.error || result.message || 'Unknown error.'].filter(Boolean).join('\n');
|
|
452
|
+
await this.channel.send(chatId, text);
|
|
453
|
+
}
|
|
454
|
+
async handleMessage(msg, ctx) {
|
|
455
|
+
const text = msg.text.trim();
|
|
456
|
+
if (text.startsWith('/') && await this.handleCommand(text, ctx))
|
|
457
|
+
return;
|
|
458
|
+
if (!text && !msg.files.length) {
|
|
459
|
+
await ctx.reply('Send some text — file uploads are not yet supported on the Slack channel.');
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const session = this.resolveSession(ctx.chatId, text, msg.files);
|
|
463
|
+
const prompt = buildPrompt(text, msg.files);
|
|
464
|
+
const taskId = buildSessionTaskId(session, this.nextTaskId++);
|
|
465
|
+
this.beginTask({
|
|
466
|
+
taskId,
|
|
467
|
+
chatId: ctx.chatId,
|
|
468
|
+
agent: session.agent,
|
|
469
|
+
sessionKey: session.key,
|
|
470
|
+
prompt,
|
|
471
|
+
startedAt: Date.now(),
|
|
472
|
+
sourceMessageId: ctx.messageId,
|
|
473
|
+
});
|
|
474
|
+
this.emitStreamQueued(session.key, taskId);
|
|
475
|
+
void this.queueSessionTask(session, async () => {
|
|
476
|
+
const abortController = new AbortController();
|
|
477
|
+
const task = this.markTaskRunning(taskId, () => abortController.abort());
|
|
478
|
+
if (task?.cancelled) {
|
|
479
|
+
this.emitStreamCancelled(taskId, session.key);
|
|
480
|
+
this.finishTask(taskId);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
this.emitStreamStart(taskId, session);
|
|
484
|
+
try {
|
|
485
|
+
const result = await this.runStream(prompt, session, msg.files, (text, thinking, activity, meta, plan) => {
|
|
486
|
+
this.emitStreamText(taskId, session.key, text, thinking, activity, meta, plan);
|
|
487
|
+
}, undefined, this.createMcpSendFile(ctx.chatId), abortController.signal, this.createInteractionHandler(ctx.chatId, taskId));
|
|
488
|
+
this.emitStreamDone(taskId, session.key, {
|
|
489
|
+
sessionId: result.sessionId || session.sessionId,
|
|
490
|
+
incomplete: !!result.incomplete,
|
|
491
|
+
...(result.ok ? {} : { error: result.error || result.message }),
|
|
492
|
+
});
|
|
493
|
+
await this.sendResult(ctx.chatId, result);
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
this.emitStreamDone(taskId, session.key, {
|
|
497
|
+
sessionId: session.sessionId,
|
|
498
|
+
incomplete: true,
|
|
499
|
+
error: describeError(error),
|
|
500
|
+
});
|
|
501
|
+
await ctx.reply(`Error: ${describeError(error)}`);
|
|
502
|
+
}
|
|
503
|
+
finally {
|
|
504
|
+
this.finishTask(taskId);
|
|
505
|
+
this.syncSelectedChats(session);
|
|
506
|
+
}
|
|
507
|
+
}, taskId).catch(error => {
|
|
508
|
+
this.finishTask(taskId);
|
|
509
|
+
this.log(`slack queue execution failed: ${describeError(error)}`);
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
async run() {
|
|
513
|
+
const tmpDir = path.join(os.tmpdir(), 'pikiloop');
|
|
514
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
515
|
+
this.channel = new SlackChannel({
|
|
516
|
+
botToken: this.botToken,
|
|
517
|
+
appToken: this.appToken,
|
|
518
|
+
workdir: tmpDir,
|
|
519
|
+
allowedChatIds: this.allowedChatIds.size ? new Set([...this.allowedChatIds].map(value => String(value))) : undefined,
|
|
520
|
+
});
|
|
521
|
+
this.processRuntimeCleanup?.();
|
|
522
|
+
this.processRuntimeCleanup = registerProcessRuntime({
|
|
523
|
+
label: 'slack',
|
|
524
|
+
getActiveTaskCount: () => this.activeTasks.size,
|
|
525
|
+
prepareForRestart: () => this.cleanupRuntimeForExit(),
|
|
526
|
+
});
|
|
527
|
+
this.installSignalHandlers();
|
|
528
|
+
try {
|
|
529
|
+
const bot = await this.channel.connect();
|
|
530
|
+
this.connected = true;
|
|
531
|
+
this.log(`bot: ${bot.displayName} (id=${bot.id})`);
|
|
532
|
+
for (const agent of this.fetchAgents().agents) {
|
|
533
|
+
this.log(`agent ${agent.agent}: ${agent.path || 'NOT FOUND'}`);
|
|
534
|
+
}
|
|
535
|
+
this.log(`config: agent=${this.defaultAgent} workdir=${this.workdir} timeout=${this.runTimeout}s`);
|
|
536
|
+
this.channel.onMessage((msg, ctx) => this.handleMessage(msg, ctx));
|
|
537
|
+
this.channel.onError(error => this.log(`error: ${describeError(error)}`));
|
|
538
|
+
this.startKeepAlive();
|
|
539
|
+
this.log('✓ Slack connected, Socket Mode listening — ready to receive messages');
|
|
540
|
+
await this.channel.listen();
|
|
541
|
+
this.stopKeepAlive();
|
|
542
|
+
this.log('stopped');
|
|
543
|
+
}
|
|
544
|
+
finally {
|
|
545
|
+
this.stopKeepAlive();
|
|
546
|
+
this.clearShutdownForceExitTimer();
|
|
547
|
+
this.removeSignalHandlers();
|
|
548
|
+
this.processRuntimeCleanup?.();
|
|
549
|
+
this.processRuntimeCleanup = null;
|
|
550
|
+
if (this.shutdownInFlight)
|
|
551
|
+
process.exit(this.shutdownExitCode ?? 1);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|