@anhlp/alp-client 0.0.0-stage → 0.10.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/README.md +8 -2
- package/dist/acp-probe.d.ts +44 -0
- package/dist/acp-probe.js +37 -0
- package/dist/antigravity-probe.d.ts +34 -0
- package/dist/antigravity-probe.js +95 -0
- package/dist/command.d.ts +5 -0
- package/dist/command.js +32 -0
- package/dist/doctor.d.ts +13 -0
- package/dist/doctor.js +386 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +391 -0
- package/dist/library-test.d.ts +94 -0
- package/dist/library-test.js +37 -0
- package/dist/mcp-probe.d.ts +24 -0
- package/dist/mcp-probe.js +144 -0
- package/dist/prompt.d.ts +32 -0
- package/dist/prompt.js +59 -0
- package/dist/render.d.ts +6 -0
- package/dist/render.js +120 -0
- package/dist/service.d.ts +50 -0
- package/dist/service.js +143 -0
- package/dist/setup.d.ts +2 -0
- package/dist/setup.js +146 -0
- package/dist/supervise.d.ts +12 -0
- package/dist/supervise.js +81 -0
- package/package.json +135 -4
- package/src/acp-probe.js +46 -0
- package/src/antigravity-probe.js +106 -0
- package/src/command.js +16 -0
- package/src/doctor.d.ts +13 -0
- package/src/doctor.js +375 -0
- package/src/index.js +408 -0
- package/src/library-test.js +41 -0
- package/src/mcp-probe.js +115 -0
- package/src/prompt.js +64 -0
- package/src/render.js +136 -0
- package/src/service.js +154 -0
- package/src/setup.js +150 -0
- package/src/supervise.d.ts +12 -0
- package/src/supervise.js +85 -0
package/src/render.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text the CLI prints for sessions and run logs, kept apart from the commands so
|
|
3
|
+
* golden tests can render fixtures without an alpd (ALPD §39).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const TASK_PAST = { create: 'created', update: 'updated', link: 'linked', start: 'started', close: 'closed', reopen: 'reopened', delegate: 'delegated', submit: 'submitted', release: 'released', gate: 'gated', clear: 'cleared a gate of', orphaned: 'reopened (its assignment ended with alpd)' };
|
|
7
|
+
|
|
8
|
+
/** How long ago `since` was, as 42s, 3m05s or 2h10m. */
|
|
9
|
+
export const ago = (since, now = Date.now()) => {
|
|
10
|
+
const seconds = Math.max(0, Math.round((now - Date.parse(since)) / 1000));
|
|
11
|
+
return seconds < 60 ? `${seconds}s` : seconds < 3600 ? `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s` : `${Math.floor(seconds / 3600)}h${String(Math.floor(seconds % 3600 / 60)).padStart(2, '0')}m`;
|
|
12
|
+
};
|
|
13
|
+
/** A duration in the same form. */
|
|
14
|
+
export const duration = ms => ago(new Date(0).toISOString(), ms);
|
|
15
|
+
/** A token count as people say it: 164k, 1M. */
|
|
16
|
+
const tokens = count => typeof count !== 'number' ? '?' : count >= 1_000_000 && count % 1_000_000 < 50_000 ? `${Math.round(count / 1_000_000)}M` : `${Math.round(count / 1000)}k`;
|
|
17
|
+
|
|
18
|
+
/** `alp ps`: sessions as a tree, one line each. */
|
|
19
|
+
export function renderPs(sessions) {
|
|
20
|
+
const lines = [];
|
|
21
|
+
const children = new Map();
|
|
22
|
+
for (const session of sessions) {
|
|
23
|
+
const key = session.parentId ?? '';
|
|
24
|
+
children.set(key, [...(children.get(key) ?? []), session]);
|
|
25
|
+
}
|
|
26
|
+
const print = (session, depth) => {
|
|
27
|
+
const status = session.parked ? `parked (${session.parked})` : session.status === 'running' || session.status === 'idle' ? (session.activeTurnId ? 'running' : session.busy ? 'waiting' : 'idle') : session.lastError?.code ?? session.status;
|
|
28
|
+
lines.push(`${' '.repeat(depth)}${session.id} ${session.agent} ${session.runtime}:${session.model} ${session.mode} ${status}${depth ? '' : ` ${session.projectRoot}${session.title ? ` "${session.title}"` : ''}`}`);
|
|
29
|
+
for (const child of children.get(session.id) ?? []) print(child, depth + 1);
|
|
30
|
+
};
|
|
31
|
+
for (const root of children.get('') ?? []) print(root, 0);
|
|
32
|
+
return lines;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `alp log`: one line per run-log entry, with its time. */
|
|
36
|
+
export function renderLog(rootId, entries) {
|
|
37
|
+
const lines = [];
|
|
38
|
+
const agents = new Map();
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
const time = entry.ts.slice(11, 19);
|
|
41
|
+
let text;
|
|
42
|
+
switch (entry.event) {
|
|
43
|
+
case 'assignment.started':
|
|
44
|
+
agents.set(entry.assignmentId, entry.agent);
|
|
45
|
+
text = `${entry.parentAgent} → ${entry.agent} (${entry.mode}${entry.isolation === 'worktree' ? ', worktree' : ''}, ${entry.model}${entry.wait === false ? ', async' : ''}): ${entry.task.split('\n')[0].slice(0, 120)}`;
|
|
46
|
+
break;
|
|
47
|
+
case 'assignment.finished':
|
|
48
|
+
text = `${entry.agent} ${entry.status} after ${duration(entry.durationMs ?? 0)}${entry.handoff ? `, handoff ${entry.handoff.outcome}${entry.handoff.verdict ? `, verdict ${entry.handoff.verdict.result.toUpperCase()}` : ''}: ${entry.handoff.summary.split('\n')[0].slice(0, 120)}` : ''}${entry.error ? `: ${entry.error}` : ''}${entry.reconciled ? ' (after a restart)' : ''}`;
|
|
49
|
+
break;
|
|
50
|
+
case 'mail':
|
|
51
|
+
// Board pins are logged once, as board.pin, not per reader.
|
|
52
|
+
if (entry.kind === 'board') continue;
|
|
53
|
+
text = `✉ ${entry.kind} ${entry.from} → ${entry.to === rootId ? 'main' : agents.get(entry.to) ?? entry.to}${entry.body ? `: ${entry.body.split('\n')[0].slice(0, 120)}` : ''}`;
|
|
54
|
+
break;
|
|
55
|
+
case 'board.pin':
|
|
56
|
+
text = `${entry.kind === 'claim' ? '⚑' : entry.kind === 'decision' ? '◆' : '•'} ${entry.agent} pins ${entry.kind} ${entry.pinId}${entry.task ? ` for ${entry.task}` : ''}${entry.paths ? ` [${entry.paths.join(', ')}]` : ''}: ${entry.body.split('\n')[0].slice(0, 120)}`;
|
|
57
|
+
break;
|
|
58
|
+
case 'task':
|
|
59
|
+
text = `☐ ${entry.agent} ${TASK_PAST[entry.action] ?? entry.action} ${entry.id} "${entry.title}" → ${entry.status}${entry.detail ? `: ${entry.detail.split('\n')[0].slice(0, 120)}` : ''}`;
|
|
60
|
+
break;
|
|
61
|
+
case 'board.unpin':
|
|
62
|
+
text = `⚐ ${entry.pinId} by ${entry.agent} ${entry.reason === 'session_ended' ? 'released when its session ended' : 'taken down'}`;
|
|
63
|
+
break;
|
|
64
|
+
case 'human.question':
|
|
65
|
+
text = `? ${entry.agent} asks the user [${entry.questionId}]: ${entry.body.split('\n')[0].slice(0, 120)}`;
|
|
66
|
+
break;
|
|
67
|
+
case 'verify':
|
|
68
|
+
text = `${entry.skipped ? '–' : entry.passed ? '✓' : '✗'} verify in the ${entry.where}${entry.taskId ? ` for ${entry.taskId}` : ''}${entry.assignmentId ? ` (${entry.assignmentId})` : ''}: ${entry.skipped ? `skipped: ${entry.skipped}` : entry.detail}`;
|
|
69
|
+
break;
|
|
70
|
+
case 'epic.landed':
|
|
71
|
+
text = `◆ ${entry.agent} closed ${entry.id} "${entry.title}": ${entry.tasks} tasks, ${entry.reworked} reworked`;
|
|
72
|
+
break;
|
|
73
|
+
case 'notice':
|
|
74
|
+
text = `${entry.level === 'error' ? '‼' : entry.level === 'warning' ? '!' : 'ℹ'} ${entry.text}`;
|
|
75
|
+
break;
|
|
76
|
+
case 'assignment.parked':
|
|
77
|
+
text = `⏸ ${entry.agent} ${entry.assignmentId} parked: ${entry.reason}`;
|
|
78
|
+
break;
|
|
79
|
+
case 'assignment.resumed':
|
|
80
|
+
text = `▶ ${entry.agent} ${entry.assignmentId} resumed`;
|
|
81
|
+
break;
|
|
82
|
+
case 'instructions':
|
|
83
|
+
text = `# ${entry.agent} instructions ${entry.sha} (ALP.md ${entry.parts?.project}, AGENT.md ${entry.parts?.agent}, ${entry.chars} chars)`;
|
|
84
|
+
break;
|
|
85
|
+
case 'context':
|
|
86
|
+
// Before 0.6 the advice came at 60% and 80% of the window (ALPD §35); since, once near compaction (§57).
|
|
87
|
+
text = entry.level === 'soon'
|
|
88
|
+
? `◔ ${entry.agent} context ${tokens(entry.tokens)}, compacts at ${tokens(entry.compactAt)}: told it is near`
|
|
89
|
+
: `◔ ${entry.agent} context ${entry.percent}% full: ${entry.level === 'now' ? 'told to hand off now' : 'told to plan a handoff'}`;
|
|
90
|
+
break;
|
|
91
|
+
case 'skill':
|
|
92
|
+
// alp_skill saving a skill (name, roles) shares the name with an agent using one (skill, via).
|
|
93
|
+
text = Array.isArray(entry.roles)
|
|
94
|
+
? `✦ ${entry.agent} saved skill ${entry.name} for ${entry.roles.join(', ')}`
|
|
95
|
+
: `✦ ${entry.agent} uses skill ${entry.skill}${entry.via === 'skill' ? ' (Skill tool)' : ''}`;
|
|
96
|
+
break;
|
|
97
|
+
case 'compacted':
|
|
98
|
+
text = entry.failed
|
|
99
|
+
? `✗ ${entry.agent}'s context could not be compacted${entry.error ? `: ${entry.error}` : ''}`
|
|
100
|
+
: `◑ ${entry.agent}'s context compacted${entry.preTokens !== undefined ? ` from ${tokens(entry.preTokens)}` : ''}${entry.postTokens !== undefined ? ` to ${tokens(entry.postTokens)}` : ''}${entry.trigger === 'manual' ? ' on request' : ''}`;
|
|
101
|
+
break;
|
|
102
|
+
case 'assignment.interrupted':
|
|
103
|
+
text = `⏹ ${entry.agent} ${entry.assignmentId} interrupted: ${entry.reason}; the next alpd continues it`;
|
|
104
|
+
break;
|
|
105
|
+
case 'assignment.recovered':
|
|
106
|
+
text = `↻ ${entry.agent} ${entry.assignmentId} reopened after alpd restarted`;
|
|
107
|
+
break;
|
|
108
|
+
case 'session.restarted':
|
|
109
|
+
text = `↻ ${entry.agent}'s native process stopped (${entry.error}); restart ${entry.restarts}`;
|
|
110
|
+
break;
|
|
111
|
+
case 'session.revived':
|
|
112
|
+
text = `▶ ${entry.agent} running again`;
|
|
113
|
+
break;
|
|
114
|
+
case 'session.revive_failed':
|
|
115
|
+
text = `✗ ${entry.agent} could not be restarted: ${entry.error}`;
|
|
116
|
+
break;
|
|
117
|
+
case 'human.answer':
|
|
118
|
+
text = `↳ ${entry.questionId} ${entry.outcome}${entry.answer !== undefined ? `: ${entry.answer.split('\n')[0].slice(0, 120)}` : ''}`;
|
|
119
|
+
break;
|
|
120
|
+
case 'hook':
|
|
121
|
+
text = entry.skipped ? `↪ ${entry.agent}'s ${entry.on} hook ${entry.hook} skipped: the workspace's hooks are not trusted`
|
|
122
|
+
: `${entry.blocked ? '⛔' : entry.error || entry.exitCode !== 0 || entry.timedOut ? '✗' : '↪'} ${entry.agent}'s ${entry.on} hook ${entry.hook} ${entry.error ? `could not run: ${entry.error}` : entry.timedOut ? 'timed out' : `exit ${entry.exitCode}`}${entry.durationMs !== undefined ? ` in ${duration(entry.durationMs)}` : ''}${entry.blocked ? ', refused it' : ''}${entry.output ? `: ${entry.output.split('\n')[0].slice(0, 120)}` : ''}`;
|
|
123
|
+
break;
|
|
124
|
+
case 'hook.trust':
|
|
125
|
+
text = `${entry.trusted ? '✓ the user trusts' : '✗ the user did not trust'} the hooks of ${entry.project}${entry.outcome ? ` (${entry.outcome})` : ''}`;
|
|
126
|
+
break;
|
|
127
|
+
case 'runtime.slip':
|
|
128
|
+
text = `⚠ ALP could not handle ${entry.agent}'s ${entry.method}: ${entry.error}; the session went on`;
|
|
129
|
+
break;
|
|
130
|
+
default:
|
|
131
|
+
text = `${entry.event}${entry.branch ? ` ${entry.branch}` : ''}${entry.status ? ` ${entry.status}` : ''}`;
|
|
132
|
+
}
|
|
133
|
+
lines.push(`${time} ${text}`);
|
|
134
|
+
}
|
|
135
|
+
return lines;
|
|
136
|
+
}
|
package/src/service.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { runCommand } from './command.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* alpd as a user service (ALPD §37): a LaunchAgent on macOS, a systemd user unit on
|
|
9
|
+
* Linux. The service manager starts alpd at login and starts it again after a crash;
|
|
10
|
+
* a clean stop (`alp daemon stop`, exit 0) stays stopped until `alp daemon start`.
|
|
11
|
+
* Paths can be redirected for tests: ALP_SERVICE_DIR for the definition's directory,
|
|
12
|
+
* ALP_LAUNCHCTL and ALP_SYSTEMCTL for the managers.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Variables alpd reads, carried into the service when the installing shell sets them. */
|
|
16
|
+
const CARRIED = ['ALP_RUN_LOG_DIR', 'ALP_CODEX_BIN', 'ALP_CLAUDE_BIN', 'ALP_ANTIGRAVITY_BIN', 'ALP_GH_BIN'];
|
|
17
|
+
|
|
18
|
+
const escapeXml = value => value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
19
|
+
const quoteUnit = value => `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Where and how alpd for `home` is defined as a service on `platform`, or undefined
|
|
23
|
+
* where ALP has no service support. Each ALP_HOME gets its own service.
|
|
24
|
+
*/
|
|
25
|
+
export function serviceFor({ home, env = process.env, platform = process.platform, uid = process.getuid?.() ?? 0 }) {
|
|
26
|
+
const custom = path.resolve(home) !== path.join(os.homedir(), '.alp');
|
|
27
|
+
const suffix = custom ? createHash('sha256').update(path.resolve(home)).digest('hex').slice(0, 8) : '';
|
|
28
|
+
if (platform === 'darwin') {
|
|
29
|
+
const label = `com.alp.alpd${suffix ? `.${suffix}` : ''}`;
|
|
30
|
+
const directory = env.ALP_SERVICE_DIR || path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
31
|
+
const launchctl = env.ALP_LAUNCHCTL || 'launchctl';
|
|
32
|
+
const target = `gui/${uid}/${label}`;
|
|
33
|
+
const file = path.join(directory, `${label}.plist`);
|
|
34
|
+
return {
|
|
35
|
+
kind: 'launchd', name: label, file,
|
|
36
|
+
install: [[launchctl, ['bootout', target], { ignore: true }], [launchctl, ['bootstrap', `gui/${uid}`, file]]],
|
|
37
|
+
start: [[launchctl, ['kickstart', target]]],
|
|
38
|
+
uninstall: [[launchctl, ['bootout', target], { ignore: true }]],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (platform === 'linux') {
|
|
42
|
+
const name = `alpd${suffix ? `-${suffix}` : ''}`;
|
|
43
|
+
const directory = env.ALP_SERVICE_DIR || path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'systemd', 'user');
|
|
44
|
+
const systemctl = env.ALP_SYSTEMCTL || 'systemctl';
|
|
45
|
+
return {
|
|
46
|
+
kind: 'systemd', name, file: path.join(directory, `${name}.service`),
|
|
47
|
+
install: [[systemctl, ['--user', 'daemon-reload']], [systemctl, ['--user', 'enable', '--now', `${name}.service`]]],
|
|
48
|
+
start: [[systemctl, ['--user', 'start', `${name}.service`]]],
|
|
49
|
+
uninstall: [[systemctl, ['--user', 'disable', '--now', `${name}.service`], { ignore: true }]],
|
|
50
|
+
afterUninstall: [[systemctl, ['--user', 'daemon-reload'], { ignore: true }]],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The service definition that runs `entry` with `execPath` for `home`. */
|
|
57
|
+
export function serviceDefinition(service, { home, entry, execPath = process.execPath, env = process.env }) {
|
|
58
|
+
const variables = { ALP_HOME: path.resolve(home), PATH: env.PATH ?? '' };
|
|
59
|
+
for (const name of CARRIED) if (env[name]) variables[name] = env[name];
|
|
60
|
+
const program = [execPath, entry, '--service'];
|
|
61
|
+
const fatal = path.join(path.resolve(home), 'logs', 'alpd.service.log');
|
|
62
|
+
if (service.kind === 'launchd') {
|
|
63
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
64
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
65
|
+
<plist version="1.0">
|
|
66
|
+
<dict>
|
|
67
|
+
<key>Label</key>
|
|
68
|
+
<string>${escapeXml(service.name)}</string>
|
|
69
|
+
<key>ProgramArguments</key>
|
|
70
|
+
<array>
|
|
71
|
+
${program.map(arg => ` <string>${escapeXml(arg)}</string>`).join('\n')}
|
|
72
|
+
</array>
|
|
73
|
+
<key>EnvironmentVariables</key>
|
|
74
|
+
<dict>
|
|
75
|
+
${Object.entries(variables).map(([key, value]) => ` <key>${escapeXml(key)}</key>\n <string>${escapeXml(value)}</string>`).join('\n')}
|
|
76
|
+
</dict>
|
|
77
|
+
<key>RunAtLoad</key>
|
|
78
|
+
<true/>
|
|
79
|
+
<key>KeepAlive</key>
|
|
80
|
+
<dict>
|
|
81
|
+
<key>Crashed</key>
|
|
82
|
+
<true/>
|
|
83
|
+
<key>SuccessfulExit</key>
|
|
84
|
+
<false/>
|
|
85
|
+
</dict>
|
|
86
|
+
<key>ThrottleInterval</key>
|
|
87
|
+
<integer>10</integer>
|
|
88
|
+
<key>ProcessType</key>
|
|
89
|
+
<string>Background</string>
|
|
90
|
+
<key>StandardOutPath</key>
|
|
91
|
+
<string>${escapeXml(fatal)}</string>
|
|
92
|
+
<key>StandardErrorPath</key>
|
|
93
|
+
<string>${escapeXml(fatal)}</string>
|
|
94
|
+
</dict>
|
|
95
|
+
</plist>
|
|
96
|
+
`;
|
|
97
|
+
}
|
|
98
|
+
return `[Unit]
|
|
99
|
+
Description=ALP daemon (alpd) for ${path.resolve(home)}
|
|
100
|
+
|
|
101
|
+
[Service]
|
|
102
|
+
ExecStart=${program.map(quoteUnit).join(' ')}
|
|
103
|
+
${Object.entries(variables).map(([key, value]) => `Environment=${quoteUnit(`${key}=${value}`)}`).join('\n')}
|
|
104
|
+
Restart=on-failure
|
|
105
|
+
RestartSec=10
|
|
106
|
+
StandardOutput=append:${fatal}
|
|
107
|
+
StandardError=append:${fatal}
|
|
108
|
+
|
|
109
|
+
[Install]
|
|
110
|
+
WantedBy=default.target
|
|
111
|
+
`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The program an installed definition runs: [node, alpd.js], or undefined when it is not installed. */
|
|
115
|
+
export async function installedProgram(service) {
|
|
116
|
+
const text = await readFile(service.file, 'utf8').catch(() => undefined);
|
|
117
|
+
if (text === undefined) return undefined;
|
|
118
|
+
if (service.kind === 'launchd') {
|
|
119
|
+
const array = text.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/)?.[1] ?? '';
|
|
120
|
+
const unescape = value => value.replace(/"/g, '"').replace(/>/g, '>').replace(/</g, '<').replace(/&/g, '&');
|
|
121
|
+
return [...array.matchAll(/<string>([\s\S]*?)<\/string>/g)].map(match => unescape(match[1])).slice(0, 2);
|
|
122
|
+
}
|
|
123
|
+
const line = text.match(/^ExecStart=(.*)$/m)?.[1] ?? '';
|
|
124
|
+
return [...line.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map(match => match[1].replace(/\\(.)/g, '$1')).slice(0, 2);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function runAll(steps, run) {
|
|
128
|
+
for (const [command, args, { ignore = false } = {}] of steps) {
|
|
129
|
+
const result = await run(command, args);
|
|
130
|
+
if (result.code !== 0 && !ignore) throw new Error(`${command} ${args.join(' ')} failed: ${(result.stderr || result.stdout).trim() || `exit ${result.code}`}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Writes the definition and loads it, which starts alpd. */
|
|
135
|
+
export async function installService(service, options, run = runCommand) {
|
|
136
|
+
await mkdir(path.dirname(service.file), { recursive: true });
|
|
137
|
+
await mkdir(path.join(path.resolve(options.home), 'logs'), { recursive: true, mode: 0o700 });
|
|
138
|
+
await writeFile(service.file, serviceDefinition(service, options), { mode: 0o644 });
|
|
139
|
+
await runAll(service.install, run);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Starts an installed service's alpd. */
|
|
143
|
+
export async function startService(service, run = runCommand) {
|
|
144
|
+
await runAll(service.start, run);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Unloads the service, which stops its alpd, and removes the definition; false when none was installed. */
|
|
148
|
+
export async function uninstallService(service, run = runCommand) {
|
|
149
|
+
if (await installedProgram(service) === undefined) return false;
|
|
150
|
+
await runAll(service.uninstall, run);
|
|
151
|
+
await unlink(service.file).catch(error => { if (error.code !== 'ENOENT') throw error; });
|
|
152
|
+
await runAll(service.afterUninstall ?? [], run);
|
|
153
|
+
return true;
|
|
154
|
+
}
|
package/src/setup.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { copyFile, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { initProject } from '@anhlp/alp-core/init';
|
|
5
|
+
import { MODELS } from '@anhlp/alp-core/models';
|
|
6
|
+
import { fitEffort, modelEfforts } from '@anhlp/alp-core/profiles';
|
|
7
|
+
import { resolveTeam } from '@anhlp/alp-core/teams';
|
|
8
|
+
import { validateSettings } from '@anhlp/alp-core/validation';
|
|
9
|
+
import { Cancelled, select, text } from './prompt.js';
|
|
10
|
+
|
|
11
|
+
const PROVIDERS = ['claude', 'codex'];
|
|
12
|
+
const PROVIDER_LABEL = { claude: 'Claude', codex: 'Codex' };
|
|
13
|
+
/** Each seat's model and effort per provider. Main runs on the preferred provider's only; peers may run on any model of an enabled provider. */
|
|
14
|
+
const DEFAULTS = {
|
|
15
|
+
main: { claude: ['claude-opus-5-5', 'high'], codex: ['gpt-6.1-sol', 'high'] },
|
|
16
|
+
worker: { claude: ['claude-opus-5-5', 'high'], codex: ['gpt-6.1-sol', 'high'] },
|
|
17
|
+
supervisor: { claude: ['claude-sonnet-5', 'medium'], codex: ['gpt-6-luna', 'medium'] },
|
|
18
|
+
oracle: { claude: ['claude-fable-5-1', 'high'], codex: ['gpt-6-astra', 'high'] },
|
|
19
|
+
};
|
|
20
|
+
const FLAGS = { main: 'model', worker: 'model', supervisor: 'supervisor', oracle: 'oracle' };
|
|
21
|
+
const SEAT_LABEL = { main: 'main', worker: 'lead, reviewer', supervisor: 'supervisor', oracle: 'oracle' };
|
|
22
|
+
const ANY_EFFORT = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
23
|
+
|
|
24
|
+
/** Write Phở and Cafe's agent profiles into a project's .alp/settings.json, without installing software or calling models. */
|
|
25
|
+
export async function setup(args, home) {
|
|
26
|
+
const { values, positionals } = parseArgs({ args, allowPositionals: true, options: Object.fromEntries(
|
|
27
|
+
['runtime', 'prefer', ...PROVIDERS.flatMap(provider => Object.values(FLAGS).map(flag => `${provider}-${flag}`))].map(name => [name, { type: 'string' }]),
|
|
28
|
+
) });
|
|
29
|
+
if (positionals.length > 1) throw new Error('setup takes at most one directory');
|
|
30
|
+
const root = path.resolve(positionals[0] ?? process.cwd());
|
|
31
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
32
|
+
try {
|
|
33
|
+
const runtime = values.runtime ?? (interactive ? await select({ message: 'Runtime', initial: 'mix', choices: [
|
|
34
|
+
{ label: 'Mix', value: 'mix', hint: 'Claude and Codex: main runs on one, the others can run on either' },
|
|
35
|
+
{ label: 'Codex only', value: 'codex' },
|
|
36
|
+
{ label: 'Claude only', value: 'claude' },
|
|
37
|
+
] }) : undefined);
|
|
38
|
+
if (runtime === undefined) throw new Error('Pass --runtime codex, claude or mix when setup is not running in a terminal');
|
|
39
|
+
if (!['codex', 'claude', 'mix'].includes(runtime)) throw new Error('--runtime must be codex, claude or mix');
|
|
40
|
+
const prefer = values.prefer ?? (runtime !== 'mix' ? runtime : interactive ? await select({ message: 'Main runs on', initial: 'claude', choices: [
|
|
41
|
+
{ label: 'Claude', value: 'claude', hint: 'Claude first for the others too' },
|
|
42
|
+
{ label: 'Codex', value: 'codex', hint: 'Codex first for the others too' },
|
|
43
|
+
] }) : 'claude');
|
|
44
|
+
if (!PROVIDERS.includes(prefer) || (runtime !== 'mix' && prefer !== runtime)) throw new Error('--prefer must be an enabled runtime');
|
|
45
|
+
// The preferred provider comes first: it is each agent's default, the other one a choice.
|
|
46
|
+
const enabled = runtime === 'mix' ? [prefer, ...PROVIDERS.filter(provider => provider !== prefer)] : [runtime];
|
|
47
|
+
for (const provider of PROVIDERS) {
|
|
48
|
+
for (const flag of Object.values(FLAGS)) {
|
|
49
|
+
if (!enabled.includes(provider) && values[`${provider}-${flag}`]) throw new Error(`--${provider}-${flag} is unavailable in ${runtime}-only mode`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** @type {Record<string, Array<{ provider: string, model: string, effort?: string }>>} */
|
|
53
|
+
const seats = {};
|
|
54
|
+
for (const [seat, flag] of Object.entries(FLAGS)) {
|
|
55
|
+
seats[seat] = (seat === 'main' ? [prefer] : enabled).map(provider => {
|
|
56
|
+
const option = `${provider}-${flag}`;
|
|
57
|
+
const [fallback, effort] = DEFAULTS[seat][provider];
|
|
58
|
+
const model = nativeModel(values[option] ?? fallback, provider, `--${option}`);
|
|
59
|
+
return { provider, model, effort: fitEffort(`${provider}:${model}`, effort) };
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
const peers = enabled.map(provider => `${provider}:*`);
|
|
63
|
+
|
|
64
|
+
const file = path.join(root, '.alp/settings.json');
|
|
65
|
+
const settings = await readFile(file, 'utf8').then(content => {
|
|
66
|
+
try { return JSON.parse(content); } catch (error) { throw new Error(`${file} is not valid JSON: ${error.message}`); }
|
|
67
|
+
}, error => {
|
|
68
|
+
if (error.code === 'ENOENT') return undefined;
|
|
69
|
+
throw new Error(`${file}: ${error.message}`);
|
|
70
|
+
});
|
|
71
|
+
const show = () => {
|
|
72
|
+
for (const [seat, choices] of Object.entries(seats)) console.log(` ${SEAT_LABEL[seat]}: ${choices.map(describe).join(', ')}`);
|
|
73
|
+
console.log(` peer: ${peers.join(', ')} (any model: whoever assigns a peer picks its model and effort for the task)`);
|
|
74
|
+
};
|
|
75
|
+
console.log(`Profiles for Phở and Cafe in ${file}:`);
|
|
76
|
+
show();
|
|
77
|
+
const replaced = ['pho', 'cafe'].filter(id => (settings?.team ?? settings?.profile)?.[id]);
|
|
78
|
+
if (replaced.length) console.log(`These replace the ${replaced.join(' and ')} profiles already in the file; a backup is kept in ${file}.bak.`);
|
|
79
|
+
if (interactive) {
|
|
80
|
+
for (;;) {
|
|
81
|
+
const next = await select({ message: 'Save these profiles?', initial: 'save', choices: [
|
|
82
|
+
{ label: 'Save', value: 'save' },
|
|
83
|
+
{ label: 'Change a model', value: 'change' },
|
|
84
|
+
{ label: 'Cancel', value: 'cancel' },
|
|
85
|
+
] });
|
|
86
|
+
if (next === 'cancel') throw new Cancelled();
|
|
87
|
+
if (next === 'save') break;
|
|
88
|
+
await change(seats);
|
|
89
|
+
show();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const profile = await profiles(root, home, seats, peers, settings);
|
|
93
|
+
validateSettings({ ...(settings ?? {}), team: { ...(settings?.profile ?? {}), ...(settings?.team ?? {}), ...profile } }, file);
|
|
94
|
+
if (!settings) await initProject(root, { library: home });
|
|
95
|
+
else if (replaced.length) await copyFile(file, `${file}.bak`);
|
|
96
|
+
const written = settings ?? JSON.parse(await readFile(file, 'utf8'));
|
|
97
|
+
const next = { ...written, team: { ...(written.profile ?? {}), ...(written.team ?? {}), ...profile } };
|
|
98
|
+
delete next.profile;
|
|
99
|
+
await writeFile(file, JSON.stringify(next, null, 2) + '\n');
|
|
100
|
+
console.log('Setup saved. New sessions in this project use these profiles; edit the file to change one agent. Run alp doctor to check that each CLI is installed and logged in.');
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (!(error instanceof Cancelled)) throw error;
|
|
103
|
+
console.log(error.message);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const describe = choice => `${choice.provider}:${choice.model}${choice.effort ? `:${choice.effort}` : ''}`;
|
|
108
|
+
|
|
109
|
+
/** Pick one seat and provider, then its model and effort. */
|
|
110
|
+
async function change(seats) {
|
|
111
|
+
const [seat, at] = await select({ message: 'Which model?', choices: Object.entries(seats).flatMap(([seat, choices]) =>
|
|
112
|
+
choices.map((choice, at) => ({ label: `${SEAT_LABEL[seat]} · ${PROVIDER_LABEL[choice.provider]}`, value: [seat, at], hint: describe(choice) }))) });
|
|
113
|
+
const current = seats[seat][at];
|
|
114
|
+
const offered = MODELS.filter(model => model.runtime === current.provider);
|
|
115
|
+
const picked = await select({ message: `${SEAT_LABEL[seat]} on ${PROVIDER_LABEL[current.provider]}`, initial: current.model, choices: [
|
|
116
|
+
...offered.map(model => ({ label: model.label, value: model.id, hint: `${model.id} · ${model.description}` })),
|
|
117
|
+
{ label: 'Other…', value: undefined, hint: 'type a model ID' },
|
|
118
|
+
] });
|
|
119
|
+
const model = picked ?? nativeModel(await text(`${PROVIDER_LABEL[current.provider]} model ID:`), current.provider, 'The model ID');
|
|
120
|
+
const efforts = modelEfforts(`${current.provider}:${model}`) ?? ANY_EFFORT;
|
|
121
|
+
const effort = efforts.length > 1 ? await select({ message: 'Effort', initial: efforts.includes(current.effort) ? current.effort : DEFAULTS[seat][current.provider][1],
|
|
122
|
+
choices: efforts.map(effort => ({ label: effort, value: effort })) }) : efforts[0];
|
|
123
|
+
seats[seat][at] = { provider: current.provider, model, ...(effort ? { effort } : {}) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Every Phở and Cafe agent's profiles, keeping each agent's own compaction setting. */
|
|
127
|
+
async function profiles(root, home, seats, peers, settings) {
|
|
128
|
+
const profile = {};
|
|
129
|
+
for (const id of ['pho', 'cafe']) {
|
|
130
|
+
const team = await resolveTeam(root, id, { library: home });
|
|
131
|
+
const agents = {};
|
|
132
|
+
const seat = (name, choices) => {
|
|
133
|
+
agents[name] = { profiles: choices.map(choice => typeof choice === 'string' ? choice : describe(choice)), autocompact: (settings?.team ?? settings?.profile)?.[id]?.[name]?.autocompact ?? 'auto' };
|
|
134
|
+
};
|
|
135
|
+
for (const [name, member] of Object.entries(team.members)) {
|
|
136
|
+
if (name !== 'oracle') seat(name, name === team.main ? seats.main : member.role === 'peer' ? peers : seats.worker);
|
|
137
|
+
}
|
|
138
|
+
if (team.supervisor) seat(team.supervisor.agent, seats.supervisor);
|
|
139
|
+
if (team.members.oracle) seat('oracle', seats.oracle);
|
|
140
|
+
profile[id] = agents;
|
|
141
|
+
}
|
|
142
|
+
return profile;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A model for one provider: a native ID, or one prefixed with that provider. */
|
|
146
|
+
function nativeModel(id, provider, where) {
|
|
147
|
+
const model = id.startsWith(`${provider}:`) ? id.slice(provider.length + 1) : id;
|
|
148
|
+
if (!model || /\s/.test(model) || /^(codex|claude):/.test(model) || (model.includes(':') && !id.startsWith(`${provider}:`))) throw new Error(`${where} expects a ${provider} model, got '${id}'`);
|
|
149
|
+
return model;
|
|
150
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function holdDaemon(home: string, by?: string): Promise<void>;
|
|
2
|
+
export function releaseHold(home: string): Promise<void>;
|
|
3
|
+
export function daemonHeld(home: string): Promise<boolean>;
|
|
4
|
+
export function startDaemon(options: { home: string; entry?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }): Promise<string>;
|
|
5
|
+
export function superviseDaemon(options: {
|
|
6
|
+
home: string;
|
|
7
|
+
entry?: string | (() => string | undefined);
|
|
8
|
+
env?: NodeJS.ProcessEnv;
|
|
9
|
+
intervalMs?: number;
|
|
10
|
+
misses?: number;
|
|
11
|
+
log?: (message: string) => void;
|
|
12
|
+
}): (() => Promise<void>) & { started: Promise<void> };
|
package/src/supervise.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { mkdir, unlink, writeFile, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ensureDaemon, lockAlive, readLock } from './index.js';
|
|
4
|
+
import { installedProgram, serviceFor, startService } from './service.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Keeping alpd up (ALPD §40). Whoever starts alpd goes through its login service when
|
|
8
|
+
* one is installed, so the service keeps managing it; otherwise alpd starts detached.
|
|
9
|
+
* A deliberate `alp daemon stop` leaves a hold, which watchers respect: they start
|
|
10
|
+
* alpd again only when someone asks for it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const holdFile = home => path.join(home, 'state', 'alpd.held');
|
|
14
|
+
|
|
15
|
+
/** Records that the user stopped alpd on purpose. */
|
|
16
|
+
export async function holdDaemon(home, by = 'alp daemon stop') {
|
|
17
|
+
await mkdir(path.dirname(holdFile(home)), { recursive: true, mode: 0o700 });
|
|
18
|
+
await writeFile(holdFile(home), JSON.stringify({ by, at: new Date().toISOString() }) + '\n', { mode: 0o600 });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function releaseHold(home) {
|
|
22
|
+
await unlink(holdFile(home)).catch(() => {});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function daemonHeld(home) {
|
|
26
|
+
return stat(holdFile(home)).then(() => true, () => false);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Polls until an alpd for `home` is ready; returns its socket. */
|
|
30
|
+
async function whenReady(home, timeoutMs) {
|
|
31
|
+
const deadline = Date.now() + timeoutMs;
|
|
32
|
+
while (Date.now() < deadline) {
|
|
33
|
+
const lock = await readLock(home);
|
|
34
|
+
if (lockAlive(lock) && lock.ready) return lock.socket;
|
|
35
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
36
|
+
}
|
|
37
|
+
throw new Error(`alpd did not become ready within ${timeoutMs / 1000} s; see ${path.join(home, 'logs')}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Starts alpd for `home` unless it runs, and lifts a hold: someone asked for it.
|
|
42
|
+
* Returns its socket.
|
|
43
|
+
*/
|
|
44
|
+
export async function startDaemon({ home, entry, env = process.env, timeoutMs = 15_000 }) {
|
|
45
|
+
await releaseHold(home);
|
|
46
|
+
const lock = await readLock(home);
|
|
47
|
+
if (lockAlive(lock)) return lock.ready ? lock.socket : whenReady(home, timeoutMs);
|
|
48
|
+
const service = serviceFor({ home, env });
|
|
49
|
+
if (service && await installedProgram(service)) {
|
|
50
|
+
await startService(service);
|
|
51
|
+
return whenReady(home, timeoutMs);
|
|
52
|
+
}
|
|
53
|
+
return ensureDaemon({ home, entry, env, timeoutMs });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Starts alpd now and starts it again whenever it is found down on `misses` checks in a
|
|
58
|
+
* row, `intervalMs` apart, unless the user holds it. Two misses let `alp daemon restart`
|
|
59
|
+
* and `install` finish their own stop and start first. Returns a function that stops
|
|
60
|
+
* watching, and resolves once a start it began has settled; alpd keeps running.
|
|
61
|
+
*/
|
|
62
|
+
export function superviseDaemon({ home, entry, env = process.env, intervalMs = 5_000, misses = 2, log = () => {} }) {
|
|
63
|
+
let stopped = false;
|
|
64
|
+
let missed = 0;
|
|
65
|
+
let starting;
|
|
66
|
+
const start = reason => {
|
|
67
|
+
starting ??= startDaemon({ home, entry: typeof entry === 'function' ? entry() : entry, env })
|
|
68
|
+
.then(() => log(`alpd ${reason}`), error => log(`could not start alpd: ${error?.message ?? error}`))
|
|
69
|
+
.finally(() => { starting = undefined; missed = 0; });
|
|
70
|
+
return starting;
|
|
71
|
+
};
|
|
72
|
+
const check = async () => {
|
|
73
|
+
if (stopped || starting) return;
|
|
74
|
+
if (lockAlive(await readLock(home))) { missed = 0; return; }
|
|
75
|
+
if (await daemonHeld(home)) { missed = 0; return; }
|
|
76
|
+
// Stopped while this check looked: start nothing.
|
|
77
|
+
if (++missed >= misses && !stopped) await start('was down; started it again');
|
|
78
|
+
};
|
|
79
|
+
const initial = start('started with Paseo');
|
|
80
|
+
const timer = setInterval(() => { void check(); }, intervalMs);
|
|
81
|
+
timer.unref?.();
|
|
82
|
+
const stop = () => { stopped = true; clearInterval(timer); return starting ?? Promise.resolve(); };
|
|
83
|
+
stop.started = initial;
|
|
84
|
+
return stop;
|
|
85
|
+
}
|