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,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background agent CLI version checking and update prompts.
|
|
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 { getAgentLabel, getAgentPackage, getAgentBrewCask } from './npm.js';
|
|
9
|
+
import { AGENT_UPDATE_TIMEOUTS, STATE_DIR_NAME } from '../core/constants.js';
|
|
10
|
+
const AGENT_UPDATE_LOCK_STALE_MS = AGENT_UPDATE_TIMEOUTS.lockStale;
|
|
11
|
+
const AGENT_UPDATE_COMMAND_TIMEOUT_MS = AGENT_UPDATE_TIMEOUTS.commandTimeout;
|
|
12
|
+
const updateStates = new Map();
|
|
13
|
+
function emptyState() {
|
|
14
|
+
return { latestVersion: null, currentVersion: null, updateAvailable: false, status: 'idle', detail: null, checkedAt: null };
|
|
15
|
+
}
|
|
16
|
+
function setUpdateState(agent, patch) {
|
|
17
|
+
const current = updateStates.get(agent) || emptyState();
|
|
18
|
+
updateStates.set(agent, { ...current, ...patch });
|
|
19
|
+
}
|
|
20
|
+
/** Returns the cached update state for a specific agent (or null). */
|
|
21
|
+
export function getAgentUpdateState(agent) {
|
|
22
|
+
return updateStates.get(agent) || null;
|
|
23
|
+
}
|
|
24
|
+
/** Returns all cached update states. */
|
|
25
|
+
export function getAllAgentUpdateStates() {
|
|
26
|
+
const result = {};
|
|
27
|
+
for (const [key, value] of updateStates)
|
|
28
|
+
result[key] = value;
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
function updaterLockPath() {
|
|
32
|
+
return path.join(os.homedir(), STATE_DIR_NAME, 'agent-auto-update.lock');
|
|
33
|
+
}
|
|
34
|
+
function normalizeBooleanEnv(value) {
|
|
35
|
+
const text = String(value || '').trim();
|
|
36
|
+
if (!text)
|
|
37
|
+
return null;
|
|
38
|
+
if (/^(1|true|yes|on)$/i.test(text))
|
|
39
|
+
return true;
|
|
40
|
+
if (/^(0|false|no|off)$/i.test(text))
|
|
41
|
+
return false;
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
export function agentAutoUpdateEnabled(config) {
|
|
45
|
+
const env = normalizeBooleanEnv(process.env.PIKILOOP_AGENT_AUTO_UPDATE);
|
|
46
|
+
if (env != null)
|
|
47
|
+
return env;
|
|
48
|
+
if (typeof config.agentAutoUpdate === 'boolean')
|
|
49
|
+
return config.agentAutoUpdate;
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
export function extractAgentSemver(value) {
|
|
53
|
+
const text = String(value || '').trim();
|
|
54
|
+
if (!text)
|
|
55
|
+
return null;
|
|
56
|
+
const match = text.match(/\b\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\b/);
|
|
57
|
+
return match?.[0] || null;
|
|
58
|
+
}
|
|
59
|
+
function isPathInside(parentDir, childPath) {
|
|
60
|
+
const parent = path.resolve(parentDir);
|
|
61
|
+
const child = path.resolve(childPath);
|
|
62
|
+
return child === parent || child.startsWith(`${parent}${path.sep}`);
|
|
63
|
+
}
|
|
64
|
+
function realPathOrNull(filePath) {
|
|
65
|
+
try {
|
|
66
|
+
return fs.realpathSync(filePath);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function packageDirFromNpmRoot(npmRoot, pkg) {
|
|
73
|
+
return path.join(path.resolve(npmRoot), ...pkg.split('/'));
|
|
74
|
+
}
|
|
75
|
+
function isNpmPackageOwnedBinary(binPath, pkg, npmRoot) {
|
|
76
|
+
if (!npmRoot)
|
|
77
|
+
return false;
|
|
78
|
+
const packageDir = packageDirFromNpmRoot(npmRoot, pkg);
|
|
79
|
+
if (!fs.existsSync(packageDir))
|
|
80
|
+
return false;
|
|
81
|
+
const realPackageDir = realPathOrNull(packageDir) || path.resolve(packageDir);
|
|
82
|
+
const realBinPath = realPathOrNull(binPath);
|
|
83
|
+
if (realBinPath && isPathInside(realPackageDir, realBinPath))
|
|
84
|
+
return true;
|
|
85
|
+
return isPathInside(realPackageDir, binPath);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Check if a binary was installed via Homebrew by resolving its real path.
|
|
89
|
+
* Homebrew cask binaries typically symlink through Caskroom or Cellar.
|
|
90
|
+
*/
|
|
91
|
+
function isBrewInstalledBinary(binPath) {
|
|
92
|
+
const realPath = realPathOrNull(binPath);
|
|
93
|
+
const target = realPath || binPath;
|
|
94
|
+
return /\/(Caskroom|Cellar)\//.test(target);
|
|
95
|
+
}
|
|
96
|
+
export function resolveAgentUpdateStrategy(agent, npmPrefix, npmRoot = null) {
|
|
97
|
+
const id = String(agent.agent || '').trim();
|
|
98
|
+
const pkg = getAgentPackage(id);
|
|
99
|
+
if (!pkg)
|
|
100
|
+
return { kind: 'skip', reason: 'unsupported agent' };
|
|
101
|
+
const binPath = String(agent.path || '').trim();
|
|
102
|
+
if (!binPath)
|
|
103
|
+
return { kind: 'skip', reason: 'no binary path' };
|
|
104
|
+
// Check for Homebrew install first (binary resolves to Caskroom/Cellar).
|
|
105
|
+
if (isBrewInstalledBinary(binPath)) {
|
|
106
|
+
const cask = getAgentBrewCask(id);
|
|
107
|
+
if (cask)
|
|
108
|
+
return { kind: 'brew', cask };
|
|
109
|
+
return { kind: 'skip', reason: 'brew-installed but no known cask' };
|
|
110
|
+
}
|
|
111
|
+
// Check for npm global install.
|
|
112
|
+
const npmBinDir = npmPrefix ? path.join(path.resolve(npmPrefix), 'bin') : null;
|
|
113
|
+
const npmManaged = !!(npmBinDir && isPathInside(npmBinDir, binPath));
|
|
114
|
+
if (!npmManaged)
|
|
115
|
+
return { kind: 'skip', reason: 'non-npm install path' };
|
|
116
|
+
if (!isNpmPackageOwnedBinary(binPath, pkg, npmRoot)) {
|
|
117
|
+
return { kind: 'skip', reason: 'binary is not owned by the npm package' };
|
|
118
|
+
}
|
|
119
|
+
return { kind: 'npm', pkg };
|
|
120
|
+
}
|
|
121
|
+
function labelForAgent(agent) {
|
|
122
|
+
return getAgentLabel(agent);
|
|
123
|
+
}
|
|
124
|
+
async function runCommand(cmd, args, opts = {}) {
|
|
125
|
+
return new Promise(resolve => {
|
|
126
|
+
let stdout = '';
|
|
127
|
+
let stderr = '';
|
|
128
|
+
let finished = false;
|
|
129
|
+
const child = spawn(cmd, args, {
|
|
130
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
131
|
+
// HOMEBREW_NO_AUTO_UPDATE=1 skips brew's implicit `brew update` before
|
|
132
|
+
// each command — we already resolve the latest version via the
|
|
133
|
+
// formulae.brew.sh API, so the refresh is redundant. NOTE: this does NOT
|
|
134
|
+
// prevent brew's vendor-install-ruby step, which contends with concurrent
|
|
135
|
+
// brew processes (Homebrew's launchd autoupdate, a manual brew run, etc.)
|
|
136
|
+
// on the `vendor-install-ruby` lockf and surfaces as "Failed to upgrade
|
|
137
|
+
// Homebrew Portable Ruby". Those transient collisions are handled by
|
|
138
|
+
// `isBrewBusyError` below, which downgrades the failure to a soft skip.
|
|
139
|
+
env: { ...process.env, npm_config_yes: 'true', HOMEBREW_NO_AUTO_UPDATE: '1' },
|
|
140
|
+
});
|
|
141
|
+
const timeoutMs = Math.max(500, opts.timeoutMs ?? AGENT_UPDATE_COMMAND_TIMEOUT_MS);
|
|
142
|
+
const timer = setTimeout(() => {
|
|
143
|
+
if (finished)
|
|
144
|
+
return;
|
|
145
|
+
finished = true;
|
|
146
|
+
child.kill('SIGTERM');
|
|
147
|
+
resolve({ ok: false, code: null, stdout, stderr, error: `Timed out after ${Math.round(timeoutMs / 1000)}s` });
|
|
148
|
+
}, timeoutMs);
|
|
149
|
+
child.stdout?.on('data', chunk => { stdout += String(chunk); });
|
|
150
|
+
child.stderr?.on('data', chunk => { stderr += String(chunk); });
|
|
151
|
+
child.on('error', err => {
|
|
152
|
+
if (finished)
|
|
153
|
+
return;
|
|
154
|
+
finished = true;
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
resolve({ ok: false, code: null, stdout, stderr, error: err.message });
|
|
157
|
+
});
|
|
158
|
+
child.on('close', code => {
|
|
159
|
+
if (finished)
|
|
160
|
+
return;
|
|
161
|
+
finished = true;
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
resolve({
|
|
164
|
+
ok: code === 0,
|
|
165
|
+
code,
|
|
166
|
+
stdout,
|
|
167
|
+
stderr,
|
|
168
|
+
error: code === 0 ? null : (stderr.trim() || stdout.trim() || `Exited with code ${code}`),
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
async function getNpmGlobalPrefix() {
|
|
174
|
+
const result = await runCommand('npm', ['prefix', '-g'], { timeoutMs: AGENT_UPDATE_TIMEOUTS.npmPrefix });
|
|
175
|
+
return result.ok ? result.stdout.trim().split('\n')[0] || null : null;
|
|
176
|
+
}
|
|
177
|
+
async function getNpmGlobalRoot() {
|
|
178
|
+
const result = await runCommand('npm', ['root', '-g'], { timeoutMs: AGENT_UPDATE_TIMEOUTS.npmPrefix });
|
|
179
|
+
return result.ok ? result.stdout.trim().split('\n')[0] || null : null;
|
|
180
|
+
}
|
|
181
|
+
async function getLatestPackageVersion(pkg) {
|
|
182
|
+
// `--prefer-online` bypasses the local npm metadata cache so we always see
|
|
183
|
+
// the registry's current `latest` tag. Without it, `npm view` can serve a
|
|
184
|
+
// stale version for several minutes after a release.
|
|
185
|
+
const result = await runCommand('npm', ['view', pkg, 'version', '--json', '--prefer-online'], { timeoutMs: AGENT_UPDATE_TIMEOUTS.npmView });
|
|
186
|
+
if (!result.ok)
|
|
187
|
+
return null;
|
|
188
|
+
const raw = result.stdout.trim();
|
|
189
|
+
if (!raw)
|
|
190
|
+
return null;
|
|
191
|
+
try {
|
|
192
|
+
const parsed = JSON.parse(raw);
|
|
193
|
+
return typeof parsed === 'string' ? parsed.trim() || null : null;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return raw.replace(/^"+|"+$/g, '').trim() || null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function acquireUpdateLock(log) {
|
|
200
|
+
const filePath = updaterLockPath();
|
|
201
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
202
|
+
try {
|
|
203
|
+
const stat = fs.statSync(filePath);
|
|
204
|
+
if (Date.now() - stat.mtimeMs > AGENT_UPDATE_LOCK_STALE_MS)
|
|
205
|
+
fs.rmSync(filePath, { force: true });
|
|
206
|
+
}
|
|
207
|
+
catch { }
|
|
208
|
+
try {
|
|
209
|
+
fs.writeFileSync(filePath, `${process.pid}\n`, { flag: 'wx' });
|
|
210
|
+
let released = false;
|
|
211
|
+
return () => {
|
|
212
|
+
if (released)
|
|
213
|
+
return;
|
|
214
|
+
released = true;
|
|
215
|
+
try {
|
|
216
|
+
fs.rmSync(filePath, { force: true });
|
|
217
|
+
}
|
|
218
|
+
catch { }
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
log('agent auto-update already running in another process; skipping this startup check');
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async function updateViaNpm(pkg) {
|
|
227
|
+
const result = await runCommand('npm', ['install', '-g', `${pkg}@latest`]);
|
|
228
|
+
return { ok: result.ok, detail: result.ok ? result.stdout.trim() || null : result.error };
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Detects the transient brew contention surfaced when another brew process is
|
|
232
|
+
* already running its `vendor-install ruby` step (Homebrew's launchd
|
|
233
|
+
* autoupdate, a manual `brew upgrade`, etc.). We treat these as soft skips
|
|
234
|
+
* rather than failures so the dashboard doesn't shout an error at the user for
|
|
235
|
+
* what is really "try again in a minute".
|
|
236
|
+
*/
|
|
237
|
+
function isBrewBusyError(text) {
|
|
238
|
+
if (!text)
|
|
239
|
+
return false;
|
|
240
|
+
return /vendor-install ruby|already locked|Failed to upgrade Homebrew Portable Ruby|is already running/i.test(text);
|
|
241
|
+
}
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// Homebrew helpers
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
/** Get latest available version for a Homebrew cask.
|
|
246
|
+
*
|
|
247
|
+
* Queries Homebrew's public formulae API rather than `brew info`, because the
|
|
248
|
+
* local cask metadata is only refreshed by `brew update` — without it, a
|
|
249
|
+
* just-published cask version can stay invisible for hours/days. The HTTPS API
|
|
250
|
+
* always returns the current cask manifest. Falls back to local `brew info`
|
|
251
|
+
* if the network call fails. */
|
|
252
|
+
async function getLatestBrewCaskVersion(cask) {
|
|
253
|
+
const apiVersion = await fetchBrewCaskVersionFromApi(cask);
|
|
254
|
+
if (apiVersion)
|
|
255
|
+
return apiVersion;
|
|
256
|
+
const result = await runCommand('brew', ['info', '--json=v2', '--cask', cask], { timeoutMs: AGENT_UPDATE_TIMEOUTS.npmView });
|
|
257
|
+
if (!result.ok)
|
|
258
|
+
return null;
|
|
259
|
+
try {
|
|
260
|
+
const data = JSON.parse(result.stdout);
|
|
261
|
+
const version = data?.casks?.[0]?.version;
|
|
262
|
+
return typeof version === 'string' ? version.trim() || null : null;
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async function fetchBrewCaskVersionFromApi(cask) {
|
|
269
|
+
const url = `https://formulae.brew.sh/api/cask/${encodeURIComponent(cask)}.json`;
|
|
270
|
+
try {
|
|
271
|
+
const controller = new AbortController();
|
|
272
|
+
const timer = setTimeout(() => controller.abort(), AGENT_UPDATE_TIMEOUTS.npmView);
|
|
273
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
274
|
+
clearTimeout(timer);
|
|
275
|
+
if (!res.ok)
|
|
276
|
+
return null;
|
|
277
|
+
const data = await res.json();
|
|
278
|
+
return typeof data.version === 'string' ? data.version.trim() || null : null;
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
async function updateViaBrew(cask) {
|
|
285
|
+
const result = await runCommand('brew', ['upgrade', '--cask', cask], {
|
|
286
|
+
timeoutMs: AGENT_UPDATE_COMMAND_TIMEOUT_MS,
|
|
287
|
+
});
|
|
288
|
+
if (result.ok)
|
|
289
|
+
return { ok: true, detail: result.stdout.trim() || null };
|
|
290
|
+
const busy = isBrewBusyError(result.stderr) || isBrewBusyError(result.error);
|
|
291
|
+
return { ok: false, detail: result.error, busy };
|
|
292
|
+
}
|
|
293
|
+
export function startAgentAutoUpdate(opts) {
|
|
294
|
+
if (!agentAutoUpdateEnabled(opts.config))
|
|
295
|
+
return;
|
|
296
|
+
const installedAgents = opts.agents.filter(agent => agent.installed && agent.path);
|
|
297
|
+
if (!installedAgents.length)
|
|
298
|
+
return;
|
|
299
|
+
const releaseLock = acquireUpdateLock(opts.log);
|
|
300
|
+
if (!releaseLock)
|
|
301
|
+
return;
|
|
302
|
+
void (async () => {
|
|
303
|
+
try {
|
|
304
|
+
opts.log(`agent auto-update: checking ${installedAgents.length} installed agent${installedAgents.length === 1 ? '' : 's'} in background`);
|
|
305
|
+
const npmPrefix = await getNpmGlobalPrefix();
|
|
306
|
+
const npmRoot = await getNpmGlobalRoot();
|
|
307
|
+
for (const agent of installedAgents) {
|
|
308
|
+
const id = String(agent.agent || '').trim();
|
|
309
|
+
const pkg = getAgentPackage(id);
|
|
310
|
+
if (!pkg)
|
|
311
|
+
continue;
|
|
312
|
+
const label = labelForAgent(id);
|
|
313
|
+
const currentVersion = extractAgentSemver(agent.version);
|
|
314
|
+
setUpdateState(id, { currentVersion, status: 'checking' });
|
|
315
|
+
const strategy = resolveAgentUpdateStrategy(agent, npmPrefix, npmRoot);
|
|
316
|
+
if (strategy.kind === 'skip') {
|
|
317
|
+
opts.log(`agent auto-update: ${label} skipped (${strategy.reason})`);
|
|
318
|
+
setUpdateState(id, { status: 'skipped', detail: strategy.reason, checkedAt: Date.now() });
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
// Use brew version check for brew installs, npm for npm installs.
|
|
322
|
+
const latestVersion = strategy.kind === 'brew'
|
|
323
|
+
? await getLatestBrewCaskVersion(strategy.cask)
|
|
324
|
+
: await getLatestPackageVersion(pkg);
|
|
325
|
+
if (!latestVersion) {
|
|
326
|
+
opts.log(`agent auto-update: ${label} latest version lookup failed`);
|
|
327
|
+
setUpdateState(id, { status: 'failed', detail: 'latest version lookup failed', checkedAt: Date.now() });
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (currentVersion === latestVersion) {
|
|
331
|
+
opts.log(`agent auto-update: ${label} is already up to date (${latestVersion})`);
|
|
332
|
+
setUpdateState(id, { latestVersion, updateAvailable: false, status: 'up-to-date', checkedAt: Date.now() });
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
setUpdateState(id, { latestVersion, updateAvailable: true, status: 'updating' });
|
|
336
|
+
opts.log(`agent auto-update: updating ${label} ${currentVersion || 'unknown'} -> ${latestVersion} via ${strategy.kind}`);
|
|
337
|
+
const result = strategy.kind === 'brew'
|
|
338
|
+
? await updateViaBrew(strategy.cask)
|
|
339
|
+
: await updateViaNpm(strategy.pkg);
|
|
340
|
+
if (result.ok) {
|
|
341
|
+
opts.log(`agent auto-update: ${label} update completed`);
|
|
342
|
+
setUpdateState(id, { updateAvailable: false, status: 'updated', detail: null, checkedAt: Date.now() });
|
|
343
|
+
}
|
|
344
|
+
else if (result.busy) {
|
|
345
|
+
opts.log(`agent auto-update: ${label} deferred — another brew process is busy upgrading Homebrew`);
|
|
346
|
+
setUpdateState(id, {
|
|
347
|
+
status: 'skipped',
|
|
348
|
+
detail: 'another brew process is busy upgrading Homebrew — will retry on next startup',
|
|
349
|
+
checkedAt: Date.now(),
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
opts.log(`agent auto-update: ${label} update failed: ${result.detail || 'unknown error'}`);
|
|
354
|
+
setUpdateState(id, { status: 'failed', detail: result.detail || 'unknown error', checkedAt: Date.now() });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
opts.log('agent auto-update: finished');
|
|
358
|
+
}
|
|
359
|
+
finally {
|
|
360
|
+
releaseLock();
|
|
361
|
+
}
|
|
362
|
+
})();
|
|
363
|
+
}
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
// On-demand version check (called from dashboard)
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
/** Check latest version for a single agent. Uses brew or npm depending on install method. */
|
|
368
|
+
export async function checkAgentLatestVersion(agent) {
|
|
369
|
+
const id = String(agent.agent || '').trim();
|
|
370
|
+
const pkg = getAgentPackage(id);
|
|
371
|
+
if (!pkg)
|
|
372
|
+
return { ...emptyState(), status: 'skipped', detail: 'unsupported agent', checkedAt: Date.now() };
|
|
373
|
+
const currentVersion = extractAgentSemver(agent.version);
|
|
374
|
+
setUpdateState(id, { currentVersion, status: 'checking' });
|
|
375
|
+
// Detect brew install and use brew version check.
|
|
376
|
+
const binPath = String(agent.path || '').trim();
|
|
377
|
+
const brewCask = binPath && isBrewInstalledBinary(binPath) ? getAgentBrewCask(id) : null;
|
|
378
|
+
const latestVersion = brewCask
|
|
379
|
+
? await getLatestBrewCaskVersion(brewCask)
|
|
380
|
+
: await getLatestPackageVersion(pkg);
|
|
381
|
+
if (!latestVersion) {
|
|
382
|
+
const state = { currentVersion, latestVersion: null, updateAvailable: false, status: 'failed', detail: 'latest version lookup failed', checkedAt: Date.now() };
|
|
383
|
+
setUpdateState(id, state);
|
|
384
|
+
return state;
|
|
385
|
+
}
|
|
386
|
+
const updateAvailable = !!(currentVersion && latestVersion && currentVersion !== latestVersion);
|
|
387
|
+
const state = {
|
|
388
|
+
currentVersion,
|
|
389
|
+
latestVersion,
|
|
390
|
+
updateAvailable,
|
|
391
|
+
status: updateAvailable ? 'update-available' : 'up-to-date',
|
|
392
|
+
detail: null,
|
|
393
|
+
checkedAt: Date.now(),
|
|
394
|
+
};
|
|
395
|
+
setUpdateState(id, state);
|
|
396
|
+
return state;
|
|
397
|
+
}
|
|
398
|
+
/** Manually trigger an update for a specific agent (auto-detects brew vs npm). */
|
|
399
|
+
export async function manualAgentUpdate(agent, log) {
|
|
400
|
+
const id = String(agent.agent || '').trim();
|
|
401
|
+
const pkg = getAgentPackage(id);
|
|
402
|
+
if (!pkg)
|
|
403
|
+
return { ok: false, error: 'Unsupported agent' };
|
|
404
|
+
const label = labelForAgent(id);
|
|
405
|
+
const binPath = String(agent.path || '').trim();
|
|
406
|
+
const brewCask = binPath && isBrewInstalledBinary(binPath) ? getAgentBrewCask(id) : null;
|
|
407
|
+
setUpdateState(id, { status: 'updating' });
|
|
408
|
+
let result;
|
|
409
|
+
if (brewCask) {
|
|
410
|
+
log(`manual update: updating ${label} via brew upgrade --cask ${brewCask}`);
|
|
411
|
+
result = await updateViaBrew(brewCask);
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
log(`manual update: updating ${label} via npm install -g ${pkg}@latest`);
|
|
415
|
+
result = await updateViaNpm(pkg);
|
|
416
|
+
}
|
|
417
|
+
if (result.ok) {
|
|
418
|
+
log(`manual update: ${label} update completed`);
|
|
419
|
+
setUpdateState(id, { updateAvailable: false, status: 'updated', detail: null, checkedAt: Date.now() });
|
|
420
|
+
return { ok: true, error: null };
|
|
421
|
+
}
|
|
422
|
+
if (result.busy) {
|
|
423
|
+
const detail = 'another brew process is busy upgrading Homebrew — please try again in a minute';
|
|
424
|
+
log(`manual update: ${label} deferred — ${detail}`);
|
|
425
|
+
setUpdateState(id, { status: 'skipped', detail, checkedAt: Date.now() });
|
|
426
|
+
return { ok: false, error: detail };
|
|
427
|
+
}
|
|
428
|
+
const error = result.detail || 'unknown error';
|
|
429
|
+
log(`manual update: ${label} update failed: ${error}`);
|
|
430
|
+
setUpdateState(id, { status: 'failed', detail: error, checkedAt: Date.now() });
|
|
431
|
+
return { ok: false, error };
|
|
432
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* await-resume.ts — "waiting for background work" session marker.
|
|
3
|
+
*
|
|
4
|
+
* <sessionRoot>/awaiting.json — persisted marker
|
|
5
|
+
*
|
|
6
|
+
* A turn runs as a one-shot `claude -p` process that exits at its `result`
|
|
7
|
+
* event. When the agent launches truly detached work (a daemon outliving the
|
|
8
|
+
* harness, e.g. an install that must survive a restart) and ends the turn
|
|
9
|
+
* intending to report back, the process exits and the session reads as plainly
|
|
10
|
+
* "completed" — there is no live process for the background task to wake. This
|
|
11
|
+
* marker lets the agent declare that intent (via the `await_background` MCP
|
|
12
|
+
* tool, which writes the file directly) so the dashboard can show a distinct
|
|
13
|
+
* "waiting" state instead of "completed".
|
|
14
|
+
*
|
|
15
|
+
* The MCP tool (child process) writes the file; the parent only reads and
|
|
16
|
+
* clears it — the same split used by goal.ts / tools/goal.ts. The marker is
|
|
17
|
+
* cleared the next time the session runs (clearAwaitResume in stream.ts), so a
|
|
18
|
+
* resumed turn naturally drops back out of the "waiting" state.
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
const AWAIT_FILE = 'awaiting.json';
|
|
23
|
+
const MAX_REASON_CHARS = 280;
|
|
24
|
+
export function sessionAwaitPath(workdir, agent, sessionId) {
|
|
25
|
+
return path.join(workdir, '.pikiloop', 'sessions', agent, sessionId, AWAIT_FILE);
|
|
26
|
+
}
|
|
27
|
+
export function readAwaitResume(workdir, agent, sessionId) {
|
|
28
|
+
const file = sessionAwaitPath(workdir, agent, sessionId);
|
|
29
|
+
if (!fs.existsSync(file))
|
|
30
|
+
return null;
|
|
31
|
+
try {
|
|
32
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
33
|
+
const reason = typeof raw?.reason === 'string' ? raw.reason.trim() : '';
|
|
34
|
+
const since = typeof raw?.since === 'string' ? raw.since.trim() : '';
|
|
35
|
+
if (!since)
|
|
36
|
+
return null;
|
|
37
|
+
return { reason: reason.slice(0, MAX_REASON_CHARS), since };
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function clearAwaitResume(workdir, agent, sessionId) {
|
|
44
|
+
try {
|
|
45
|
+
fs.rmSync(sessionAwaitPath(workdir, agent, sessionId), { force: true });
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* best-effort */
|
|
49
|
+
}
|
|
50
|
+
}
|