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,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OS keychain access — lazy import of @napi-rs/keyring (optional dep).
|
|
3
|
+
* If keychain is unavailable, callers fall back to inline-seal.
|
|
4
|
+
*/
|
|
5
|
+
import { KEYCHAIN_SERVICE } from './ref.js';
|
|
6
|
+
let keyringModule; // undefined = not tried, null = failed
|
|
7
|
+
async function loadKeyring() {
|
|
8
|
+
if (keyringModule !== undefined)
|
|
9
|
+
return keyringModule;
|
|
10
|
+
try {
|
|
11
|
+
// Optional dep — tsc can't see it without the package installed; use a
|
|
12
|
+
// dynamic, untyped import so the build succeeds with or without it.
|
|
13
|
+
const importer = new Function('m', 'return import(m)');
|
|
14
|
+
const mod = await importer('@napi-rs/keyring');
|
|
15
|
+
keyringModule = mod;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
keyringModule = null;
|
|
19
|
+
}
|
|
20
|
+
return keyringModule;
|
|
21
|
+
}
|
|
22
|
+
/** Reset the cached module — used by tests. */
|
|
23
|
+
export function _resetKeychainCache() {
|
|
24
|
+
keyringModule = undefined;
|
|
25
|
+
}
|
|
26
|
+
export async function isKeychainAvailable() {
|
|
27
|
+
return (await loadKeyring()) !== null;
|
|
28
|
+
}
|
|
29
|
+
export async function readKeychain(account) {
|
|
30
|
+
const mod = await loadKeyring();
|
|
31
|
+
if (!mod)
|
|
32
|
+
throw new Error('OS keychain unavailable (install @napi-rs/keyring)');
|
|
33
|
+
try {
|
|
34
|
+
const entry = new mod.Entry(KEYCHAIN_SERVICE, account);
|
|
35
|
+
const value = entry.getPassword();
|
|
36
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
// keyring-rs returns NoEntry as an error — treat as missing
|
|
40
|
+
if (/NoEntry|no.such|not.found/i.test(e?.message || ''))
|
|
41
|
+
return null;
|
|
42
|
+
throw e;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function writeKeychain(account, value) {
|
|
46
|
+
const mod = await loadKeyring();
|
|
47
|
+
if (!mod)
|
|
48
|
+
throw new Error('OS keychain unavailable (install @napi-rs/keyring)');
|
|
49
|
+
const entry = new mod.Entry(KEYCHAIN_SERVICE, account);
|
|
50
|
+
entry.setPassword(value);
|
|
51
|
+
}
|
|
52
|
+
export async function deleteKeychain(account) {
|
|
53
|
+
const mod = await loadKeyring();
|
|
54
|
+
if (!mod)
|
|
55
|
+
return false;
|
|
56
|
+
try {
|
|
57
|
+
const entry = new mod.Entry(KEYCHAIN_SERVICE, account);
|
|
58
|
+
return !!entry.deletePassword();
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure utility functions shared across all layers. No filesystem side effects, no state dependencies.
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { whichSync as platformWhichSync } from './platform.js';
|
|
7
|
+
/**
|
|
8
|
+
* If `dir` has a .gitignore, ignore managed `.pikiloop` state without hiding
|
|
9
|
+
* `.pikiloop/skills`, which may be committed as project skills.
|
|
10
|
+
*/
|
|
11
|
+
export function ensureGitignore(dir) {
|
|
12
|
+
try {
|
|
13
|
+
const gi = path.join(dir, '.gitignore');
|
|
14
|
+
if (!fs.existsSync(gi))
|
|
15
|
+
return;
|
|
16
|
+
const managedLines = [
|
|
17
|
+
'.pikiloop/*',
|
|
18
|
+
'!.pikiloop/skills/',
|
|
19
|
+
'!.pikiloop/skills/**',
|
|
20
|
+
];
|
|
21
|
+
const legacyLines = new Set([
|
|
22
|
+
'.pikiloop/',
|
|
23
|
+
'.claude/skills/',
|
|
24
|
+
'.agents/skills/',
|
|
25
|
+
]);
|
|
26
|
+
const rawLines = fs.readFileSync(gi, 'utf8').split(/\r?\n/);
|
|
27
|
+
const normalized = rawLines.filter(line => {
|
|
28
|
+
const trimmed = line.trim();
|
|
29
|
+
return trimmed && !managedLines.includes(trimmed) && !legacyLines.has(trimmed);
|
|
30
|
+
});
|
|
31
|
+
const next = [...normalized, ...managedLines, ''].join('\n');
|
|
32
|
+
const current = fs.readFileSync(gi, 'utf8');
|
|
33
|
+
if (current === next)
|
|
34
|
+
return;
|
|
35
|
+
fs.writeFileSync(gi, next);
|
|
36
|
+
}
|
|
37
|
+
catch { /* best-effort */ }
|
|
38
|
+
}
|
|
39
|
+
export function envBool(name, def) {
|
|
40
|
+
const raw = process.env[name];
|
|
41
|
+
if (raw == null)
|
|
42
|
+
return def;
|
|
43
|
+
return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase());
|
|
44
|
+
}
|
|
45
|
+
export function envString(name, def) {
|
|
46
|
+
const raw = process.env[name];
|
|
47
|
+
if (raw == null)
|
|
48
|
+
return def;
|
|
49
|
+
const trimmed = raw.trim();
|
|
50
|
+
return trimmed || def;
|
|
51
|
+
}
|
|
52
|
+
export function envInt(name, def) {
|
|
53
|
+
const raw = process.env[name];
|
|
54
|
+
if (raw == null || raw.trim() === '')
|
|
55
|
+
return def;
|
|
56
|
+
const n = parseInt(raw, 10);
|
|
57
|
+
return Number.isNaN(n) ? def : n;
|
|
58
|
+
}
|
|
59
|
+
export function shellSplit(str) {
|
|
60
|
+
const args = [];
|
|
61
|
+
let cur = '', inS = false, inD = false;
|
|
62
|
+
for (const ch of str) {
|
|
63
|
+
if (ch === "'" && !inD) {
|
|
64
|
+
inS = !inS;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (ch === '"' && !inS) {
|
|
68
|
+
inD = !inD;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (ch === ' ' && !inS && !inD) {
|
|
72
|
+
if (cur) {
|
|
73
|
+
args.push(cur);
|
|
74
|
+
cur = '';
|
|
75
|
+
}
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
cur += ch;
|
|
79
|
+
}
|
|
80
|
+
if (cur)
|
|
81
|
+
args.push(cur);
|
|
82
|
+
return args;
|
|
83
|
+
}
|
|
84
|
+
export const whichSync = platformWhichSync;
|
|
85
|
+
/**
|
|
86
|
+
* Strip ANSI terminal control sequences from a string. Covers the families
|
|
87
|
+
* pikiloop runs into when scraping PTY screens (cursor positioning, SGR
|
|
88
|
+
* colour / bold / italic, OSC titles, plus orphaned ESC bytes):
|
|
89
|
+
*
|
|
90
|
+
* CSI: ESC [ ... — colours, cursor moves, line clears
|
|
91
|
+
* OSC: ESC ] ... (BEL | ESC \) — set window title, hyperlinks
|
|
92
|
+
* Other: ESC <single char> — single-char escapes (RIS, IND, …)
|
|
93
|
+
*
|
|
94
|
+
* Some IM channels strip the raw ESC byte but pass through the trailing
|
|
95
|
+
* `[3G` / `[1m` / `[38;2;…m` payload, which is how the user ends up seeing
|
|
96
|
+
* "[3G你把" in Feishu. The regex matches with-or-without the leading ESC so
|
|
97
|
+
* already-mangled output still gets cleaned. The leading-bracket fallback is
|
|
98
|
+
* conservative — it only fires for known control verbs (digits/`;` then a
|
|
99
|
+
* SGR/cursor letter), so legitimate text like "[3 second timeout]" survives.
|
|
100
|
+
*/
|
|
101
|
+
export function stripAnsiEscapes(input) {
|
|
102
|
+
if (!input)
|
|
103
|
+
return input;
|
|
104
|
+
// Drop OSC (operating system command) sequences first so their payload
|
|
105
|
+
// doesn't confuse the CSI matcher.
|
|
106
|
+
let out = input.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '');
|
|
107
|
+
// CSI with the leading ESC byte — match any final byte, even param-less
|
|
108
|
+
// (e.g. `\x1b[A` cursor-up, `\x1b[m` reset SGR). The ESC byte unambiguously
|
|
109
|
+
// signals a control sequence so we can be liberal here.
|
|
110
|
+
out = out.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
|
|
111
|
+
// CSI without the ESC byte (some IM channels strip the raw \x1b but pass
|
|
112
|
+
// through the `[3G` / `[38;2;…m` payload). Require at least one digit /
|
|
113
|
+
// semicolon in the params so legitimate text like "see [issue #42]" or
|
|
114
|
+
// "[3 second timeout]" doesn't get nibbled.
|
|
115
|
+
out = out.replace(/\[[0-9;?]+[A-Za-z]/g, '');
|
|
116
|
+
// Any remaining ESC + single byte (RIS, IND, NEL, …).
|
|
117
|
+
out = out.replace(/\x1b[@-Z\\-_]/g, '');
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
export function fmtTokens(n) {
|
|
121
|
+
if (n == null)
|
|
122
|
+
return '-';
|
|
123
|
+
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
|
124
|
+
}
|
|
125
|
+
export function fmtUptime(ms) {
|
|
126
|
+
const s = Math.floor(ms / 1000);
|
|
127
|
+
if (s < 60)
|
|
128
|
+
return `${s}s`;
|
|
129
|
+
const m = Math.floor(s / 60);
|
|
130
|
+
if (m < 60)
|
|
131
|
+
return `${m}m ${s % 60}s`;
|
|
132
|
+
const h = Math.floor(m / 60);
|
|
133
|
+
if (h < 24)
|
|
134
|
+
return `${h}h ${m % 60}m`;
|
|
135
|
+
const d = Math.floor(h / 24);
|
|
136
|
+
return `${d}d ${h % 24}h`;
|
|
137
|
+
}
|
|
138
|
+
export function fmtBytes(bytes) {
|
|
139
|
+
if (bytes < 1024)
|
|
140
|
+
return `${bytes}B`;
|
|
141
|
+
if (bytes < 1024 * 1024)
|
|
142
|
+
return `${(bytes / 1024).toFixed(0)}KB`;
|
|
143
|
+
if (bytes < 1024 * 1024 * 1024)
|
|
144
|
+
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
|
145
|
+
if (bytes < 1024 * 1024 * 1024 * 1024)
|
|
146
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}GB`;
|
|
147
|
+
return `${(bytes / 1024 / 1024 / 1024 / 1024).toFixed(1)}TB`;
|
|
148
|
+
}
|
|
149
|
+
export function parseAllowedChatIds(raw) {
|
|
150
|
+
const ids = new Set();
|
|
151
|
+
for (const t of raw.split(',')) {
|
|
152
|
+
const v = t.trim();
|
|
153
|
+
if (!v)
|
|
154
|
+
continue;
|
|
155
|
+
const n = parseInt(v, 10);
|
|
156
|
+
// If the string is purely numeric, store as number for backward compat (Telegram).
|
|
157
|
+
// Otherwise store as string (Feishu, Discord, etc.).
|
|
158
|
+
if (!Number.isNaN(n) && String(n) === v)
|
|
159
|
+
ids.add(n);
|
|
160
|
+
else if (v)
|
|
161
|
+
ids.add(v);
|
|
162
|
+
}
|
|
163
|
+
return ids;
|
|
164
|
+
}
|
|
165
|
+
export function listSubdirs(dirPath) {
|
|
166
|
+
try {
|
|
167
|
+
return fs.readdirSync(dirPath)
|
|
168
|
+
.filter(name => {
|
|
169
|
+
if (name.startsWith('.'))
|
|
170
|
+
return false;
|
|
171
|
+
try {
|
|
172
|
+
return fs.statSync(path.join(dirPath, name)).isDirectory();
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
export function extractThinkingTail(text, maxLines = 10) {
|
|
185
|
+
const normalized = text.replace(/\r\n?/g, '\n').trim();
|
|
186
|
+
if (!normalized)
|
|
187
|
+
return '';
|
|
188
|
+
const lines = normalized
|
|
189
|
+
.split('\n')
|
|
190
|
+
.map(line => line.trimEnd())
|
|
191
|
+
.filter(line => line.trim());
|
|
192
|
+
if (lines.length > maxLines)
|
|
193
|
+
return lines.slice(-maxLines).join('\n').trim();
|
|
194
|
+
return normalized;
|
|
195
|
+
}
|
|
196
|
+
export function formatThinkingForDisplay(text, maxChars = 1600) {
|
|
197
|
+
let display = extractThinkingTail(text);
|
|
198
|
+
if (display.length > maxChars)
|
|
199
|
+
display = '...\n' + display.slice(-maxChars);
|
|
200
|
+
return display;
|
|
201
|
+
}
|
|
202
|
+
export function buildPrompt(text, files) {
|
|
203
|
+
if (!files.length)
|
|
204
|
+
return text;
|
|
205
|
+
return `${text || 'Please analyze this.'}\n\n[Files: ${files.map(f => path.basename(f)).join(', ')}]`;
|
|
206
|
+
}
|
|
207
|
+
/** Race a promise against a timeout, resolving with `fallback` on timeout or rejection. */
|
|
208
|
+
export function withTimeoutFallback(promise, timeoutMs, fallback) {
|
|
209
|
+
return new Promise(resolve => {
|
|
210
|
+
let settled = false;
|
|
211
|
+
const timer = setTimeout(() => {
|
|
212
|
+
if (settled)
|
|
213
|
+
return;
|
|
214
|
+
settled = true;
|
|
215
|
+
resolve(fallback);
|
|
216
|
+
}, timeoutMs);
|
|
217
|
+
promise
|
|
218
|
+
.then(result => {
|
|
219
|
+
if (settled)
|
|
220
|
+
return;
|
|
221
|
+
settled = true;
|
|
222
|
+
clearTimeout(timer);
|
|
223
|
+
resolve(result);
|
|
224
|
+
})
|
|
225
|
+
.catch(() => {
|
|
226
|
+
if (settled)
|
|
227
|
+
return;
|
|
228
|
+
settled = true;
|
|
229
|
+
clearTimeout(timer);
|
|
230
|
+
resolve(fallback);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the package version from package.json at startup.
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
const packageJsonPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../package.json');
|
|
8
|
+
function readPackageVersion() {
|
|
9
|
+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
10
|
+
const version = typeof pkg.version === 'string' ? pkg.version.trim() : '';
|
|
11
|
+
if (!version)
|
|
12
|
+
throw new Error(`Missing version in ${packageJsonPath}`);
|
|
13
|
+
return version;
|
|
14
|
+
}
|
|
15
|
+
export const VERSION = readPackageVersion();
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform detection helpers.
|
|
3
|
+
*
|
|
4
|
+
* macOS permission checks, terminal detection, JXA scripts, and other OS-level utilities.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import { execFileSync, execSync } from 'node:child_process';
|
|
10
|
+
import { DASHBOARD_PERMISSION_TIMEOUTS, DASHBOARD_PERMISSION_CACHE_TTL_MS, } from '../core/constants.js';
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Permission pane URLs (macOS)
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
const permissionPaneUrls = {
|
|
15
|
+
screenRecording: 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture',
|
|
16
|
+
fullDiskAccess: 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles',
|
|
17
|
+
};
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// JXA helpers
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
function runJxa(script, timeout = DASHBOARD_PERMISSION_TIMEOUTS.jxaDefault) {
|
|
22
|
+
try {
|
|
23
|
+
return String(execFileSync('osascript', ['-l', 'JavaScript', '-e', script], { encoding: 'utf8', timeout })).trim().toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function checkScreenRecordingPermission() {
|
|
30
|
+
const screenshotPath = path.join(os.tmpdir(), `.pikiloop_perm_test_${process.pid}_${Date.now()}.png`);
|
|
31
|
+
try {
|
|
32
|
+
execFileSync('screencapture', ['-x', screenshotPath], { stdio: 'ignore', timeout: DASHBOARD_PERMISSION_TIMEOUTS.screenRecordingProbe });
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch { }
|
|
36
|
+
finally {
|
|
37
|
+
try {
|
|
38
|
+
fs.rmSync(screenshotPath, { force: true });
|
|
39
|
+
}
|
|
40
|
+
catch { }
|
|
41
|
+
}
|
|
42
|
+
const output = runJxa('ObjC.bindFunction("CGPreflightScreenCaptureAccess", ["bool", []]); console.log($.CGPreflightScreenCaptureAccess());', DASHBOARD_PERMISSION_TIMEOUTS.screenRecordingPreflight);
|
|
43
|
+
if (output == null)
|
|
44
|
+
return null;
|
|
45
|
+
return output === 'true';
|
|
46
|
+
}
|
|
47
|
+
function requestScreenRecordingPermission() {
|
|
48
|
+
return runJxa('ObjC.bindFunction("CGRequestScreenCaptureAccess", ["bool", []]); console.log($.CGRequestScreenCaptureAccess());', DASHBOARD_PERMISSION_TIMEOUTS.screenRecordingRequest) !== null;
|
|
49
|
+
}
|
|
50
|
+
function openPermissionSettings(permission) {
|
|
51
|
+
const pane = permissionPaneUrls[permission];
|
|
52
|
+
if (!pane)
|
|
53
|
+
return false;
|
|
54
|
+
try {
|
|
55
|
+
execFileSync('open', [pane], { stdio: 'ignore', timeout: DASHBOARD_PERMISSION_TIMEOUTS.openSystemPreferences });
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Permission checks
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
export function checkPermissions() {
|
|
66
|
+
const r = {};
|
|
67
|
+
if (process.platform !== 'darwin') {
|
|
68
|
+
r.screenRecording = { granted: true, checkable: false, detail: 'N/A' };
|
|
69
|
+
r.fullDiskAccess = { granted: true, checkable: false, detail: 'N/A' };
|
|
70
|
+
return r;
|
|
71
|
+
}
|
|
72
|
+
const screenRecordingGranted = checkScreenRecordingPermission();
|
|
73
|
+
r.screenRecording = {
|
|
74
|
+
granted: screenRecordingGranted === true,
|
|
75
|
+
checkable: true,
|
|
76
|
+
detail: screenRecordingGranted === true ? '已授权' : '未授权',
|
|
77
|
+
};
|
|
78
|
+
try {
|
|
79
|
+
execSync(`ls "${os.homedir()}/Library/Mail" 2>/dev/null`, { timeout: 3000 });
|
|
80
|
+
r.fullDiskAccess = { granted: true, checkable: true, detail: '已授权' };
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
r.fullDiskAccess = { granted: false, checkable: true, detail: '未授权' };
|
|
84
|
+
}
|
|
85
|
+
return r;
|
|
86
|
+
}
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Cached probes for the polling dashboard
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// `/api/state` is polled (~1.5s while a channel validates) and both probes below
|
|
91
|
+
// spawn subprocesses — checkPermissions() runs screencapture + an `ls` shell,
|
|
92
|
+
// detectHostTerminalApp() runs a `ps` process-tree walk — so they must never run
|
|
93
|
+
// per request. The host terminal is fixed for the process lifetime; permission
|
|
94
|
+
// grants change rarely, so a short TTL is plenty and requestPermission()
|
|
95
|
+
// invalidates the cache so a user-driven grant surfaces on the next poll.
|
|
96
|
+
let permissionsCache = null;
|
|
97
|
+
let hostTerminalAppCache = null;
|
|
98
|
+
export function getPermissionsStatus() {
|
|
99
|
+
if (permissionsCache && Date.now() - permissionsCache.at < DASHBOARD_PERMISSION_CACHE_TTL_MS) {
|
|
100
|
+
return permissionsCache.value;
|
|
101
|
+
}
|
|
102
|
+
const value = checkPermissions();
|
|
103
|
+
permissionsCache = { at: Date.now(), value };
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
export function getHostTerminalApp() {
|
|
107
|
+
if (!hostTerminalAppCache)
|
|
108
|
+
hostTerminalAppCache = { value: detectHostTerminalApp() };
|
|
109
|
+
return hostTerminalAppCache.value;
|
|
110
|
+
}
|
|
111
|
+
export function requestPermission(permission) {
|
|
112
|
+
permissionsCache = null; // a request can change grant state — force the next poll to re-probe
|
|
113
|
+
if (process.platform !== 'darwin') {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
action: 'unsupported',
|
|
117
|
+
granted: true,
|
|
118
|
+
requiresManualGrant: false,
|
|
119
|
+
error: 'Permission requests are only supported on macOS.',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const current = checkPermissions()[permission];
|
|
123
|
+
if (current?.granted) {
|
|
124
|
+
return {
|
|
125
|
+
ok: true,
|
|
126
|
+
action: 'already_granted',
|
|
127
|
+
granted: true,
|
|
128
|
+
requiresManualGrant: false,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (permission === 'screenRecording') {
|
|
132
|
+
const prompted = requestScreenRecordingPermission();
|
|
133
|
+
if (!prompted) {
|
|
134
|
+
const openedSettings = openPermissionSettings(permission);
|
|
135
|
+
return openedSettings
|
|
136
|
+
? { ok: true, action: 'opened_settings', granted: false, requiresManualGrant: true }
|
|
137
|
+
: { ok: false, action: 'unsupported', granted: false, requiresManualGrant: true, error: 'Failed to trigger Screen Recording permission request.' };
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
ok: true,
|
|
141
|
+
action: 'prompted',
|
|
142
|
+
granted: !!checkPermissions().screenRecording?.granted,
|
|
143
|
+
requiresManualGrant: true,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (permission === 'fullDiskAccess') {
|
|
147
|
+
const openedSettings = openPermissionSettings(permission);
|
|
148
|
+
return openedSettings
|
|
149
|
+
? { ok: true, action: 'opened_settings', granted: false, requiresManualGrant: true }
|
|
150
|
+
: { ok: false, action: 'unsupported', granted: false, requiresManualGrant: true, error: 'Failed to open Full Disk Access settings.' };
|
|
151
|
+
}
|
|
152
|
+
return { ok: false, action: 'unsupported', granted: false, requiresManualGrant: true, error: 'Unknown permission.' };
|
|
153
|
+
}
|
|
154
|
+
export function isValidPermissionKey(value) {
|
|
155
|
+
return value in permissionPaneUrls;
|
|
156
|
+
}
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Terminal detection
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
/** Walk the process tree upward to find the host terminal / IDE that launched pikiloop. Works on macOS and Linux. */
|
|
161
|
+
export function detectHostTerminalApp() {
|
|
162
|
+
if (process.platform !== 'darwin' && process.platform !== 'linux')
|
|
163
|
+
return null;
|
|
164
|
+
try {
|
|
165
|
+
// Patterns to match in the comm/exe name (case-insensitive on Linux where names vary)
|
|
166
|
+
// macOS: Terminal, iTerm2, Warp; Linux: gnome-terminal, konsole, xfce4-terminal, xterm, tilix, foot, sakura, terminology
|
|
167
|
+
// Cross-platform: Alacritty, kitty, WezTerm, Hyper, VS Code, Cursor, Windsurf
|
|
168
|
+
const patterns = [
|
|
169
|
+
'Terminal', 'iTerm', 'Warp',
|
|
170
|
+
'Alacritty', 'alacritty', 'kitty', 'WezTerm', 'wezterm', 'Hyper',
|
|
171
|
+
'Code', 'Cursor', 'Windsurf',
|
|
172
|
+
'konsole', 'xfce4-terminal', 'xterm', 'tilix', 'foot', 'sakura', 'terminology', 'tmux', 'screen',
|
|
173
|
+
];
|
|
174
|
+
const caseList = patterns.map(p => `*${p}*`).join('|');
|
|
175
|
+
const output = execSync(`pid=${process.pid} ; while [ "$pid" != "1" ] && [ -n "$pid" ]; do pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' '); comm=$(ps -o comm= -p "$pid" 2>/dev/null); case "$comm" in ${caseList}) echo "$comm"; exit 0;; esac; done`, { encoding: 'utf8', timeout: DASHBOARD_PERMISSION_TIMEOUTS.detectTerminal, shell: '/bin/sh' }).trim();
|
|
176
|
+
if (!output)
|
|
177
|
+
return null;
|
|
178
|
+
const base = path.basename(output);
|
|
179
|
+
// Map comm name → human-readable display name
|
|
180
|
+
const nameMap = [
|
|
181
|
+
// macOS
|
|
182
|
+
['iTerm', 'iTerm2'],
|
|
183
|
+
['Code Helper', 'VS Code'],
|
|
184
|
+
['Cursor Helper', 'Cursor'],
|
|
185
|
+
['Windsurf Helper', 'Windsurf'],
|
|
186
|
+
// Cross-platform IDE wrappers (Linux uses "code" binary directly)
|
|
187
|
+
['code', 'VS Code'],
|
|
188
|
+
['cursor', 'Cursor'],
|
|
189
|
+
['windsurf', 'Windsurf'],
|
|
190
|
+
// Terminal emulators
|
|
191
|
+
['gnome-terminal', 'GNOME Terminal'],
|
|
192
|
+
['xfce4-terminal', 'Xfce Terminal'],
|
|
193
|
+
['Terminal', 'Terminal'],
|
|
194
|
+
['Warp', 'Warp'],
|
|
195
|
+
['Alacritty', 'Alacritty'],
|
|
196
|
+
['alacritty', 'Alacritty'],
|
|
197
|
+
['kitty', 'kitty'],
|
|
198
|
+
['WezTerm', 'WezTerm'],
|
|
199
|
+
['wezterm', 'WezTerm'],
|
|
200
|
+
['Hyper', 'Hyper'],
|
|
201
|
+
['konsole', 'Konsole'],
|
|
202
|
+
['xterm', 'xterm'],
|
|
203
|
+
['tilix', 'Tilix'],
|
|
204
|
+
['foot', 'foot'],
|
|
205
|
+
['sakura', 'Sakura'],
|
|
206
|
+
['terminology', 'Terminology'],
|
|
207
|
+
['tmux', 'tmux'],
|
|
208
|
+
['screen', 'screen'],
|
|
209
|
+
];
|
|
210
|
+
for (const [key, name] of nameMap) {
|
|
211
|
+
if (base.includes(key))
|
|
212
|
+
return name;
|
|
213
|
+
}
|
|
214
|
+
return base;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|