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,101 @@
1
+ /**
2
+ * Structured logging with scoped writers and file retention.
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ const LOG_LEVEL_WEIGHT = {
7
+ debug: 10,
8
+ info: 20,
9
+ warn: 30,
10
+ error: 40,
11
+ };
12
+ const DEFAULT_LOG_LEVEL = 'info';
13
+ const DEFAULT_LOG_MAX_LINES = 5000;
14
+ const DEFAULT_LOG_MAX_AGE_MS = 24 * 60 * 60 * 1000;
15
+ const DEFAULT_LOG_TRIM_EVERY_WRITES = 200;
16
+ function positiveIntEnv(name, fallback) {
17
+ const raw = String(process.env[name] || '').trim();
18
+ if (!raw)
19
+ return fallback;
20
+ const value = Number.parseInt(raw, 10);
21
+ return Number.isFinite(value) && value > 0 ? value : fallback;
22
+ }
23
+ function resolveRetention(options = {}) {
24
+ return {
25
+ maxLines: options.maxLines ?? positiveIntEnv('PIKILOOP_LOG_MAX_LINES', DEFAULT_LOG_MAX_LINES),
26
+ maxAgeMs: options.maxAgeMs ?? positiveIntEnv('PIKILOOP_LOG_MAX_AGE_MS', DEFAULT_LOG_MAX_AGE_MS),
27
+ trimEveryWrites: options.trimEveryWrites ?? positiveIntEnv('PIKILOOP_LOG_TRIM_EVERY_WRITES', DEFAULT_LOG_TRIM_EVERY_WRITES),
28
+ };
29
+ }
30
+ export function normalizeLogLevel(value, fallback = DEFAULT_LOG_LEVEL) {
31
+ const normalized = String(value || '').trim().toLowerCase();
32
+ if (normalized === 'debug' || normalized === 'info' || normalized === 'warn' || normalized === 'error') {
33
+ return normalized;
34
+ }
35
+ return fallback;
36
+ }
37
+ export function getConfiguredLogLevel() {
38
+ return normalizeLogLevel(process.env.PIKILOOP_LOG_LEVEL, DEFAULT_LOG_LEVEL);
39
+ }
40
+ export function shouldLog(level, configuredLevel = getConfiguredLogLevel()) {
41
+ return LOG_LEVEL_WEIGHT[level] >= LOG_LEVEL_WEIGHT[configuredLevel];
42
+ }
43
+ export function formatScopedLogLine(scope, message, now = new Date()) {
44
+ const ts = now.toTimeString().slice(0, 8);
45
+ return `[${scope} ${ts}] ${message}\n`;
46
+ }
47
+ export function writeScopedLog(scope, message, options = {}) {
48
+ const level = options.level ?? 'info';
49
+ if (!shouldLog(level))
50
+ return false;
51
+ const line = formatScopedLogLine(scope, message, options.now);
52
+ if (options.stream === 'stderr')
53
+ process.stderr.write(line);
54
+ else
55
+ process.stdout.write(line);
56
+ return true;
57
+ }
58
+ function trimRetainedLogContent(content, maxLines) {
59
+ if (!content)
60
+ return content;
61
+ const normalized = content.replace(/\r\n/g, '\n');
62
+ const hasTrailingNewline = normalized.endsWith('\n');
63
+ const lines = normalized.split('\n');
64
+ if (hasTrailingNewline)
65
+ lines.pop();
66
+ if (lines.length <= maxLines)
67
+ return content;
68
+ return `${lines.slice(-maxLines).join('\n')}\n`;
69
+ }
70
+ export function pruneRetainedLogFile(filePath, options = {}) {
71
+ const { maxLines, maxAgeMs } = resolveRetention(options);
72
+ try {
73
+ const stat = fs.statSync(filePath);
74
+ if (Date.now() - stat.mtimeMs > maxAgeMs) {
75
+ fs.writeFileSync(filePath, '');
76
+ return;
77
+ }
78
+ const content = fs.readFileSync(filePath, 'utf8');
79
+ const trimmed = trimRetainedLogContent(content, maxLines);
80
+ if (trimmed !== content)
81
+ fs.writeFileSync(filePath, trimmed);
82
+ }
83
+ catch { }
84
+ }
85
+ export function createRetainedLogSink(filePath, options = {}) {
86
+ const { trimEveryWrites } = resolveRetention(options);
87
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
88
+ pruneRetainedLogFile(filePath, options);
89
+ let writes = 0;
90
+ return (chunk) => {
91
+ if (!chunk)
92
+ return;
93
+ try {
94
+ fs.appendFileSync(filePath, chunk);
95
+ writes++;
96
+ if (writes === 1 || writes % trimEveryWrites === 0)
97
+ pruneRetainedLogFile(filePath, options);
98
+ }
99
+ catch { }
100
+ };
101
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Cross-platform primitives. All OS-dependent behavior must route through here
3
+ * so the rest of the codebase stays platform-neutral.
4
+ */
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import which from 'which';
8
+ export const IS_WIN = process.platform === 'win32';
9
+ export const IS_MAC = process.platform === 'darwin';
10
+ export const IS_LINUX = process.platform === 'linux';
11
+ /**
12
+ * User home directory. Re-reads each call so runtime `$HOME`/`$USERPROFILE`
13
+ * overrides (and tests that mutate them) stay honored. Works on Windows
14
+ * where `$HOME` is not set by default — `os.homedir()` falls back to
15
+ * `$USERPROFILE`.
16
+ */
17
+ export function getHome() {
18
+ return os.homedir();
19
+ }
20
+ /** Expand a leading `~` (or `~/`, `~\`) to the user's home directory. */
21
+ export function expandTilde(p) {
22
+ if (!p || p[0] !== '~')
23
+ return p;
24
+ const home = getHome();
25
+ if (p === '~')
26
+ return home;
27
+ if (p.startsWith('~/') || (IS_WIN && p.startsWith('~\\'))) {
28
+ return path.join(home, p.slice(2));
29
+ }
30
+ return p;
31
+ }
32
+ /** Locate an executable on PATH, honoring PATHEXT on Windows. */
33
+ export function whichSync(cmd) {
34
+ return which.sync(cmd, { nothrow: true }) || null;
35
+ }
36
+ /**
37
+ * Encode an absolute workdir path as a single directory-name segment.
38
+ * Mirrors Claude Code's scheme under `~/.claude/projects/`: every non
39
+ * alphanumeric character collapses to `-`. Critically that includes
40
+ * underscores and dots (e.g. `/path/to/harness_ppt` → `-path-to-harness-ppt`),
41
+ * which matches the encoding Claude Code uses on disk. Replacing only path
42
+ * separators leaves a workdir whose name contains `_` (or `.`) pointing at
43
+ * a directory that does not exist, so session JSONL lookups silently fall
44
+ * back to an empty/truncated result.
45
+ */
46
+ export function encodePathAsDirName(p) {
47
+ return p.replace(/[^a-zA-Z0-9]/g, '-');
48
+ }
49
+ /**
50
+ * Match a path segment regardless of separator. Useful for probing whether a
51
+ * resolved script path runs under a given binary (e.g. `tsx`, `ts-node`)
52
+ * without hardcoding `/` — which fails on Windows.
53
+ */
54
+ export function pathContainsSegment(p, segment) {
55
+ const escaped = segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
56
+ return new RegExp(`[\\\\/]${escaped}([\\\\/]|$)`).test(p);
57
+ }
58
+ /** Null-redirect suffix for shell commands. */
59
+ export const DEV_NULL_REDIRECT = IS_WIN ? '2>nul' : '2>/dev/null';
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Process lifecycle: restart coordination, watchdog, and process tree termination.
3
+ */
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { spawn } from 'node:child_process';
8
+ import { pathContainsSegment } from './platform.js';
9
+ import { STATE_DIR_NAME } from './constants.js';
10
+ export const PROCESS_RESTART_EXIT_CODE = 75;
11
+ export const PROCESS_RESTART_STATE_FILE_ENV = 'PIKILOOP_RESTART_STATE_FILE';
12
+ const DAEMON_PID_FILENAME = 'pikiloop.pid';
13
+ /** Path to the daemon PID file used by `pikiloop stop`. */
14
+ export function getDaemonPidFilePath() {
15
+ return path.join(os.homedir(), STATE_DIR_NAME, DAEMON_PID_FILENAME);
16
+ }
17
+ export function writeDaemonPidFile(pid = process.pid) {
18
+ const filePath = getDaemonPidFilePath();
19
+ try {
20
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
21
+ fs.writeFileSync(filePath, String(pid), 'utf8');
22
+ }
23
+ catch { }
24
+ }
25
+ export function clearDaemonPidFile() {
26
+ try {
27
+ fs.unlinkSync(getDaemonPidFilePath());
28
+ }
29
+ catch { }
30
+ }
31
+ export function readDaemonPidFile() {
32
+ try {
33
+ const raw = fs.readFileSync(getDaemonPidFilePath(), 'utf8').trim();
34
+ const pid = Number.parseInt(raw, 10);
35
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ /** True if a process with `pid` is currently running (POSIX kill -0 / win32 tasklist). */
42
+ export function isProcessAlive(pid) {
43
+ if (!pid || pid <= 0)
44
+ return false;
45
+ try {
46
+ process.kill(pid, 0);
47
+ return true;
48
+ }
49
+ catch (err) {
50
+ const code = err?.code;
51
+ // EPERM means the process exists but we cannot signal it — still alive.
52
+ return code === 'EPERM';
53
+ }
54
+ }
55
+ const runtimes = new Map();
56
+ let nextRuntimeId = 1;
57
+ let restartInFlight = false;
58
+ export function shellSplit(str) {
59
+ const args = [];
60
+ let cur = '';
61
+ let inSingle = false;
62
+ let inDouble = false;
63
+ for (const ch of str) {
64
+ if (ch === '\'' && !inDouble) {
65
+ inSingle = !inSingle;
66
+ continue;
67
+ }
68
+ if (ch === '"' && !inSingle) {
69
+ inDouble = !inDouble;
70
+ continue;
71
+ }
72
+ if (ch === ' ' && !inSingle && !inDouble) {
73
+ if (cur)
74
+ args.push(cur);
75
+ cur = '';
76
+ continue;
77
+ }
78
+ cur += ch;
79
+ }
80
+ if (cur)
81
+ args.push(cur);
82
+ return args;
83
+ }
84
+ function isNpxBinary(bin) {
85
+ return path.basename(bin, path.extname(bin)).toLowerCase() === 'npx';
86
+ }
87
+ export function ensureNonInteractiveRestartArgs(bin, args) {
88
+ if (!isNpxBinary(bin))
89
+ return args;
90
+ if (args.includes('--yes') || args.includes('-y'))
91
+ return args;
92
+ return ['--yes', ...args];
93
+ }
94
+ export function getDefaultRestartCmd() {
95
+ const argv0 = process.argv[0] ?? '';
96
+ const argv1 = process.argv[1] ?? '';
97
+ if (argv1.endsWith('.ts') || pathContainsSegment(argv1, 'tsx') || pathContainsSegment(argv1, 'ts-node')) {
98
+ const isTsxLoader = !pathContainsSegment(argv0, 'tsx')
99
+ && process.execArgv?.some(arg => arg.includes('tsx'));
100
+ const parts = isTsxLoader ? ['tsx', argv1] : process.argv.slice(0, 2);
101
+ return parts.map(arg => arg.includes(' ') ? `"${arg}"` : arg).join(' ');
102
+ }
103
+ // Running from an installed package (e.g. npm install -g) — reuse the same entry point
104
+ if (argv1.endsWith('.js') && (argv1.includes('pikiloop') || argv1.includes('pikiloop'))) {
105
+ const nodeBin = argv0.includes(' ') ? `"${argv0}"` : argv0;
106
+ const entry = argv1.includes(' ') ? `"${argv1}"` : argv1;
107
+ return `${nodeBin} ${entry}`;
108
+ }
109
+ return 'npx --yes pikiloop@latest';
110
+ }
111
+ export function buildRestartCommand(argv, restartCmd = process.env.PIKILOOP_RESTART_CMD || getDefaultRestartCmd()) {
112
+ const [bin, ...rawArgs] = shellSplit(restartCmd);
113
+ return {
114
+ bin,
115
+ args: [...ensureNonInteractiveRestartArgs(bin, rawArgs), ...argv],
116
+ };
117
+ }
118
+ export function registerProcessRuntime(runtime) {
119
+ const id = nextRuntimeId++;
120
+ runtimes.set(id, runtime);
121
+ return () => {
122
+ runtimes.delete(id);
123
+ };
124
+ }
125
+ export function getRegisteredRuntimeCount() {
126
+ return runtimes.size;
127
+ }
128
+ export function getActiveTaskCount() {
129
+ let total = 0;
130
+ for (const runtime of runtimes.values()) {
131
+ total += Math.max(0, runtime.getActiveTaskCount?.() || 0);
132
+ }
133
+ return total;
134
+ }
135
+ export function createRestartStateFilePath(ownerPid = process.pid) {
136
+ const dir = path.join(os.tmpdir(), 'pikiloop');
137
+ fs.mkdirSync(dir, { recursive: true });
138
+ return path.join(dir, `restart-${ownerPid}.json`);
139
+ }
140
+ export function clearRestartStateFile(filePath) {
141
+ if (!filePath)
142
+ return;
143
+ try {
144
+ fs.unlinkSync(filePath);
145
+ }
146
+ catch { }
147
+ }
148
+ export function writeRestartStateFile(filePath, env) {
149
+ const payload = { version: 1, env };
150
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
151
+ fs.writeFileSync(filePath, JSON.stringify(payload), 'utf8');
152
+ }
153
+ export function consumeRestartStateFile(filePath) {
154
+ if (!filePath)
155
+ return {};
156
+ try {
157
+ const raw = fs.readFileSync(filePath, 'utf8');
158
+ const parsed = JSON.parse(raw);
159
+ if (parsed?.version !== 1 || !parsed.env || typeof parsed.env !== 'object')
160
+ return {};
161
+ return Object.fromEntries(Object.entries(parsed.env)
162
+ .filter((entry) => typeof entry[0] === 'string' && typeof entry[1] === 'string')
163
+ .map(([key, value]) => [key, value.trim()]));
164
+ }
165
+ catch {
166
+ return {};
167
+ }
168
+ finally {
169
+ clearRestartStateFile(filePath);
170
+ }
171
+ }
172
+ function mergeEnvValues(target, patch) {
173
+ for (const [key, rawValue] of Object.entries(patch)) {
174
+ const value = rawValue.trim();
175
+ if (!value)
176
+ continue;
177
+ if (!target[key]) {
178
+ target[key] = value;
179
+ continue;
180
+ }
181
+ const merged = new Set([
182
+ ...target[key].split(',').map(item => item.trim()).filter(Boolean),
183
+ ...value.split(',').map(item => item.trim()).filter(Boolean),
184
+ ]);
185
+ target[key] = [...merged].join(',');
186
+ }
187
+ }
188
+ function collectRestartEnv() {
189
+ const env = {};
190
+ for (const runtime of runtimes.values()) {
191
+ const patch = runtime.buildRestartEnv?.() || {};
192
+ mergeEnvValues(env, patch);
193
+ }
194
+ return env;
195
+ }
196
+ async function prepareRuntimesForRestart(log) {
197
+ for (const runtime of [...runtimes.values()]) {
198
+ const label = runtime.label ? `${runtime.label}: ` : '';
199
+ try {
200
+ await runtime.prepareForRestart?.();
201
+ }
202
+ catch (err) {
203
+ const message = err instanceof Error ? err.message : String(err);
204
+ log?.(`restart cleanup failed (${label}${message})`);
205
+ }
206
+ }
207
+ }
208
+ function buildRestartEnvForSpawn(extraEnv) {
209
+ const env = {
210
+ ...process.env,
211
+ ...extraEnv,
212
+ npm_config_yes: process.env.npm_config_yes || 'true',
213
+ };
214
+ delete env.PIKILOOP_DAEMON_CHILD;
215
+ delete env[PROCESS_RESTART_STATE_FILE_ENV];
216
+ return env;
217
+ }
218
+ function spawnReplacementProcess(bin, args, env, log) {
219
+ // npx/npx.cmd needs shell resolution; node.exe does not
220
+ const needsShell = process.platform === 'win32' && !bin.endsWith('node.exe');
221
+ const child = spawn(needsShell ? `"${bin}"` : bin, args, {
222
+ stdio: 'inherit',
223
+ detached: true,
224
+ shell: needsShell || undefined,
225
+ env,
226
+ cwd: process.cwd(),
227
+ });
228
+ child.unref();
229
+ log?.(`restart: new process spawned (PID ${child.pid})`);
230
+ return child;
231
+ }
232
+ export async function requestProcessRestart(opts = {}) {
233
+ if (restartInFlight) {
234
+ return {
235
+ ok: true,
236
+ restarting: true,
237
+ error: null,
238
+ activeTasks: 0,
239
+ };
240
+ }
241
+ const activeTasks = getActiveTaskCount();
242
+ if (activeTasks > 0) {
243
+ return {
244
+ ok: false,
245
+ restarting: false,
246
+ error: `${activeTasks} task(s) still running. Wait for them to finish or try again.`,
247
+ activeTasks,
248
+ };
249
+ }
250
+ restartInFlight = true;
251
+ const log = opts.log;
252
+ const exit = opts.exit || process.exit;
253
+ try {
254
+ const extraEnv = collectRestartEnv();
255
+ await prepareRuntimesForRestart(log);
256
+ if (process.env.PIKILOOP_DAEMON_CHILD === '1') {
257
+ const restartStateFile = process.env[PROCESS_RESTART_STATE_FILE_ENV];
258
+ if (restartStateFile) {
259
+ if (Object.keys(extraEnv).length)
260
+ writeRestartStateFile(restartStateFile, extraEnv);
261
+ else
262
+ clearRestartStateFile(restartStateFile);
263
+ }
264
+ log?.('restart: handing off to daemon supervisor');
265
+ exit(PROCESS_RESTART_EXIT_CODE);
266
+ return { ok: true, restarting: true, error: null, activeTasks: 0 };
267
+ }
268
+ const { bin, args } = buildRestartCommand(opts.argv || process.argv.slice(2), opts.restartCmd);
269
+ log?.(`restart: spawning \`${bin} ${args.join(' ')}\``);
270
+ spawnReplacementProcess(bin, args, buildRestartEnvForSpawn(extraEnv), log);
271
+ exit(0);
272
+ return { ok: true, restarting: true, error: null, activeTasks: 0 };
273
+ }
274
+ catch (err) {
275
+ restartInFlight = false;
276
+ return {
277
+ ok: false,
278
+ restarting: false,
279
+ error: err instanceof Error ? err.message : String(err),
280
+ activeTasks: 0,
281
+ };
282
+ }
283
+ }
284
+ export function terminateProcessTree(target, opts = {}) {
285
+ const pid = typeof target === 'number' ? target : target?.pid;
286
+ if (!pid || pid <= 0)
287
+ return;
288
+ const signal = opts.signal ?? 'SIGTERM';
289
+ const forceSignal = opts.forceSignal ?? null;
290
+ const forceAfterMs = opts.forceAfterMs ?? 0;
291
+ const killPid = (targetPid, nextSignal) => {
292
+ try {
293
+ if (process.platform === 'win32') {
294
+ const args = ['/pid', String(targetPid), '/t'];
295
+ if (nextSignal === 'SIGKILL')
296
+ args.push('/f');
297
+ const killer = spawn('taskkill', args, { stdio: 'ignore', windowsHide: true });
298
+ killer.unref();
299
+ return;
300
+ }
301
+ process.kill(-targetPid, nextSignal);
302
+ }
303
+ catch {
304
+ try {
305
+ process.kill(targetPid, nextSignal);
306
+ }
307
+ catch { }
308
+ }
309
+ };
310
+ killPid(pid, signal);
311
+ if (forceSignal == null || forceAfterMs <= 0 || forceSignal === signal)
312
+ return;
313
+ const timer = setTimeout(() => killPid(pid, forceSignal), forceAfterMs);
314
+ timer.unref?.();
315
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Barrel for the pikiloop credential vault.
3
+ *
4
+ * Pikiloop owns its own credential layer rather than delegating to per-agent
5
+ * config files: when an agent is spawned, the active Profile's credentials
6
+ * are resolved on the fly and injected as env vars / generated config files.
7
+ * This means setting.json never holds a raw secret in the default flow, and
8
+ * adding a new agent (Hermes, OpenCode, …) does not duplicate credential UX.
9
+ */
10
+ export { KEYCHAIN_SERVICE, isCredentialRef, describeCredentialRef } from './ref.js';
11
+ export { resolveCredential, tryResolveCredential } from './resolver.js';
12
+ export { isKeychainAvailable, readKeychain, writeKeychain, deleteKeychain, } from './store.js';
13
+ export { sealInline, unsealInline } from './inline-seal.js';
14
+ import { writeKeychain, deleteKeychain, isKeychainAvailable } from './store.js';
15
+ import { sealInline } from './inline-seal.js';
16
+ /**
17
+ * Convenience: store a freshly-pasted secret using the safest available
18
+ * backend. Returns the CredentialRef the caller should persist alongside
19
+ * the Provider record.
20
+ */
21
+ export async function persistSecret(account, plaintext) {
22
+ if (await isKeychainAvailable()) {
23
+ try {
24
+ await writeKeychain(account, plaintext);
25
+ return { source: 'keychain', account };
26
+ }
27
+ catch {
28
+ // fall through to inline seal
29
+ }
30
+ }
31
+ return { source: 'inline', sealed: sealInline(plaintext) };
32
+ }
33
+ /** Best-effort cleanup when a Provider is deleted. */
34
+ export async function forgetSecret(ref) {
35
+ if (ref.source === 'keychain') {
36
+ try {
37
+ await deleteKeychain(ref.account);
38
+ }
39
+ catch { }
40
+ }
41
+ // env / command / inline references have nothing to clean
42
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Machine-bound AES-256-GCM seal for credential values when no OS keychain
3
+ * is available. The derived key mixes hostname + a per-install random salt
4
+ * stored alongside ~/.pikiloop/setting.json, so a sealed blob copied to a
5
+ * different machine will not decrypt.
6
+ */
7
+ import crypto from 'node:crypto';
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { STATE_DIR_NAME } from '../constants.js';
12
+ const SALT_FILE = path.join(os.homedir(), STATE_DIR_NAME, '.machine-salt');
13
+ const VERSION_TAG = 'v1';
14
+ function readOrCreateSalt() {
15
+ try {
16
+ const raw = fs.readFileSync(SALT_FILE);
17
+ if (raw.length >= 32)
18
+ return raw.subarray(0, 32);
19
+ }
20
+ catch { }
21
+ const salt = crypto.randomBytes(32);
22
+ try {
23
+ fs.mkdirSync(path.dirname(SALT_FILE), { recursive: true });
24
+ fs.writeFileSync(SALT_FILE, salt, { mode: 0o600 });
25
+ }
26
+ catch { }
27
+ return salt;
28
+ }
29
+ function deriveKey() {
30
+ const salt = readOrCreateSalt();
31
+ const material = Buffer.concat([
32
+ Buffer.from(os.hostname() || 'pikiloop'),
33
+ Buffer.from(os.userInfo().username || ''),
34
+ salt,
35
+ ]);
36
+ return crypto.createHash('sha256').update(material).digest();
37
+ }
38
+ /** Returns base64 string `v1:<iv>:<ciphertext>:<tag>`. */
39
+ export function sealInline(plaintext) {
40
+ const key = deriveKey();
41
+ const iv = crypto.randomBytes(12);
42
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
43
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
44
+ const tag = cipher.getAuthTag();
45
+ return `${VERSION_TAG}:${iv.toString('base64')}:${encrypted.toString('base64')}:${tag.toString('base64')}`;
46
+ }
47
+ export function unsealInline(sealed) {
48
+ const parts = sealed.split(':');
49
+ if (parts.length !== 4 || parts[0] !== VERSION_TAG) {
50
+ throw new Error(`Invalid sealed blob: ${parts[0]}`);
51
+ }
52
+ const [, ivB64, ctB64, tagB64] = parts;
53
+ const key = deriveKey();
54
+ const iv = Buffer.from(ivB64, 'base64');
55
+ const ct = Buffer.from(ctB64, 'base64');
56
+ const tag = Buffer.from(tagB64, 'base64');
57
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
58
+ decipher.setAuthTag(tag);
59
+ return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
60
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Credential reference — never store raw secrets in setting.json.
3
+ *
4
+ * Each credential is described by a small reference that can be dereferenced
5
+ * at use time:
6
+ * - keychain: stored in OS keychain, looked up by service+account
7
+ * - env: read from process.env at use time
8
+ * - command: exec a command and use its stdout (e.g. `op read`, `gh auth token`)
9
+ * - inline: AES-GCM sealed blob bound to this machine (fallback)
10
+ */
11
+ /** Stable service name used when writing to the OS keychain. */
12
+ export const KEYCHAIN_SERVICE = 'pikiloop';
13
+ export function isCredentialRef(value) {
14
+ if (!value || typeof value !== 'object')
15
+ return false;
16
+ const r = value;
17
+ switch (r.source) {
18
+ case 'keychain': return typeof value.account === 'string';
19
+ case 'env': return typeof value.varName === 'string';
20
+ case 'command': return Array.isArray(value.argv);
21
+ case 'inline': return typeof value.sealed === 'string';
22
+ default: return false;
23
+ }
24
+ }
25
+ /** Short, non-sensitive description of a credential reference for UI display. */
26
+ export function describeCredentialRef(ref) {
27
+ switch (ref.source) {
28
+ case 'keychain': return `keychain:${ref.account}`;
29
+ case 'env': return `env:${ref.varName}`;
30
+ case 'command': return `cmd:${ref.argv[0] || '?'}…`;
31
+ case 'inline': return 'inline (sealed)';
32
+ }
33
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Resolve a CredentialRef to a plaintext secret.
3
+ *
4
+ * Resolved values are returned to the caller and immediately consumed (e.g.
5
+ * injected into a child process env at spawn time). Pikiloop never persists
6
+ * the resolved plaintext in long-lived state.
7
+ */
8
+ import { execFile } from 'node:child_process';
9
+ import { promisify } from 'node:util';
10
+ import { readKeychain } from './store.js';
11
+ import { unsealInline } from './inline-seal.js';
12
+ const execFileP = promisify(execFile);
13
+ export async function resolveCredential(ref, opts = {}) {
14
+ const env = opts.env ?? process.env;
15
+ switch (ref.source) {
16
+ case 'keychain': {
17
+ const value = await readKeychain(ref.account);
18
+ if (!value)
19
+ throw new Error(`Keychain entry not found: ${ref.account}`);
20
+ return value;
21
+ }
22
+ case 'env': {
23
+ const value = env[ref.varName];
24
+ if (!value)
25
+ throw new Error(`Environment variable not set: ${ref.varName}`);
26
+ return value;
27
+ }
28
+ case 'command': {
29
+ if (!ref.argv.length)
30
+ throw new Error('Empty command argv');
31
+ const [cmd, ...args] = ref.argv;
32
+ try {
33
+ const { stdout } = await execFileP(cmd, args, {
34
+ timeout: opts.commandTimeoutMs ?? 5000,
35
+ encoding: 'utf8',
36
+ env,
37
+ });
38
+ const value = String(stdout).trim();
39
+ if (!value)
40
+ throw new Error(`Command produced empty output: ${cmd}`);
41
+ return value;
42
+ }
43
+ catch (e) {
44
+ throw new Error(`Credential command failed (${cmd}): ${e?.message || e}`);
45
+ }
46
+ }
47
+ case 'inline': {
48
+ try {
49
+ return unsealInline(ref.sealed);
50
+ }
51
+ catch (e) {
52
+ throw new Error(`Inline credential decryption failed: ${e?.message || e}`);
53
+ }
54
+ }
55
+ }
56
+ }
57
+ /** Resolve to null on error rather than throwing — useful for status checks. */
58
+ export async function tryResolveCredential(ref, opts = {}) {
59
+ try {
60
+ return await resolveCredential(ref, opts);
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }