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,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeChat channel transport.
|
|
3
|
+
*/
|
|
4
|
+
import { Channel, DEFAULT_CHANNEL_CAPABILITIES, splitText, sleep, } from '../base.js';
|
|
5
|
+
import { WEIXIN_LIMITS } from '../../core/constants.js';
|
|
6
|
+
import { ChannelHealth } from '../health.js';
|
|
7
|
+
import { extractWeixinTextBody, markdownToWeixinPlainText, normalizeWeixinBaseUrl, WeixinMessageType, weixinGetConfig, weixinGetUpdates, weixinSendTextMessage, weixinSendTyping, } from './api.js';
|
|
8
|
+
const WEIXIN_MAX_MESSAGE_LENGTH = WEIXIN_LIMITS.maxMessageLength;
|
|
9
|
+
function describeError(error) {
|
|
10
|
+
return error instanceof Error ? error.message : String(error ?? 'unknown error');
|
|
11
|
+
}
|
|
12
|
+
function isAbortError(error) {
|
|
13
|
+
return error instanceof Error && error.name === 'AbortError';
|
|
14
|
+
}
|
|
15
|
+
export class WeixinChannel extends Channel {
|
|
16
|
+
capabilities = {
|
|
17
|
+
...DEFAULT_CHANNEL_CAPABILITIES,
|
|
18
|
+
typingIndicators: true,
|
|
19
|
+
};
|
|
20
|
+
knownChats = new Set();
|
|
21
|
+
token;
|
|
22
|
+
accountId;
|
|
23
|
+
baseUrl;
|
|
24
|
+
pollTimeout;
|
|
25
|
+
allowedChatIds;
|
|
26
|
+
messageHandlers = new Set();
|
|
27
|
+
errorHandlers = new Set();
|
|
28
|
+
logHandlers = new Set();
|
|
29
|
+
chatMeta = new Map();
|
|
30
|
+
stopping = false;
|
|
31
|
+
updateBuf = '';
|
|
32
|
+
listenAbort = null;
|
|
33
|
+
constructor(opts) {
|
|
34
|
+
super();
|
|
35
|
+
this.token = opts.token;
|
|
36
|
+
this.accountId = opts.accountId;
|
|
37
|
+
this.baseUrl = normalizeWeixinBaseUrl(opts.baseUrl);
|
|
38
|
+
this.pollTimeout = opts.pollTimeout ?? WEIXIN_LIMITS.longPollTimeout;
|
|
39
|
+
this.allowedChatIds = opts.allowedChatIds;
|
|
40
|
+
}
|
|
41
|
+
onMessage(handler) {
|
|
42
|
+
this.messageHandlers.add(handler);
|
|
43
|
+
return this;
|
|
44
|
+
}
|
|
45
|
+
onError(handler) {
|
|
46
|
+
this.errorHandlers.add(handler);
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
onLog(handler) {
|
|
50
|
+
this.logHandlers.add(handler);
|
|
51
|
+
return this;
|
|
52
|
+
}
|
|
53
|
+
async connect() {
|
|
54
|
+
const shortId = this.accountId.length > 18 ? `${this.accountId.slice(0, 8)}...${this.accountId.slice(-6)}` : this.accountId;
|
|
55
|
+
this.bot = {
|
|
56
|
+
id: this.accountId,
|
|
57
|
+
username: `weixin_${shortId}`,
|
|
58
|
+
displayName: `Weixin ${shortId}`,
|
|
59
|
+
};
|
|
60
|
+
return this.bot;
|
|
61
|
+
}
|
|
62
|
+
async listen() {
|
|
63
|
+
this.stopping = false;
|
|
64
|
+
this.listenAbort = new AbortController();
|
|
65
|
+
const health = new ChannelHealth({
|
|
66
|
+
label: 'Weixin',
|
|
67
|
+
opAction: 'polling',
|
|
68
|
+
initialDelayMs: 1_000,
|
|
69
|
+
maxDelayMs: WEIXIN_LIMITS.maxRetryDelay,
|
|
70
|
+
sustainedFailureHint: 'verify weixinBaseUrl / weixinBotToken / weixinAccountId in setting.json',
|
|
71
|
+
log: (msg, level) => this.emitLog(msg, level),
|
|
72
|
+
});
|
|
73
|
+
try {
|
|
74
|
+
while (!this.stopping) {
|
|
75
|
+
try {
|
|
76
|
+
const response = await weixinGetUpdates({
|
|
77
|
+
baseUrl: this.baseUrl,
|
|
78
|
+
token: this.token,
|
|
79
|
+
getUpdatesBuf: this.updateBuf,
|
|
80
|
+
timeoutMs: this.pollTimeout,
|
|
81
|
+
signal: this.listenAbort.signal,
|
|
82
|
+
});
|
|
83
|
+
if (response.get_updates_buf !== undefined)
|
|
84
|
+
this.updateBuf = response.get_updates_buf || '';
|
|
85
|
+
// Errcode check BEFORE recordSuccess(): an HTTP 200 carrying a
|
|
86
|
+
// business error code (e.g. "session timeout") must not silently
|
|
87
|
+
// reset the exponential backoff.
|
|
88
|
+
if ((response.ret ?? 0) !== 0 || (response.errcode ?? 0) !== 0) {
|
|
89
|
+
throw new Error(`Weixin getupdates failed: ${response.errmsg || response.errcode || response.ret}`);
|
|
90
|
+
}
|
|
91
|
+
health.recordSuccess();
|
|
92
|
+
for (const message of response.msgs || []) {
|
|
93
|
+
await this.dispatchInboundMessage(message);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (this.stopping || isAbortError(error))
|
|
98
|
+
break;
|
|
99
|
+
await sleep(health.recordFailure(error));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
this.listenAbort = null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
disconnect() {
|
|
108
|
+
this.stopping = true;
|
|
109
|
+
this.listenAbort?.abort();
|
|
110
|
+
}
|
|
111
|
+
async send(chatId, text, _opts) {
|
|
112
|
+
const meta = this.chatMeta.get(String(chatId));
|
|
113
|
+
if (!meta?.contextToken)
|
|
114
|
+
throw new Error('Weixin context token is missing for this chat.');
|
|
115
|
+
const plain = markdownToWeixinPlainText(text) || String(text || '').trim();
|
|
116
|
+
const chunks = splitText(plain, WEIXIN_MAX_MESSAGE_LENGTH).map(chunk => chunk.trim()).filter(Boolean);
|
|
117
|
+
let lastMessageId = null;
|
|
118
|
+
for (const chunk of chunks) {
|
|
119
|
+
await weixinSendTextMessage({
|
|
120
|
+
baseUrl: this.baseUrl,
|
|
121
|
+
token: this.token,
|
|
122
|
+
toUserId: meta.userId,
|
|
123
|
+
text: chunk,
|
|
124
|
+
contextToken: meta.contextToken,
|
|
125
|
+
});
|
|
126
|
+
lastMessageId = `wx:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`;
|
|
127
|
+
}
|
|
128
|
+
return lastMessageId;
|
|
129
|
+
}
|
|
130
|
+
async editMessage(_chatId, _msgId, _text, _opts) { }
|
|
131
|
+
async deleteMessage(_chatId, _msgId) { }
|
|
132
|
+
async sendTyping(chatId, _opts) {
|
|
133
|
+
const meta = this.chatMeta.get(String(chatId));
|
|
134
|
+
if (!meta?.contextToken || !meta.userId)
|
|
135
|
+
return;
|
|
136
|
+
let typingTicket = meta.typingTicket;
|
|
137
|
+
if (!typingTicket) {
|
|
138
|
+
const config = await weixinGetConfig({
|
|
139
|
+
baseUrl: this.baseUrl,
|
|
140
|
+
token: this.token,
|
|
141
|
+
userId: meta.userId,
|
|
142
|
+
contextToken: meta.contextToken,
|
|
143
|
+
});
|
|
144
|
+
typingTicket = String(config.typing_ticket || '').trim();
|
|
145
|
+
if (!typingTicket)
|
|
146
|
+
return;
|
|
147
|
+
meta.typingTicket = typingTicket;
|
|
148
|
+
this.chatMeta.set(String(chatId), meta);
|
|
149
|
+
}
|
|
150
|
+
await weixinSendTyping({
|
|
151
|
+
baseUrl: this.baseUrl,
|
|
152
|
+
token: this.token,
|
|
153
|
+
userId: meta.userId,
|
|
154
|
+
typingTicket,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
composeChatId(userId) {
|
|
158
|
+
return `${this.accountId}:${userId}`;
|
|
159
|
+
}
|
|
160
|
+
isAllowed(chatId, userId) {
|
|
161
|
+
if (!this.allowedChatIds?.size)
|
|
162
|
+
return true;
|
|
163
|
+
return this.allowedChatIds.has(chatId) || this.allowedChatIds.has(userId);
|
|
164
|
+
}
|
|
165
|
+
async dispatchInboundMessage(message) {
|
|
166
|
+
if ((message.message_type ?? WeixinMessageType.USER) !== WeixinMessageType.USER)
|
|
167
|
+
return;
|
|
168
|
+
const userId = String(message.from_user_id || '').trim();
|
|
169
|
+
if (!userId)
|
|
170
|
+
return;
|
|
171
|
+
const chatId = this.composeChatId(userId);
|
|
172
|
+
if (!this.isAllowed(chatId, userId))
|
|
173
|
+
return;
|
|
174
|
+
const existing = this.chatMeta.get(chatId);
|
|
175
|
+
const contextToken = String(message.context_token || existing?.contextToken || '').trim();
|
|
176
|
+
const meta = {
|
|
177
|
+
userId,
|
|
178
|
+
contextToken,
|
|
179
|
+
typingTicket: existing?.contextToken === contextToken ? existing?.typingTicket : undefined,
|
|
180
|
+
};
|
|
181
|
+
this.chatMeta.set(chatId, meta);
|
|
182
|
+
this.knownChats.add(chatId);
|
|
183
|
+
const ctx = {
|
|
184
|
+
chatId,
|
|
185
|
+
messageId: String(message.message_id || message.seq || Date.now()),
|
|
186
|
+
from: { userId },
|
|
187
|
+
reply: (text, opts) => this.send(chatId, text, opts),
|
|
188
|
+
editReply: (msgId, text, opts) => this.editMessage(chatId, msgId, text, opts),
|
|
189
|
+
sendTyping: () => this.sendTyping(chatId),
|
|
190
|
+
channel: this,
|
|
191
|
+
raw: message,
|
|
192
|
+
};
|
|
193
|
+
const payload = {
|
|
194
|
+
text: extractWeixinTextBody(message),
|
|
195
|
+
files: [],
|
|
196
|
+
};
|
|
197
|
+
for (const handler of this.messageHandlers) {
|
|
198
|
+
try {
|
|
199
|
+
await handler(payload, ctx);
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
this.emitError(error instanceof Error ? error : new Error(describeError(error)));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
emitError(error) {
|
|
207
|
+
for (const handler of this.errorHandlers) {
|
|
208
|
+
try {
|
|
209
|
+
handler(error);
|
|
210
|
+
}
|
|
211
|
+
catch { }
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
emitLog(msg, level) {
|
|
215
|
+
for (const handler of this.logHandlers) {
|
|
216
|
+
try {
|
|
217
|
+
handler(msg, level);
|
|
218
|
+
}
|
|
219
|
+
catch { }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macOS LaunchAgent integration for `pikiloop --daemon`.
|
|
3
|
+
*
|
|
4
|
+
* Every time the user runs pikiloop with an explicit `--daemon` flag on
|
|
5
|
+
* macOS *without* a LaunchAgent already installed, an osascript dialog asks
|
|
6
|
+
* whether to enable login auto-start. Choosing Enable writes
|
|
7
|
+
* `~/Library/LaunchAgents/ai.pikiloop.gateway.plist` and loads it via
|
|
8
|
+
* `launchctl bootstrap`. There is intentionally no CLI to disable: the user
|
|
9
|
+
* toggles it off under System Settings → General → Login Items, which is
|
|
10
|
+
* where macOS surfaces every LaunchAgent installed in
|
|
11
|
+
* `~/Library/LaunchAgents`.
|
|
12
|
+
*
|
|
13
|
+
* Decision flow (`maybePromptAutostart`):
|
|
14
|
+
* 1. Non-darwin → no-op.
|
|
15
|
+
* 2. Already running under launchd (PIKILOOP_FROM_LAUNCHD set) → no-op.
|
|
16
|
+
* 3. plist exists but its ProgramArguments no longer point to a valid
|
|
17
|
+
* binary (e.g. Homebrew migration moved node) → silently rewrite.
|
|
18
|
+
* 4. plist already valid → no-op.
|
|
19
|
+
* 5. Non-interactive (no TTY, CI=1) → no-op.
|
|
20
|
+
* 6. Otherwise → show dialog after a short delay so the bot is already
|
|
21
|
+
* live when the user sees it. "Not now" silently dismisses; the next
|
|
22
|
+
* `--daemon` run will ask again.
|
|
23
|
+
*/
|
|
24
|
+
import { exec, execFile } from 'node:child_process';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { promisify } from 'node:util';
|
|
29
|
+
import { whichSync } from '../core/platform.js';
|
|
30
|
+
import { STATE_DIR_NAME } from '../core/constants.js';
|
|
31
|
+
const execFileAsync = promisify(execFile);
|
|
32
|
+
const PLIST_LABEL = 'ai.pikiloop.gateway';
|
|
33
|
+
const PLIST_DIR = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
34
|
+
const PLIST_PATH = path.join(PLIST_DIR, `${PLIST_LABEL}.plist`);
|
|
35
|
+
const PIKILOOP_HOME = path.join(os.homedir(), STATE_DIR_NAME);
|
|
36
|
+
// Pre-rename LaunchAgent — removed on (re)install so an upgraded machine never
|
|
37
|
+
// runs two daemons (old pikiclaw + new pikiloop). Delete post-rename.
|
|
38
|
+
const LEGACY_PLIST_LABEL = 'ai.pikiclaw.gateway';
|
|
39
|
+
const LEGACY_PLIST_PATH = path.join(PLIST_DIR, `${LEGACY_PLIST_LABEL}.plist`);
|
|
40
|
+
const PROMPT_DELAY_MS = 3000;
|
|
41
|
+
export const FROM_LAUNCHD_ENV = 'PIKILOOP_FROM_LAUNCHD';
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the command used to launch pikiloop, so the plist can re-launch it
|
|
44
|
+
* the same way. We distinguish npx (`.../_npx/<hash>/.../main.js`) from a
|
|
45
|
+
* globally installed binary (`pikiloop` on PATH).
|
|
46
|
+
*/
|
|
47
|
+
function detectInvocation() {
|
|
48
|
+
const entry = process.argv[1] || '';
|
|
49
|
+
const userArgs = process.argv.slice(2);
|
|
50
|
+
if (!userArgs.includes('--daemon'))
|
|
51
|
+
userArgs.push('--daemon');
|
|
52
|
+
if (entry.includes('/_npx/') || entry.includes('\\_npx\\')) {
|
|
53
|
+
const npxBin = whichSync('npx');
|
|
54
|
+
if (!npxBin)
|
|
55
|
+
return null;
|
|
56
|
+
return { program: npxBin, args: ['-y', 'pikiloop@latest', ...userArgs] };
|
|
57
|
+
}
|
|
58
|
+
const pikiloopBin = whichSync('pikiloop');
|
|
59
|
+
if (pikiloopBin)
|
|
60
|
+
return { program: pikiloopBin, args: userArgs };
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
function plistExists() {
|
|
64
|
+
try {
|
|
65
|
+
return fs.statSync(PLIST_PATH).isFile();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function isInteractive() {
|
|
72
|
+
if (process.env.CI)
|
|
73
|
+
return false;
|
|
74
|
+
if (process.env[FROM_LAUNCHD_ENV])
|
|
75
|
+
return false;
|
|
76
|
+
return Boolean(process.stdout.isTTY || process.stderr.isTTY);
|
|
77
|
+
}
|
|
78
|
+
function escapeXml(s) {
|
|
79
|
+
return s.replace(/[<>&"']/g, c => ({
|
|
80
|
+
'<': '<', '>': '>', '&': '&', '"': '"', "'": ''',
|
|
81
|
+
}[c]));
|
|
82
|
+
}
|
|
83
|
+
function buildPlistXml(invocation) {
|
|
84
|
+
const programArgs = [invocation.program, ...invocation.args]
|
|
85
|
+
.map(arg => ` <string>${escapeXml(arg)}</string>`)
|
|
86
|
+
.join('\n');
|
|
87
|
+
const stdoutPath = path.join(PIKILOOP_HOME, 'launchd-stdout.log');
|
|
88
|
+
const stderrPath = path.join(PIKILOOP_HOME, 'launchd-stderr.log');
|
|
89
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
90
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
91
|
+
<plist version="1.0">
|
|
92
|
+
<dict>
|
|
93
|
+
<key>Label</key>
|
|
94
|
+
<string>${PLIST_LABEL}</string>
|
|
95
|
+
<key>ProgramArguments</key>
|
|
96
|
+
<array>
|
|
97
|
+
${programArgs}
|
|
98
|
+
</array>
|
|
99
|
+
<key>RunAtLoad</key>
|
|
100
|
+
<true/>
|
|
101
|
+
<key>KeepAlive</key>
|
|
102
|
+
<dict>
|
|
103
|
+
<key>SuccessfulExit</key>
|
|
104
|
+
<false/>
|
|
105
|
+
</dict>
|
|
106
|
+
<key>ThrottleInterval</key>
|
|
107
|
+
<integer>30</integer>
|
|
108
|
+
<key>EnvironmentVariables</key>
|
|
109
|
+
<dict>
|
|
110
|
+
<key>${FROM_LAUNCHD_ENV}</key>
|
|
111
|
+
<string>1</string>
|
|
112
|
+
<key>PATH</key>
|
|
113
|
+
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
114
|
+
</dict>
|
|
115
|
+
<key>StandardOutPath</key>
|
|
116
|
+
<string>${escapeXml(stdoutPath)}</string>
|
|
117
|
+
<key>StandardErrorPath</key>
|
|
118
|
+
<string>${escapeXml(stderrPath)}</string>
|
|
119
|
+
</dict>
|
|
120
|
+
</plist>
|
|
121
|
+
`;
|
|
122
|
+
}
|
|
123
|
+
function plistIsStale(invocation) {
|
|
124
|
+
try {
|
|
125
|
+
const xml = fs.readFileSync(PLIST_PATH, 'utf-8');
|
|
126
|
+
return !xml.includes(`<string>${escapeXml(invocation.program)}</string>`);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async function showEnableDialog() {
|
|
133
|
+
const script = [
|
|
134
|
+
'display dialog ',
|
|
135
|
+
'"Start pikiloop automatically when you log in?\\n\\n',
|
|
136
|
+
'You can change this anytime in:\\n',
|
|
137
|
+
'System Settings → General → Login Items" ',
|
|
138
|
+
'buttons {"Not now", "Enable"} default button "Enable" ',
|
|
139
|
+
'with title "pikiloop" with icon note',
|
|
140
|
+
].join('');
|
|
141
|
+
try {
|
|
142
|
+
const { stdout } = await execFileAsync('osascript', ['-e', script]);
|
|
143
|
+
if (stdout.includes('Enable'))
|
|
144
|
+
return 'enable';
|
|
145
|
+
if (stdout.includes('Not now'))
|
|
146
|
+
return 'not_now';
|
|
147
|
+
return 'closed';
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// User pressed Cmd-. or closed the dialog — osascript exits non-zero.
|
|
151
|
+
return 'closed';
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async function bootstrapLaunchAgent(log) {
|
|
155
|
+
const uid = process.getuid?.() ?? 0;
|
|
156
|
+
const domain = `gui/${uid}`;
|
|
157
|
+
// bootout first so a reinstall replaces any previously-loaded copy. We
|
|
158
|
+
// swallow errors here since bootout fails harmlessly when nothing is loaded.
|
|
159
|
+
await new Promise(resolve => {
|
|
160
|
+
exec(`launchctl bootout ${domain}/${PLIST_LABEL}`, () => resolve());
|
|
161
|
+
});
|
|
162
|
+
try {
|
|
163
|
+
await execFileAsync('launchctl', ['bootstrap', domain, PLIST_PATH]);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
log(`autostart: launchctl bootstrap failed: ${err?.message || err}`);
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Remove the pre-rename `ai.pikiclaw.gateway` LaunchAgent if present, so an
|
|
173
|
+
* upgraded install never runs two daemons (old pikiclaw + new pikiloop).
|
|
174
|
+
* Best-effort; safe when nothing is loaded. Delete a couple releases post-rename.
|
|
175
|
+
*/
|
|
176
|
+
async function cleanupLegacyAutostart(log) {
|
|
177
|
+
try {
|
|
178
|
+
let existed = false;
|
|
179
|
+
try {
|
|
180
|
+
existed = fs.statSync(LEGACY_PLIST_PATH).isFile();
|
|
181
|
+
}
|
|
182
|
+
catch { }
|
|
183
|
+
const uid = process.getuid?.() ?? 0;
|
|
184
|
+
await new Promise(resolve => {
|
|
185
|
+
exec(`launchctl bootout gui/${uid}/${LEGACY_PLIST_LABEL}`, () => resolve());
|
|
186
|
+
});
|
|
187
|
+
if (existed) {
|
|
188
|
+
try {
|
|
189
|
+
fs.unlinkSync(LEGACY_PLIST_PATH);
|
|
190
|
+
}
|
|
191
|
+
catch { }
|
|
192
|
+
log(`autostart: removed legacy LaunchAgent ${LEGACY_PLIST_LABEL}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch { /* best-effort */ }
|
|
196
|
+
}
|
|
197
|
+
async function installAutostart(log, invocation) {
|
|
198
|
+
try {
|
|
199
|
+
fs.mkdirSync(PLIST_DIR, { recursive: true });
|
|
200
|
+
fs.mkdirSync(PIKILOOP_HOME, { recursive: true });
|
|
201
|
+
fs.writeFileSync(PLIST_PATH, buildPlistXml(invocation));
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
log(`autostart: failed to write plist: ${err?.message || err}`);
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
const loaded = await bootstrapLaunchAgent(log);
|
|
208
|
+
if (loaded) {
|
|
209
|
+
log(`autostart: installed LaunchAgent at ${PLIST_PATH}`);
|
|
210
|
+
await cleanupLegacyAutostart(log);
|
|
211
|
+
}
|
|
212
|
+
return loaded;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Top-level entry called once from the watchdog process when the user passed
|
|
216
|
+
* an explicit `--daemon` flag. Fires and forgets; never throws.
|
|
217
|
+
*/
|
|
218
|
+
export function maybePromptAutostart(log) {
|
|
219
|
+
if (process.platform !== 'darwin')
|
|
220
|
+
return;
|
|
221
|
+
if (process.env[FROM_LAUNCHD_ENV])
|
|
222
|
+
return;
|
|
223
|
+
const invocation = detectInvocation();
|
|
224
|
+
if (!invocation)
|
|
225
|
+
return;
|
|
226
|
+
if (plistExists()) {
|
|
227
|
+
if (plistIsStale(invocation)) {
|
|
228
|
+
void installAutostart(msg => log(`autostart (rewrite): ${msg}`), invocation);
|
|
229
|
+
}
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (!isInteractive())
|
|
233
|
+
return;
|
|
234
|
+
setTimeout(() => {
|
|
235
|
+
void (async () => {
|
|
236
|
+
try {
|
|
237
|
+
const choice = await showEnableDialog();
|
|
238
|
+
if (choice === 'enable') {
|
|
239
|
+
await installAutostart(log, invocation);
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
log('autostart: not enabled this run; will ask again next time `pikiloop --daemon` runs');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
log(`autostart: prompt failed: ${err?.message || err}`);
|
|
247
|
+
}
|
|
248
|
+
})();
|
|
249
|
+
}, PROMPT_DELAY_MS).unref?.();
|
|
250
|
+
}
|
|
251
|
+
// ─── exports for tests ─────────────────────────────────────────────────
|
|
252
|
+
export const __test = {
|
|
253
|
+
PLIST_LABEL,
|
|
254
|
+
PLIST_PATH,
|
|
255
|
+
LEGACY_PLIST_LABEL,
|
|
256
|
+
LEGACY_PLIST_PATH,
|
|
257
|
+
detectInvocation,
|
|
258
|
+
buildPlistXml,
|
|
259
|
+
plistIsStale,
|
|
260
|
+
escapeXml,
|
|
261
|
+
cleanupLegacyAutostart,
|
|
262
|
+
};
|