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/cli/main.js
ADDED
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cli.ts — CLI entry point for pikiloop.
|
|
4
|
+
*/
|
|
5
|
+
// Mark this process as a Claude Code context so nested claude launches are blocked.
|
|
6
|
+
// The spawn framework in code-agent.ts strips this before launching agent subprocesses.
|
|
7
|
+
process.env.CLAUDECODE = '1';
|
|
8
|
+
import { hydrateLegacyEnv, migrateLegacyStateDir } from '../core/legacy-compat.js';
|
|
9
|
+
// Backward-compat for the pikiclaw → pikiloop rename. Runs before any config is
|
|
10
|
+
// read or lock taken: mirror PIKICLAW_* → PIKILOOP_* and move ~/.pikiclaw →
|
|
11
|
+
// ~/.pikiloop. Both are idempotent no-ops once an install has migrated.
|
|
12
|
+
hydrateLegacyEnv();
|
|
13
|
+
migrateLegacyStateDir();
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { startAgentAutoUpdate } from '../agent/auto-update.js';
|
|
17
|
+
import { envBool, DEFAULT_RUN_TIMEOUT_S } from '../bot/bot.js';
|
|
18
|
+
import { DAEMON_TIMEOUTS } from '../core/constants.js';
|
|
19
|
+
import { hasConfiguredChannelToken, resolveConfiguredChannels } from './channels.js';
|
|
20
|
+
import { ChannelSupervisor } from './channel-supervisor.js';
|
|
21
|
+
import { listAgents } from '../agent/index.js';
|
|
22
|
+
import { startDashboard } from '../dashboard/server.js';
|
|
23
|
+
import { buildSetupGuide, collectSetupState, hasReadyAgent, isSetupReady } from './onboarding.js';
|
|
24
|
+
import { buildRestartCommand, clearDaemonPidFile, clearRestartStateFile, consumeRestartStateFile, createRestartStateFilePath, isProcessAlive, PROCESS_RESTART_EXIT_CODE, readDaemonPidFile, requestProcessRestart, writeDaemonPidFile, } from '../core/process-control.js';
|
|
25
|
+
import { runSetupWizard } from './setup-wizard.js';
|
|
26
|
+
import { FROM_LAUNCHD_ENV, maybePromptAutostart } from './autostart.js';
|
|
27
|
+
import { applyUserConfig, loadUserConfig, startUserConfigSync, updateUserConfig, } from '../core/config/user-config.js';
|
|
28
|
+
import { VERSION } from '../core/version.js';
|
|
29
|
+
/* ── Daemon (watchdog) mode ─────────────────────────────────────────── */
|
|
30
|
+
const DAEMON_RESTART_DELAY_MS = DAEMON_TIMEOUTS.restartDelay;
|
|
31
|
+
const DAEMON_MAX_RESTART_DELAY_MS = DAEMON_TIMEOUTS.maxRestartDelay;
|
|
32
|
+
const DAEMON_RAPID_CRASH_WINDOW_MS = DAEMON_TIMEOUTS.rapidCrashWindow;
|
|
33
|
+
function daemonLog(msg) {
|
|
34
|
+
const ts = new Date().toTimeString().slice(0, 8);
|
|
35
|
+
process.stdout.write(`[daemon ${ts}] ${msg}\n`);
|
|
36
|
+
}
|
|
37
|
+
/** Args that are daemon-specific and should not be forwarded to the child. */
|
|
38
|
+
const DAEMON_STRIP_ARGS = new Set(['--daemon', '--no-daemon']);
|
|
39
|
+
/**
|
|
40
|
+
* Runs the bot as a supervised child process. On non-zero exit the child is
|
|
41
|
+
* restarted with exponential back-off. A clean exit (code 0) stops the daemon.
|
|
42
|
+
* Restart requests use a dedicated exit code and are respawned immediately.
|
|
43
|
+
*/
|
|
44
|
+
async function runDaemon(userArgs) {
|
|
45
|
+
// Forward user's CLI args (strip daemon-related flags).
|
|
46
|
+
const forwardedArgs = userArgs.filter(a => !DAEMON_STRIP_ARGS.has(a));
|
|
47
|
+
const restartCmd = process.env.PIKILOOP_RESTART_CMD;
|
|
48
|
+
const restartStateFile = createRestartStateFilePath(process.pid);
|
|
49
|
+
// Publish the daemon PID so `pikiloop stop` can find it. Clean up on any
|
|
50
|
+
// exit path so a stale file never points at someone else's PID.
|
|
51
|
+
writeDaemonPidFile(process.pid);
|
|
52
|
+
process.once('exit', clearDaemonPidFile);
|
|
53
|
+
// Auto-start enrollment: only when the user explicitly typed `--daemon`
|
|
54
|
+
// (the watchdog itself is on by default, so we use the explicit flag as
|
|
55
|
+
// the signal of "I'm settling in for long-term use"). Fire-and-forget so
|
|
56
|
+
// the bot still comes up immediately; the dialog appears a few seconds
|
|
57
|
+
// later. No-op when already enrolled, declined, non-interactive, or
|
|
58
|
+
// already running under launchd — see src/cli/autostart.ts.
|
|
59
|
+
if (userArgs.includes('--daemon')) {
|
|
60
|
+
maybePromptAutostart(daemonLog);
|
|
61
|
+
}
|
|
62
|
+
let restartDelay = DAEMON_RESTART_DELAY_MS;
|
|
63
|
+
let attempt = 0;
|
|
64
|
+
let nextRestartEnv = {};
|
|
65
|
+
const spawnChild = (extraEnv = {}) => {
|
|
66
|
+
clearRestartStateFile(restartStateFile);
|
|
67
|
+
const { bin, args } = buildRestartCommand(forwardedArgs, restartCmd);
|
|
68
|
+
daemonLog(`exec: ${bin} ${args.join(' ')}`);
|
|
69
|
+
// npx/npx.cmd needs shell resolution; node.exe does not
|
|
70
|
+
const needsShell = process.platform === 'win32' && !bin.endsWith('node.exe');
|
|
71
|
+
return spawn(needsShell ? `"${bin}"` : bin, args, {
|
|
72
|
+
stdio: 'inherit',
|
|
73
|
+
shell: needsShell || undefined,
|
|
74
|
+
env: {
|
|
75
|
+
...process.env,
|
|
76
|
+
...extraEnv,
|
|
77
|
+
PIKILOOP_DAEMON_CHILD: '1',
|
|
78
|
+
PIKILOOP_RESTART_STATE_FILE: restartStateFile,
|
|
79
|
+
npm_config_yes: 'true',
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
// eslint-disable-next-line no-constant-condition
|
|
84
|
+
while (true) {
|
|
85
|
+
attempt++;
|
|
86
|
+
daemonLog(`starting child process (attempt #${attempt})`);
|
|
87
|
+
const child = spawnChild(nextRestartEnv);
|
|
88
|
+
nextRestartEnv = {};
|
|
89
|
+
daemonLog(`child running (pid=${child.pid})`);
|
|
90
|
+
const startedAt = Date.now();
|
|
91
|
+
let shutdownSignal = null;
|
|
92
|
+
// Forward termination and restart signals to the active child.
|
|
93
|
+
const forwardShutdownSignal = (sig) => {
|
|
94
|
+
shutdownSignal = sig;
|
|
95
|
+
child.kill(sig);
|
|
96
|
+
};
|
|
97
|
+
const forwardRestartSignal = () => {
|
|
98
|
+
child.kill('SIGUSR2');
|
|
99
|
+
};
|
|
100
|
+
process.on('SIGINT', forwardShutdownSignal);
|
|
101
|
+
process.on('SIGTERM', forwardShutdownSignal);
|
|
102
|
+
process.on('SIGUSR2', forwardRestartSignal);
|
|
103
|
+
const code = await new Promise(resolve => {
|
|
104
|
+
child.on('exit', (c) => resolve(c));
|
|
105
|
+
});
|
|
106
|
+
process.removeListener('SIGINT', forwardShutdownSignal);
|
|
107
|
+
process.removeListener('SIGTERM', forwardShutdownSignal);
|
|
108
|
+
process.removeListener('SIGUSR2', forwardRestartSignal);
|
|
109
|
+
if (shutdownSignal) {
|
|
110
|
+
const exitCode = shutdownSignal === 'SIGINT' ? 130 : 143;
|
|
111
|
+
daemonLog(`received ${shutdownSignal}, daemon stopping`);
|
|
112
|
+
process.exit(exitCode);
|
|
113
|
+
}
|
|
114
|
+
if (code === PROCESS_RESTART_EXIT_CODE) {
|
|
115
|
+
nextRestartEnv = consumeRestartStateFile(restartStateFile);
|
|
116
|
+
restartDelay = DAEMON_RESTART_DELAY_MS;
|
|
117
|
+
daemonLog('child requested restart, respawning immediately');
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
// Clean exit → stop daemon.
|
|
121
|
+
if (code === 0 || code === null) {
|
|
122
|
+
daemonLog(`child exited cleanly (code=${code}), daemon stopping`);
|
|
123
|
+
process.exit(0);
|
|
124
|
+
}
|
|
125
|
+
// Exponential back-off for rapid crashes.
|
|
126
|
+
const uptime = Date.now() - startedAt;
|
|
127
|
+
if (uptime > DAEMON_RAPID_CRASH_WINDOW_MS) {
|
|
128
|
+
restartDelay = DAEMON_RESTART_DELAY_MS; // reset if it ran for a while
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
restartDelay = Math.min(restartDelay * 2, DAEMON_MAX_RESTART_DELAY_MS);
|
|
132
|
+
}
|
|
133
|
+
daemonLog(`child crashed (code=${code}, uptime=${Math.round(uptime / 1000)}s), restarting in ${Math.round(restartDelay / 1000)}s...`);
|
|
134
|
+
await new Promise(resolve => setTimeout(resolve, restartDelay));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function parseArgs(argv) {
|
|
138
|
+
const args = {
|
|
139
|
+
token: null, agent: null, model: null, workdir: null,
|
|
140
|
+
fullAccess: null, safeMode: false, allowedIds: null,
|
|
141
|
+
timeout: null, version: false, help: false, doctor: false, setup: false,
|
|
142
|
+
noDashboard: false, dashboardPort: null, daemon: true,
|
|
143
|
+
stop: false,
|
|
144
|
+
};
|
|
145
|
+
const it = argv[Symbol.iterator]();
|
|
146
|
+
for (const arg of it) {
|
|
147
|
+
switch (arg) {
|
|
148
|
+
case 'stop':
|
|
149
|
+
args.stop = true;
|
|
150
|
+
break;
|
|
151
|
+
case '-t':
|
|
152
|
+
case '--token':
|
|
153
|
+
args.token = it.next().value;
|
|
154
|
+
break;
|
|
155
|
+
case '-a':
|
|
156
|
+
case '--agent':
|
|
157
|
+
args.agent = it.next().value;
|
|
158
|
+
break;
|
|
159
|
+
case '-m':
|
|
160
|
+
case '--model':
|
|
161
|
+
args.model = it.next().value;
|
|
162
|
+
break;
|
|
163
|
+
case '-w':
|
|
164
|
+
case '--workdir':
|
|
165
|
+
args.workdir = it.next().value;
|
|
166
|
+
break;
|
|
167
|
+
case '--full-access':
|
|
168
|
+
args.fullAccess = true;
|
|
169
|
+
break;
|
|
170
|
+
case '--safe-mode':
|
|
171
|
+
args.safeMode = true;
|
|
172
|
+
break;
|
|
173
|
+
case '--allowed-ids':
|
|
174
|
+
args.allowedIds = it.next().value;
|
|
175
|
+
break;
|
|
176
|
+
case '--timeout':
|
|
177
|
+
args.timeout = parseInt(it.next().value ?? '', 10);
|
|
178
|
+
break;
|
|
179
|
+
case '--doctor':
|
|
180
|
+
args.doctor = true;
|
|
181
|
+
break;
|
|
182
|
+
case '--setup':
|
|
183
|
+
args.setup = true;
|
|
184
|
+
break;
|
|
185
|
+
case '--no-dashboard':
|
|
186
|
+
args.noDashboard = true;
|
|
187
|
+
break;
|
|
188
|
+
case '--dashboard-port':
|
|
189
|
+
args.dashboardPort = parseInt(it.next().value ?? '', 10);
|
|
190
|
+
break;
|
|
191
|
+
case '--daemon':
|
|
192
|
+
args.daemon = true;
|
|
193
|
+
break;
|
|
194
|
+
case '--no-daemon':
|
|
195
|
+
args.daemon = false;
|
|
196
|
+
break;
|
|
197
|
+
case '-v':
|
|
198
|
+
case '--version':
|
|
199
|
+
args.version = true;
|
|
200
|
+
break;
|
|
201
|
+
case '-h':
|
|
202
|
+
case '--help':
|
|
203
|
+
args.help = true;
|
|
204
|
+
break;
|
|
205
|
+
default:
|
|
206
|
+
if (arg.startsWith('-')) {
|
|
207
|
+
process.stderr.write(`Unknown option: ${arg}\n`);
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return args;
|
|
213
|
+
}
|
|
214
|
+
/* ── Shared helpers ────────────────────────────────────────────────── */
|
|
215
|
+
function processLog(message) {
|
|
216
|
+
const ts = new Date().toTimeString().slice(0, 8);
|
|
217
|
+
process.stdout.write(`[pikiloop ${ts}] ${message}\n`);
|
|
218
|
+
}
|
|
219
|
+
const listStartupAgents = () => listAgents().agents;
|
|
220
|
+
const listVerboseAgents = () => listAgents({ includeVersion: true }).agents;
|
|
221
|
+
/* ── Phase: early exits (MCP serve, --version, --help) ────────────── */
|
|
222
|
+
/** If launched as an MCP stdio server, run that and exit. */
|
|
223
|
+
async function handleMcpServeMode() {
|
|
224
|
+
if (process.argv.includes('--mcp-serve')) {
|
|
225
|
+
await import('../agent/mcp/session-server.js');
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
/** Print help text and exit. */
|
|
231
|
+
function printHelp() {
|
|
232
|
+
process.stdout.write(`pikiloop v${VERSION} — Run local coding agents through IM.
|
|
233
|
+
|
|
234
|
+
Run a bot that forwards IM messages to a local AI coding agent
|
|
235
|
+
(Claude Code or Codex CLI), streams responses in real-time, and manages
|
|
236
|
+
sessions, models, and workdirs.
|
|
237
|
+
|
|
238
|
+
Channels are auto-detected from configured credentials. If multiple
|
|
239
|
+
validated channels are enabled, they launch simultaneously.
|
|
240
|
+
|
|
241
|
+
Usage:
|
|
242
|
+
npx pikiloop # auto-detect from config/env
|
|
243
|
+
npx pikiloop -w ~/project # set working directory
|
|
244
|
+
npx pikiloop stop # stop the running daemon
|
|
245
|
+
|
|
246
|
+
Options:
|
|
247
|
+
-t, --token <token> Channel auth token (env: PIKILOOP_TOKEN)
|
|
248
|
+
-a, --agent <agent> AI agent: claude | codex [default: codex]
|
|
249
|
+
-m, --model <model> Default model, switchable in chat via /models
|
|
250
|
+
-w, --workdir <dir> Working directory for the agent [default: current process cwd]
|
|
251
|
+
--full-access Codex full-access + Claude bypassPermissions + Gemini yolo/no-sandbox [default]
|
|
252
|
+
--safe-mode Use safer agent permission modes
|
|
253
|
+
--allowed-ids <id,id> Comma-separated chat/user ID whitelist
|
|
254
|
+
--timeout <seconds> Max seconds per agent request [default: ${DEFAULT_RUN_TIMEOUT_S}]
|
|
255
|
+
--doctor Run setup checks and exit
|
|
256
|
+
--setup Run the interactive setup wizard
|
|
257
|
+
--no-daemon Disable watchdog (auto-restart on crash is ON by default)
|
|
258
|
+
--no-dashboard Skip the web dashboard
|
|
259
|
+
--dashboard-port <port> Dashboard port [default: 3939]
|
|
260
|
+
-v, --version Print version
|
|
261
|
+
-h, --help Print this help
|
|
262
|
+
|
|
263
|
+
Environment variables (general):
|
|
264
|
+
PIKILOOP_TOKEN Channel auth token (same as -t, channel-agnostic)
|
|
265
|
+
DEFAULT_AGENT Default agent (same as -a)
|
|
266
|
+
PIKILOOP_WORKDIR Working directory (same as -w)
|
|
267
|
+
PIKILOOP_TIMEOUT Timeout in seconds (same as --timeout)
|
|
268
|
+
PIKILOOP_ALLOWED_IDS Comma-separated chat/user ID whitelist
|
|
269
|
+
PIKILOOP_FULL_ACCESS Default full-access behavior (true/false)
|
|
270
|
+
|
|
271
|
+
Environment variables (Telegram):
|
|
272
|
+
TELEGRAM_BOT_TOKEN Telegram bot token (from @BotFather)
|
|
273
|
+
TELEGRAM_ALLOWED_CHAT_IDS Comma-separated allowed Telegram chat IDs
|
|
274
|
+
|
|
275
|
+
Environment variables (Weixin):
|
|
276
|
+
WEIXIN_BASE_URL Weixin API base URL (default: https://ilinkai.weixin.qq.com)
|
|
277
|
+
WEIXIN_BOT_TOKEN Weixin bot token (normally configured from dashboard QR login)
|
|
278
|
+
WEIXIN_ACCOUNT_ID Weixin bot account ID
|
|
279
|
+
|
|
280
|
+
Environment variables (per agent):
|
|
281
|
+
CLAUDE_MODEL Claude model name
|
|
282
|
+
CLAUDE_PERMISSION_MODE Permission mode (default: bypassPermissions)
|
|
283
|
+
CLAUDE_EXTRA_ARGS Extra CLI args for claude
|
|
284
|
+
CODEX_MODEL Codex model name
|
|
285
|
+
CODEX_REASONING_EFFORT Reasoning effort (default: xhigh)
|
|
286
|
+
CODEX_FULL_ACCESS Full-access mode (default: true)
|
|
287
|
+
CODEX_EXTRA_ARGS Extra CLI args for codex
|
|
288
|
+
GEMINI_MODEL Gemini model name
|
|
289
|
+
GEMINI_APPROVAL_MODE Approval mode (default: yolo)
|
|
290
|
+
GEMINI_SANDBOX Sandbox mode (default: false)
|
|
291
|
+
GEMINI_EXTRA_ARGS Extra CLI args for gemini
|
|
292
|
+
|
|
293
|
+
Bot commands (available once running):
|
|
294
|
+
/sessions List or switch coding sessions
|
|
295
|
+
/agents List or switch AI agents
|
|
296
|
+
/models List or switch models
|
|
297
|
+
/status Bot status, uptime, and token usage
|
|
298
|
+
/host Host machine info (CPU, memory, disk, battery)
|
|
299
|
+
/switch Browse and change working directory
|
|
300
|
+
/restart Restart with latest version
|
|
301
|
+
|
|
302
|
+
Environment variables (Feishu):
|
|
303
|
+
FEISHU_APP_ID Feishu app ID (from Feishu Open Platform)
|
|
304
|
+
FEISHU_APP_SECRET Feishu app secret
|
|
305
|
+
FEISHU_DOMAIN API domain (default: https://open.feishu.cn)
|
|
306
|
+
FEISHU_ALLOWED_CHAT_IDS Comma-separated allowed Feishu chat IDs
|
|
307
|
+
|
|
308
|
+
Notes:
|
|
309
|
+
- weixin setup is QR-based in the dashboard and currently supports text-only replies.
|
|
310
|
+
- --safe-mode delegates to the agent's own permission model; it does not add
|
|
311
|
+
a pikiloop-specific approval workflow.
|
|
312
|
+
|
|
313
|
+
Prerequisites: Node.js >= 18, and at least one agent CLI installed (claude or codex).
|
|
314
|
+
Docs: https://github.com/xiaotonng/pikiloop
|
|
315
|
+
`);
|
|
316
|
+
process.exit(0);
|
|
317
|
+
}
|
|
318
|
+
/* ── Phase: workdir persistence & daemon handoff ──────────────────── */
|
|
319
|
+
/**
|
|
320
|
+
* For a fresh CLI launch (not a daemon-managed child), persist the working
|
|
321
|
+
* directory into setting.json so the bot defaults to where the user invoked
|
|
322
|
+
* the command. Without `-w`, fall back to the cwd; this is preserved across
|
|
323
|
+
* non-daemon self-restarts because process-control inherits cwd when spawning
|
|
324
|
+
* the replacement process.
|
|
325
|
+
*/
|
|
326
|
+
function persistWorkdir(args, userConfig) {
|
|
327
|
+
if (process.env.PIKILOOP_DAEMON_CHILD)
|
|
328
|
+
return userConfig;
|
|
329
|
+
// launchd launches the process from `/`; without `-w`, that would clobber
|
|
330
|
+
// the user's saved workdir to "/". Skip persistence so the config stays
|
|
331
|
+
// whatever the user previously chose interactively.
|
|
332
|
+
if (process.env[FROM_LAUNCHD_ENV])
|
|
333
|
+
return userConfig;
|
|
334
|
+
const nextWorkdir = path.resolve(args.workdir || process.cwd());
|
|
335
|
+
if (userConfig.workdir === nextWorkdir)
|
|
336
|
+
return userConfig;
|
|
337
|
+
updateUserConfig({ workdir: nextWorkdir });
|
|
338
|
+
return loadUserConfig();
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* If daemon mode is active and we are the top-level process, become the
|
|
342
|
+
* watchdog. This function never returns in daemon mode.
|
|
343
|
+
*/
|
|
344
|
+
async function enterDaemonIfNeeded(args) {
|
|
345
|
+
if (args.daemon && !process.env.PIKILOOP_DAEMON_CHILD) {
|
|
346
|
+
await runDaemon(process.argv.slice(2));
|
|
347
|
+
}
|
|
348
|
+
if (!args.daemon) {
|
|
349
|
+
// --no-daemon: clear inherited env so requestProcessRestart uses the
|
|
350
|
+
// direct-spawn path instead of handing off to a non-existent daemon.
|
|
351
|
+
delete process.env.PIKILOOP_DAEMON_CHILD;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/** Install SIGUSR2 restart handler and clean it up on exit. */
|
|
355
|
+
function installRestartSignalHandler() {
|
|
356
|
+
const onSigusr2 = () => {
|
|
357
|
+
processLog('SIGUSR2 received, restarting...');
|
|
358
|
+
void requestProcessRestart({ log: processLog });
|
|
359
|
+
};
|
|
360
|
+
process.on('SIGUSR2', onSigusr2);
|
|
361
|
+
process.once('exit', () => {
|
|
362
|
+
process.off('SIGUSR2', onSigusr2);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Top-level shutdown safety net. Channels install their own SIGINT/SIGTERM
|
|
367
|
+
* handlers that do per-channel cleanup with a 3 s unref-ed force-exit timer,
|
|
368
|
+
* but those handlers only exist while a channel is running and silently fail
|
|
369
|
+
* if cleanup throws before the timer is set. This handler is the last-resort
|
|
370
|
+
* guarantee: once the user hits Ctrl+C, the process exits within the grace
|
|
371
|
+
* window no matter what state we're in.
|
|
372
|
+
*/
|
|
373
|
+
function installTopLevelShutdownHandler() {
|
|
374
|
+
const GRACE_MS = 5_000;
|
|
375
|
+
let shuttingDown = false;
|
|
376
|
+
const onSignal = (sig) => {
|
|
377
|
+
const exitCode = sig === 'SIGINT' ? 130 : 143;
|
|
378
|
+
if (shuttingDown) {
|
|
379
|
+
// Second Ctrl+C — bail immediately.
|
|
380
|
+
processLog(`${sig} again, forcing immediate exit`);
|
|
381
|
+
process.exit(exitCode);
|
|
382
|
+
}
|
|
383
|
+
shuttingDown = true;
|
|
384
|
+
processLog(`${sig} received, shutting down (force exit in ${GRACE_MS / 1000}s)...`);
|
|
385
|
+
setTimeout(() => process.exit(exitCode), GRACE_MS);
|
|
386
|
+
};
|
|
387
|
+
process.on('SIGINT', () => onSignal('SIGINT'));
|
|
388
|
+
process.on('SIGTERM', () => onSignal('SIGTERM'));
|
|
389
|
+
}
|
|
390
|
+
/* ── Phase: stop subcommand ───────────────────────────────────────── */
|
|
391
|
+
/**
|
|
392
|
+
* Find and terminate the running daemon. Reads the PID file written by
|
|
393
|
+
* `runDaemon`, sends SIGTERM, waits briefly, escalates to SIGKILL if the
|
|
394
|
+
* process is still alive. Never returns — always exits.
|
|
395
|
+
*/
|
|
396
|
+
async function handleStopCommand() {
|
|
397
|
+
const pid = readDaemonPidFile();
|
|
398
|
+
if (!pid) {
|
|
399
|
+
process.stderr.write('pikiloop stop: no daemon PID file found (is pikiloop running in daemon mode?)\n');
|
|
400
|
+
process.exit(1);
|
|
401
|
+
}
|
|
402
|
+
if (!isProcessAlive(pid)) {
|
|
403
|
+
process.stdout.write(`pikiloop stop: daemon (pid ${pid}) is not running, clearing stale PID file\n`);
|
|
404
|
+
clearDaemonPidFile();
|
|
405
|
+
process.exit(0);
|
|
406
|
+
}
|
|
407
|
+
try {
|
|
408
|
+
process.kill(pid, 'SIGTERM');
|
|
409
|
+
}
|
|
410
|
+
catch (err) {
|
|
411
|
+
const code = err?.code;
|
|
412
|
+
if (code === 'ESRCH') {
|
|
413
|
+
process.stdout.write(`pikiloop stop: daemon (pid ${pid}) already exited\n`);
|
|
414
|
+
clearDaemonPidFile();
|
|
415
|
+
process.exit(0);
|
|
416
|
+
}
|
|
417
|
+
process.stderr.write(`pikiloop stop: failed to signal pid ${pid}: ${err}\n`);
|
|
418
|
+
process.exit(1);
|
|
419
|
+
}
|
|
420
|
+
process.stdout.write(`pikiloop stop: SIGTERM → pid ${pid}\n`);
|
|
421
|
+
// Poll for up to 8 s; daemon's child needs ~3 s for its force-exit timer.
|
|
422
|
+
const deadline = Date.now() + 8_000;
|
|
423
|
+
while (Date.now() < deadline) {
|
|
424
|
+
if (!isProcessAlive(pid)) {
|
|
425
|
+
clearDaemonPidFile();
|
|
426
|
+
process.stdout.write(`pikiloop stop: daemon (pid ${pid}) stopped\n`);
|
|
427
|
+
process.exit(0);
|
|
428
|
+
}
|
|
429
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
430
|
+
}
|
|
431
|
+
// Escalate to SIGKILL.
|
|
432
|
+
process.stderr.write(`pikiloop stop: daemon (pid ${pid}) still alive after 8s, sending SIGKILL\n`);
|
|
433
|
+
try {
|
|
434
|
+
process.kill(pid, 'SIGKILL');
|
|
435
|
+
}
|
|
436
|
+
catch { }
|
|
437
|
+
clearDaemonPidFile();
|
|
438
|
+
process.exit(0);
|
|
439
|
+
}
|
|
440
|
+
/* ── Phase: doctor check ──────────────────────────────────────────── */
|
|
441
|
+
/** Run setup diagnostics and exit (--doctor). */
|
|
442
|
+
function runDoctorCheck(channel, tokenProvided) {
|
|
443
|
+
const setupState = collectSetupState({
|
|
444
|
+
agents: listVerboseAgents(),
|
|
445
|
+
channel,
|
|
446
|
+
tokenProvided,
|
|
447
|
+
});
|
|
448
|
+
const guide = buildSetupGuide(setupState, VERSION, { doctor: true });
|
|
449
|
+
const ready = isSetupReady(setupState);
|
|
450
|
+
if (ready)
|
|
451
|
+
process.stdout.write(`${guide}\nSetup looks ready.\n`);
|
|
452
|
+
else
|
|
453
|
+
process.stderr.write(guide);
|
|
454
|
+
process.exit(ready ? 0 : 1);
|
|
455
|
+
}
|
|
456
|
+
/* ── Phase: setup (dashboard / wizard / guide) ────────────────────── */
|
|
457
|
+
/**
|
|
458
|
+
* Poll the dashboard until the user completes configuration.
|
|
459
|
+
* Mutates `ctx` in place with freshly resolved channels.
|
|
460
|
+
*/
|
|
461
|
+
async function awaitDashboardConfig(dashboard, ctx) {
|
|
462
|
+
const ts = new Date().toTimeString().slice(0, 8);
|
|
463
|
+
process.stdout.write(`[pikiloop ${ts}] waiting for configuration via dashboard...\n`);
|
|
464
|
+
process.stdout.write(`[pikiloop ${ts}] configure at ${dashboard.url}; startup will continue automatically once ready.\n`);
|
|
465
|
+
// eslint-disable-next-line no-constant-condition
|
|
466
|
+
while (true) {
|
|
467
|
+
await new Promise(resolve => setTimeout(resolve, DAEMON_TIMEOUTS.configPollInterval));
|
|
468
|
+
ctx.userConfig = loadUserConfig();
|
|
469
|
+
const channels = resolveConfiguredChannels({
|
|
470
|
+
config: { ...ctx.userConfig, ...ctx.configOverrides },
|
|
471
|
+
tokenOverride: ctx.args.token,
|
|
472
|
+
});
|
|
473
|
+
const channel = channels[0] || 'feishu';
|
|
474
|
+
const nextSetupState = collectSetupState({
|
|
475
|
+
agents: listStartupAgents(),
|
|
476
|
+
channel,
|
|
477
|
+
tokenProvided: channels.length > 0 && hasConfiguredChannelToken({ ...ctx.userConfig, ...ctx.configOverrides }, channel, ctx.args.token),
|
|
478
|
+
});
|
|
479
|
+
// Dashboard-as-terminal: an installed agent is the only prerequisite to
|
|
480
|
+
// start. IM channels are optional — the dashboard is itself a terminal, so
|
|
481
|
+
// don't block startup waiting for a channel token.
|
|
482
|
+
const nextNeedsSetup = !hasReadyAgent(nextSetupState);
|
|
483
|
+
if (!nextNeedsSetup) {
|
|
484
|
+
const resumeTs = new Date().toTimeString().slice(0, 8);
|
|
485
|
+
process.stdout.write(`[pikiloop ${resumeTs}] configuration detected, starting bot channels...\n`);
|
|
486
|
+
return { channels, channel };
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Run the setup phase: dashboard wait-loop, terminal wizard, or guide printout.
|
|
492
|
+
* Returns the dashboard instance (if started) and possibly-updated userConfig.
|
|
493
|
+
*/
|
|
494
|
+
async function runSetupPhase(args, userConfig, configOverrides, channels, channel, tokenProvided) {
|
|
495
|
+
const setupState = collectSetupState({
|
|
496
|
+
agents: listStartupAgents(),
|
|
497
|
+
channel,
|
|
498
|
+
tokenProvided,
|
|
499
|
+
});
|
|
500
|
+
const useDashboard = !args.noDashboard && !args.setup;
|
|
501
|
+
let dashboard = null;
|
|
502
|
+
// With the dashboard as a terminal, an installed agent is enough to start.
|
|
503
|
+
// Without the dashboard (headless server / --no-dashboard), an IM channel is
|
|
504
|
+
// still required as the only terminal.
|
|
505
|
+
const needsSetup = useDashboard
|
|
506
|
+
? !hasReadyAgent(setupState)
|
|
507
|
+
: (channels.length === 0 || !tokenProvided || !hasReadyAgent(setupState));
|
|
508
|
+
if (useDashboard) {
|
|
509
|
+
// Suppress the browser pop on auto-start when there's no user-facing
|
|
510
|
+
// terminal: launchd-spawned bots, Docker/headless server runs, or when
|
|
511
|
+
// the user explicitly set PIKILOOP_OPEN_BROWSER=0.
|
|
512
|
+
const openBrowser = !process.env[FROM_LAUNCHD_ENV]
|
|
513
|
+
&& !envBool('PIKILOOP_DOCKER', false)
|
|
514
|
+
&& envBool('PIKILOOP_OPEN_BROWSER', true);
|
|
515
|
+
dashboard = await startDashboard({
|
|
516
|
+
port: args.dashboardPort || 3939,
|
|
517
|
+
open: openBrowser,
|
|
518
|
+
});
|
|
519
|
+
if (needsSetup) {
|
|
520
|
+
const ctx = { userConfig, configOverrides, args };
|
|
521
|
+
const resolved = await awaitDashboardConfig(dashboard, ctx);
|
|
522
|
+
userConfig = ctx.userConfig;
|
|
523
|
+
channels = resolved.channels;
|
|
524
|
+
channel = resolved.channel;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
else if (args.setup) {
|
|
528
|
+
const canPromptInteractively = !!(process.stdin.isTTY && process.stdout.isTTY);
|
|
529
|
+
if (!canPromptInteractively) {
|
|
530
|
+
process.stderr.write('--setup requires an interactive terminal.\n');
|
|
531
|
+
process.exit(1);
|
|
532
|
+
}
|
|
533
|
+
const wizard = await runSetupWizard({
|
|
534
|
+
version: VERSION,
|
|
535
|
+
channel,
|
|
536
|
+
argsAgent: args.agent || userConfig.defaultAgent || null,
|
|
537
|
+
currentToken: args.token || userConfig.telegramBotToken || null,
|
|
538
|
+
initialState: setupState,
|
|
539
|
+
listAgents: listVerboseAgents,
|
|
540
|
+
});
|
|
541
|
+
if (!wizard.completed)
|
|
542
|
+
process.exit(1);
|
|
543
|
+
userConfig = loadUserConfig();
|
|
544
|
+
}
|
|
545
|
+
else if (needsSetup) {
|
|
546
|
+
process.stdout.write(buildSetupGuide(setupState, VERSION));
|
|
547
|
+
process.exit(0);
|
|
548
|
+
}
|
|
549
|
+
return { dashboard, userConfig, channels, channel };
|
|
550
|
+
}
|
|
551
|
+
/* ── Phase: post-setup validation ─────────────────────────────────── */
|
|
552
|
+
/**
|
|
553
|
+
* Re-resolve channels after the setup phase and validate we have a runnable
|
|
554
|
+
* terminal: an installed agent is mandatory; an IM channel is required only
|
|
555
|
+
* when the dashboard isn't serving as the terminal. Exits on failure.
|
|
556
|
+
* Returns the (possibly empty) channel set — empty is valid in dashboard mode.
|
|
557
|
+
*/
|
|
558
|
+
function validatePostSetupChannels(configOverrides, userConfig, args, useDashboard) {
|
|
559
|
+
const effectiveConfig = { ...userConfig, ...configOverrides };
|
|
560
|
+
const channels = resolveConfiguredChannels({
|
|
561
|
+
config: effectiveConfig,
|
|
562
|
+
tokenOverride: args.token,
|
|
563
|
+
});
|
|
564
|
+
const channel = channels[0] || 'feishu';
|
|
565
|
+
const refreshedSetupState = collectSetupState({
|
|
566
|
+
agents: listStartupAgents(),
|
|
567
|
+
channel,
|
|
568
|
+
tokenProvided: channels.length > 0,
|
|
569
|
+
});
|
|
570
|
+
// An installed agent is the hard requirement — no terminal can run a session
|
|
571
|
+
// without one.
|
|
572
|
+
if (!hasReadyAgent(refreshedSetupState)) {
|
|
573
|
+
process.stderr.write(buildSetupGuide(refreshedSetupState, VERSION, { doctor: true }));
|
|
574
|
+
process.exit(1);
|
|
575
|
+
}
|
|
576
|
+
// Zero IM channels is fine when the dashboard is the terminal; only bail when
|
|
577
|
+
// there's no terminal at all (dashboard disabled AND no channel configured).
|
|
578
|
+
if (channels.length === 0 && !useDashboard) {
|
|
579
|
+
process.stdout.write(buildSetupGuide(refreshedSetupState, VERSION));
|
|
580
|
+
process.exit(0);
|
|
581
|
+
}
|
|
582
|
+
return { channels, channel };
|
|
583
|
+
}
|
|
584
|
+
/* ── Phase: runtime config & env setup ────────────────────────────── */
|
|
585
|
+
/**
|
|
586
|
+
* Build the final runtime config, apply token/model/permission overrides to
|
|
587
|
+
* the environment, start config file sync, and kick off agent auto-update.
|
|
588
|
+
*/
|
|
589
|
+
function applyRuntimeConfig(args, userConfig, configOverrides, channel) {
|
|
590
|
+
const runtimeConfig = { ...userConfig, ...configOverrides };
|
|
591
|
+
// Inject CLI token into channel-specific config fields.
|
|
592
|
+
if (args.token) {
|
|
593
|
+
if (channel === 'telegram')
|
|
594
|
+
runtimeConfig.telegramBotToken = args.token;
|
|
595
|
+
else if (channel === 'feishu') {
|
|
596
|
+
const [appId, ...rest] = args.token.split(':');
|
|
597
|
+
runtimeConfig.feishuAppId = appId;
|
|
598
|
+
runtimeConfig.feishuAppSecret = rest.join(':');
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (args.allowedIds && channel === 'telegram')
|
|
602
|
+
runtimeConfig.telegramAllowedChatIds = args.allowedIds;
|
|
603
|
+
applyUserConfig(runtimeConfig, undefined, { overwrite: true, clearMissing: true });
|
|
604
|
+
startAgentAutoUpdate({
|
|
605
|
+
config: runtimeConfig,
|
|
606
|
+
agents: listAgents({ includeVersion: true, refresh: true }).agents,
|
|
607
|
+
log: processLog,
|
|
608
|
+
});
|
|
609
|
+
// Model override: route to the correct agent env var.
|
|
610
|
+
if (args.model) {
|
|
611
|
+
const ag = args.agent || runtimeConfig.defaultAgent || 'codex';
|
|
612
|
+
if (ag === 'codex')
|
|
613
|
+
process.env.CODEX_MODEL = args.model;
|
|
614
|
+
else if (ag === 'gemini')
|
|
615
|
+
process.env.GEMINI_MODEL = args.model;
|
|
616
|
+
else
|
|
617
|
+
process.env.CLAUDE_MODEL = args.model;
|
|
618
|
+
}
|
|
619
|
+
if (args.timeout != null)
|
|
620
|
+
process.env.PIKILOOP_TIMEOUT = String(args.timeout);
|
|
621
|
+
// Permission mode: safe vs full-access.
|
|
622
|
+
if (args.safeMode) {
|
|
623
|
+
process.env.CODEX_FULL_ACCESS = 'false';
|
|
624
|
+
process.env.CLAUDE_PERMISSION_MODE = 'default';
|
|
625
|
+
process.env.GEMINI_APPROVAL_MODE = 'default';
|
|
626
|
+
process.env.GEMINI_SANDBOX = 'true';
|
|
627
|
+
}
|
|
628
|
+
else if (args.fullAccess || envBool('PIKILOOP_FULL_ACCESS', true)) {
|
|
629
|
+
process.env.CODEX_FULL_ACCESS = 'true';
|
|
630
|
+
process.env.CLAUDE_PERMISSION_MODE = 'bypassPermissions';
|
|
631
|
+
process.env.GEMINI_APPROVAL_MODE = 'yolo';
|
|
632
|
+
process.env.GEMINI_SANDBOX = 'false';
|
|
633
|
+
}
|
|
634
|
+
// Live-reload config file sync.
|
|
635
|
+
//
|
|
636
|
+
// Only pass overrides that came from CLI flags / explicit args, NOT the full
|
|
637
|
+
// runtimeConfig. Otherwise a snapshot of every user-managed field (model,
|
|
638
|
+
// effort, workdir, etc.) gets re-applied on every sync tick, silently
|
|
639
|
+
// reverting changes the user just made via the menu or dashboard.
|
|
640
|
+
const syncOverrides = {};
|
|
641
|
+
if (args.agent)
|
|
642
|
+
syncOverrides.defaultAgent = args.agent;
|
|
643
|
+
if (args.token) {
|
|
644
|
+
if (channel === 'telegram')
|
|
645
|
+
syncOverrides.telegramBotToken = args.token;
|
|
646
|
+
else if (channel === 'feishu') {
|
|
647
|
+
const [appId, ...rest] = args.token.split(':');
|
|
648
|
+
syncOverrides.feishuAppId = appId;
|
|
649
|
+
syncOverrides.feishuAppSecret = rest.join(':');
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (args.allowedIds && channel === 'telegram')
|
|
653
|
+
syncOverrides.telegramAllowedChatIds = args.allowedIds;
|
|
654
|
+
const stopUserConfigSync = startUserConfigSync({
|
|
655
|
+
overrides: syncOverrides,
|
|
656
|
+
log: message => processLog(message),
|
|
657
|
+
});
|
|
658
|
+
process.once('exit', stopUserConfigSync);
|
|
659
|
+
return runtimeConfig;
|
|
660
|
+
}
|
|
661
|
+
/* ── Phase: channel launch ────────────────────────────────────────── */
|
|
662
|
+
/**
|
|
663
|
+
* Hand off channel lifecycle to ChannelSupervisor and block forever. The
|
|
664
|
+
* supervisor reconciles bots against the user config — adding, removing,
|
|
665
|
+
* or replacing channels in response to dashboard saves without restarting
|
|
666
|
+
* the pikiloop process.
|
|
667
|
+
*
|
|
668
|
+
* Per-bot signal handlers (and the daemon supervisor when present) drive
|
|
669
|
+
* process exit; this promise is just a foreground keep-alive.
|
|
670
|
+
*/
|
|
671
|
+
async function launchChannels(channels, dashboard) {
|
|
672
|
+
processLog(`launching channels: ${channels.join(', ')}`);
|
|
673
|
+
const supervisor = new ChannelSupervisor({ dashboard, log: processLog });
|
|
674
|
+
await supervisor.start();
|
|
675
|
+
// Block forever — the dashboard HTTP listener and per-channel signal
|
|
676
|
+
// handlers keep the process alive and drive shutdown.
|
|
677
|
+
await new Promise(() => { });
|
|
678
|
+
}
|
|
679
|
+
/* ── main() ───────────────────────────────────────────────────────── */
|
|
680
|
+
export async function main() {
|
|
681
|
+
if (await handleMcpServeMode())
|
|
682
|
+
return;
|
|
683
|
+
const args = parseArgs(process.argv.slice(2));
|
|
684
|
+
let userConfig = loadUserConfig();
|
|
685
|
+
if (args.version) {
|
|
686
|
+
process.stdout.write(`pikiloop ${VERSION}\n`);
|
|
687
|
+
process.exit(0);
|
|
688
|
+
}
|
|
689
|
+
if (args.help)
|
|
690
|
+
printHelp();
|
|
691
|
+
if (args.stop)
|
|
692
|
+
await handleStopCommand();
|
|
693
|
+
// Persist workdir for fresh (non-daemon-child) launches.
|
|
694
|
+
userConfig = persistWorkdir(args, userConfig);
|
|
695
|
+
// Daemon mode: become watchdog (never returns in daemon mode).
|
|
696
|
+
await enterDaemonIfNeeded(args);
|
|
697
|
+
// Child / no-daemon process: install restart signal handler + top-level
|
|
698
|
+
// shutdown safety net so Ctrl+C always brings the process down.
|
|
699
|
+
installRestartSignalHandler();
|
|
700
|
+
installTopLevelShutdownHandler();
|
|
701
|
+
// Apply config overrides from CLI args.
|
|
702
|
+
const configOverrides = {};
|
|
703
|
+
if (args.agent)
|
|
704
|
+
configOverrides.defaultAgent = args.agent;
|
|
705
|
+
applyUserConfig({ ...userConfig, ...configOverrides }, undefined, { overwrite: true, clearMissing: true });
|
|
706
|
+
// Resolve initial channels.
|
|
707
|
+
const effectiveConfig = () => ({ ...userConfig, ...configOverrides });
|
|
708
|
+
let channels = resolveConfiguredChannels({ config: effectiveConfig(), tokenOverride: args.token });
|
|
709
|
+
let channel = channels[0] || 'feishu';
|
|
710
|
+
const tokenProvided = channels.length > 0 && hasConfiguredChannelToken(effectiveConfig(), channel, args.token);
|
|
711
|
+
// Doctor mode: check and exit.
|
|
712
|
+
if (args.doctor)
|
|
713
|
+
runDoctorCheck(channel, tokenProvided);
|
|
714
|
+
// Setup phase: dashboard, wizard, or guide.
|
|
715
|
+
const useDashboard = !args.noDashboard && !args.setup;
|
|
716
|
+
let dashboard;
|
|
717
|
+
({ dashboard, userConfig, channels, channel } = await runSetupPhase(args, userConfig, configOverrides, channels, channel, tokenProvided));
|
|
718
|
+
// Validate the terminal is runnable after setup (channels may be empty when
|
|
719
|
+
// the dashboard is serving as the terminal).
|
|
720
|
+
({ channels, channel } = validatePostSetupChannels(configOverrides, userConfig, args, useDashboard));
|
|
721
|
+
// Apply runtime config, env overrides, and start config sync.
|
|
722
|
+
applyRuntimeConfig(args, userConfig, configOverrides, channel);
|
|
723
|
+
// Launch bot channel(s).
|
|
724
|
+
await launchChannels(channels, dashboard);
|
|
725
|
+
}
|
|
726
|
+
main().catch(err => { console.error(err); process.exit(1); });
|