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.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +353 -0
  3. package/README.v2.md +287 -0
  4. package/README.zh-CN.md +352 -0
  5. package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
  6. package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
  7. package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
  8. package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
  9. package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
  10. package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
  11. package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
  12. package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
  13. package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
  14. package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
  15. package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
  16. package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
  17. package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
  18. package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
  19. package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
  20. package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
  21. package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
  22. package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
  23. package/dashboard/dist/assets/index-reSbuley.css +1 -0
  24. package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
  25. package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
  26. package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
  27. package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
  28. package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
  29. package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
  30. package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
  31. package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
  32. package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
  33. package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
  34. package/dashboard/dist/favicon.svg +28 -0
  35. package/dashboard/dist/index.html +17 -0
  36. package/dist/agent/acp-client.js +261 -0
  37. package/dist/agent/auto-update.js +432 -0
  38. package/dist/agent/await-resume.js +50 -0
  39. package/dist/agent/cli/auth.js +325 -0
  40. package/dist/agent/cli/catalog.js +40 -0
  41. package/dist/agent/cli/detector.js +136 -0
  42. package/dist/agent/cli/index.js +7 -0
  43. package/dist/agent/cli/registry.js +33 -0
  44. package/dist/agent/driver.js +39 -0
  45. package/dist/agent/drivers/claude-tui.js +2297 -0
  46. package/dist/agent/drivers/claude.js +2689 -0
  47. package/dist/agent/drivers/codex.js +2210 -0
  48. package/dist/agent/drivers/gemini.js +1059 -0
  49. package/dist/agent/drivers/hermes.js +795 -0
  50. package/dist/agent/goal.js +274 -0
  51. package/dist/agent/handover.js +130 -0
  52. package/dist/agent/images.js +355 -0
  53. package/dist/agent/index.js +50 -0
  54. package/dist/agent/mcp/bridge.js +791 -0
  55. package/dist/agent/mcp/extensions.js +637 -0
  56. package/dist/agent/mcp/oauth.js +353 -0
  57. package/dist/agent/mcp/registry.js +119 -0
  58. package/dist/agent/mcp/session-server.js +229 -0
  59. package/dist/agent/mcp/tools/ask-user.js +113 -0
  60. package/dist/agent/mcp/tools/await-resume.js +77 -0
  61. package/dist/agent/mcp/tools/goal.js +144 -0
  62. package/dist/agent/mcp/tools/types.js +12 -0
  63. package/dist/agent/mcp/tools/workspace.js +212 -0
  64. package/dist/agent/npm.js +31 -0
  65. package/dist/agent/session.js +1206 -0
  66. package/dist/agent/skill-installer.js +160 -0
  67. package/dist/agent/skills.js +257 -0
  68. package/dist/agent/stream.js +743 -0
  69. package/dist/agent/types.js +13 -0
  70. package/dist/agent/utils.js +687 -0
  71. package/dist/bot/bot.js +2499 -0
  72. package/dist/bot/command-ui.js +633 -0
  73. package/dist/bot/commands.js +513 -0
  74. package/dist/bot/headless-bot.js +36 -0
  75. package/dist/bot/host.js +192 -0
  76. package/dist/bot/human-loop.js +168 -0
  77. package/dist/bot/menu.js +48 -0
  78. package/dist/bot/orchestration.js +79 -0
  79. package/dist/bot/render-shared.js +309 -0
  80. package/dist/bot/session-hub.js +361 -0
  81. package/dist/bot/session-status.js +55 -0
  82. package/dist/bot/streaming.js +309 -0
  83. package/dist/browser-profile.js +579 -0
  84. package/dist/browser-supervisor.js +249 -0
  85. package/dist/catalog/cli-tools.js +421 -0
  86. package/dist/catalog/index.js +21 -0
  87. package/dist/catalog/local-models.js +94 -0
  88. package/dist/catalog/mcp-servers.js +315 -0
  89. package/dist/catalog/skill-repos.js +173 -0
  90. package/dist/channels/base.js +55 -0
  91. package/dist/channels/dingtalk/bot.js +549 -0
  92. package/dist/channels/dingtalk/channel.js +268 -0
  93. package/dist/channels/discord/bot.js +552 -0
  94. package/dist/channels/discord/channel.js +245 -0
  95. package/dist/channels/feishu/bot.js +1275 -0
  96. package/dist/channels/feishu/channel.js +911 -0
  97. package/dist/channels/feishu/markdown.js +91 -0
  98. package/dist/channels/feishu/render.js +619 -0
  99. package/dist/channels/health.js +109 -0
  100. package/dist/channels/slack/bot.js +554 -0
  101. package/dist/channels/slack/channel.js +283 -0
  102. package/dist/channels/states.js +6 -0
  103. package/dist/channels/telegram/bot.js +1310 -0
  104. package/dist/channels/telegram/channel.js +820 -0
  105. package/dist/channels/telegram/directory.js +111 -0
  106. package/dist/channels/telegram/live-preview.js +220 -0
  107. package/dist/channels/telegram/render.js +384 -0
  108. package/dist/channels/wecom/bot.js +558 -0
  109. package/dist/channels/wecom/channel.js +479 -0
  110. package/dist/channels/weixin/api.js +520 -0
  111. package/dist/channels/weixin/bot.js +1000 -0
  112. package/dist/channels/weixin/channel.js +222 -0
  113. package/dist/cli/autostart.js +262 -0
  114. package/dist/cli/channel-supervisor.js +313 -0
  115. package/dist/cli/channels.js +54 -0
  116. package/dist/cli/main.js +726 -0
  117. package/dist/cli/onboarding.js +227 -0
  118. package/dist/cli/run.js +308 -0
  119. package/dist/cli/setup-wizard.js +235 -0
  120. package/dist/core/config/runtime-config.js +201 -0
  121. package/dist/core/config/user-config.js +510 -0
  122. package/dist/core/config/validation.js +521 -0
  123. package/dist/core/constants.js +400 -0
  124. package/dist/core/git.js +145 -0
  125. package/dist/core/legacy-compat.js +60 -0
  126. package/dist/core/logging.js +101 -0
  127. package/dist/core/platform.js +59 -0
  128. package/dist/core/process-control.js +315 -0
  129. package/dist/core/secrets/index.js +42 -0
  130. package/dist/core/secrets/inline-seal.js +60 -0
  131. package/dist/core/secrets/ref.js +33 -0
  132. package/dist/core/secrets/resolver.js +65 -0
  133. package/dist/core/secrets/store.js +63 -0
  134. package/dist/core/utils.js +233 -0
  135. package/dist/core/version.js +15 -0
  136. package/dist/dashboard/platform.js +219 -0
  137. package/dist/dashboard/routes/agents.js +450 -0
  138. package/dist/dashboard/routes/cli.js +174 -0
  139. package/dist/dashboard/routes/config.js +523 -0
  140. package/dist/dashboard/routes/extensions.js +745 -0
  141. package/dist/dashboard/routes/local-models.js +290 -0
  142. package/dist/dashboard/routes/models.js +324 -0
  143. package/dist/dashboard/routes/sessions.js +838 -0
  144. package/dist/dashboard/runtime.js +410 -0
  145. package/dist/dashboard/server.js +237 -0
  146. package/dist/dashboard/session-control.js +347 -0
  147. package/dist/model/catalog.js +104 -0
  148. package/dist/model/index.js +20 -0
  149. package/dist/model/injector.js +272 -0
  150. package/dist/model/provider-models.js +112 -0
  151. package/dist/model/store.js +212 -0
  152. package/dist/model/types.js +13 -0
  153. package/dist/model/validation.js +203 -0
  154. package/package.json +82 -0
@@ -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); });