@anhlp/alp-runtime 0.0.0-beta.1
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 +202 -0
- package/NOTICE +7 -0
- package/README.md +7 -0
- package/dist/acp-transport.d.ts +114 -0
- package/dist/acp-transport.js +478 -0
- package/dist/antigravity-transport.d.ts +80 -0
- package/dist/antigravity-transport.js +609 -0
- package/dist/board-domain.d.ts +56 -0
- package/dist/board-domain.js +157 -0
- package/dist/board.d.ts +21 -0
- package/dist/board.js +69 -0
- package/dist/catalog.d.ts +31 -0
- package/dist/catalog.js +38 -0
- package/dist/claude-transport.d.ts +157 -0
- package/dist/claude-transport.js +671 -0
- package/dist/contracts.d.ts +163 -0
- package/dist/contracts.js +5 -0
- package/dist/events.d.ts +1 -0
- package/dist/events.js +0 -0
- package/dist/github.d.ts +10 -0
- package/dist/github.js +39 -0
- package/dist/handoff.d.ts +36 -0
- package/dist/handoff.js +73 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +64 -0
- package/dist/language.d.ts +41 -0
- package/dist/language.js +68 -0
- package/dist/live.d.ts +48 -0
- package/dist/live.js +45 -0
- package/dist/mailbox.d.ts +24 -0
- package/dist/mailbox.js +36 -0
- package/dist/mcp-tools.d.ts +115 -0
- package/dist/mcp-tools.js +71 -0
- package/dist/permission-path.d.ts +2 -0
- package/dist/permission-path.js +44 -0
- package/dist/process-info.d.ts +14 -0
- package/dist/process-info.js +32 -0
- package/dist/providers.d.ts +28 -0
- package/dist/providers.js +71 -0
- package/dist/recall.d.ts +42 -0
- package/dist/recall.js +60 -0
- package/dist/resolve.d.ts +121 -0
- package/dist/resolve.js +238 -0
- package/dist/runtime-context.d.ts +43 -0
- package/dist/runtime-context.js +61 -0
- package/dist/runtime.d.ts +4 -0
- package/dist/runtime.js +2979 -0
- package/dist/session-config.d.ts +1973 -0
- package/dist/session-config.js +205 -0
- package/dist/session-state.d.ts +140 -0
- package/dist/session-state.js +0 -0
- package/dist/tool-results.d.ts +12 -0
- package/dist/tool-results.js +25 -0
- package/dist/tools.d.ts +674 -0
- package/dist/tools.js +375 -0
- package/dist/transport.d.ts +47 -0
- package/dist/transport.js +169 -0
- package/dist/usage.d.ts +15 -0
- package/dist/usage.js +35 -0
- package/dist/workspace.d.ts +101 -0
- package/dist/workspace.js +259 -0
- package/dist/worktree-domain.d.ts +88 -0
- package/dist/worktree-domain.js +196 -0
- package/package.json +256 -0
- package/src/acp-transport.ts +473 -0
- package/src/antigravity-transport.ts +658 -0
- package/src/board-domain.ts +185 -0
- package/src/board.ts +80 -0
- package/src/catalog.ts +32 -0
- package/src/claude-transport.ts +771 -0
- package/src/contracts.ts +166 -0
- package/src/events.ts +1 -0
- package/src/github.ts +37 -0
- package/src/handoff.ts +86 -0
- package/src/index.ts +26 -0
- package/src/language.ts +102 -0
- package/src/live.ts +86 -0
- package/src/mailbox.ts +46 -0
- package/src/mcp-tools.ts +73 -0
- package/src/permission-path.ts +37 -0
- package/src/process-info.ts +40 -0
- package/src/providers.ts +84 -0
- package/src/recall.ts +85 -0
- package/src/resolve.ts +230 -0
- package/src/runtime-context.ts +49 -0
- package/src/runtime.ts +3579 -0
- package/src/session-config.ts +301 -0
- package/src/session-state.ts +141 -0
- package/src/tool-results.ts +26 -0
- package/src/tools.ts +379 -0
- package/src/transport.ts +116 -0
- package/src/usage.ts +42 -0
- package/src/workspace.ts +318 -0
- package/src/worktree-domain.ts +216 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { lstatSync, realpathSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
|
|
5
|
+
/** Resolve each component before '..', matching filesystem traversal through links.
|
|
6
|
+
* Missing leaves are allowed for creation; dangling links and other errors fail closed. */
|
|
7
|
+
function canonical(file: string): string {
|
|
8
|
+
const absolute = path.isAbsolute(file) ? file : `${process.cwd()}${path.sep}${file}`;
|
|
9
|
+
let current = path.parse(absolute).root;
|
|
10
|
+
for (const part of absolute.slice(current.length).split(process.platform === 'win32' ? /[\\/]/ : /\//)) {
|
|
11
|
+
if (!part || part === '.') continue;
|
|
12
|
+
if (part === '..') { current = path.dirname(current); continue; }
|
|
13
|
+
const candidate = path.join(current, part);
|
|
14
|
+
try { current = realpathSync.native(candidate); }
|
|
15
|
+
catch (error: any) {
|
|
16
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
17
|
+
try {
|
|
18
|
+
if (lstatSync(candidate).isSymbolicLink()) throw new Error('Dangling symlink');
|
|
19
|
+
} catch (infoError: any) { if (infoError?.code !== 'ENOENT') throw infoError; }
|
|
20
|
+
current = candidate;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return current;
|
|
24
|
+
}
|
|
25
|
+
const contains = (root: string, file: string) => {
|
|
26
|
+
const relative = path.relative(root, file);
|
|
27
|
+
return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** ALP's permission gate, not an OS sandbox or protection against concurrent link swaps. */
|
|
31
|
+
export function writablePath(file: unknown, workdir: string): boolean {
|
|
32
|
+
if (typeof file !== 'string' || !file.trim() || file.includes('\0')) return false;
|
|
33
|
+
try {
|
|
34
|
+
const target = canonical(path.isAbsolute(file) ? file : `${workdir}${path.sep}${file}`);
|
|
35
|
+
return contains(canonical(workdir), target) || contains(canonical(os.tmpdir()), target);
|
|
36
|
+
} catch { return false; }
|
|
37
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Telling a process from a later one that reuses its pid (ALPD §32): a pid alone
|
|
5
|
+
* can name an unrelated process once the first one is gone, so ALP also records
|
|
6
|
+
* when the process started, and compares that.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** When this process started, in milliseconds since the epoch. */
|
|
10
|
+
export const OWN_START = Math.round(Date.now() - process.uptime() * 1000);
|
|
11
|
+
|
|
12
|
+
/** ps reports start times to the second; PowerShell's are exact. */
|
|
13
|
+
const START_TOLERANCE_MS = 2000;
|
|
14
|
+
|
|
15
|
+
/** When process `pid` started, from ps (PowerShell on Windows); undefined where they cannot tell. */
|
|
16
|
+
export function processStartedAt(pid: number): number | undefined {
|
|
17
|
+
try {
|
|
18
|
+
if (process.platform === 'win32') {
|
|
19
|
+
// No ps: ask PowerShell (about 200 ms). A process of another user hides its start time, which reads as unknown.
|
|
20
|
+
const out = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `[DateTimeOffset]::new((Get-Process -Id ${Math.trunc(pid)}).StartTime).ToUnixTimeMilliseconds()`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, windowsHide: true }).trim();
|
|
21
|
+
return /^\d+$/.test(out) ? Number(out) : undefined;
|
|
22
|
+
}
|
|
23
|
+
const started = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000, env: { ...process.env, LC_ALL: 'C' } }).trim();
|
|
24
|
+
const at = Date.parse(started);
|
|
25
|
+
return Number.isNaN(at) ? undefined : at;
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether the process that started at `startedAt` as `pid` still runs. Without a
|
|
33
|
+
* start time, or where ps cannot tell, a live pid counts as that process.
|
|
34
|
+
*/
|
|
35
|
+
export function sameProcessAlive(pid: number, startedAt?: number) {
|
|
36
|
+
try { process.kill(pid, 0); } catch (error: any) { if (error?.code !== 'EPERM') return false; }
|
|
37
|
+
if (startedAt === undefined) return true;
|
|
38
|
+
const actual = processStartedAt(pid);
|
|
39
|
+
return actual === undefined || Math.abs(actual - startedAt) < START_TOLERANCE_MS;
|
|
40
|
+
}
|
package/src/providers.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { CodexTransport } from './transport.js';
|
|
2
|
+
import { ClaudeTransport } from './claude-transport.js';
|
|
3
|
+
import { AcpTransport, type AcpProvider } from './acp-transport.js';
|
|
4
|
+
import { AntigravityTransport, type AntigravityProvider } from './antigravity-transport.js';
|
|
5
|
+
import type { RuntimeKind } from './resolve.js';
|
|
6
|
+
import type { RuntimeOptions, RuntimeTransport } from './contracts.js';
|
|
7
|
+
|
|
8
|
+
/** The environment a native harness starts with: the host's, without variables that would tie it to the host's own agent session. */
|
|
9
|
+
export function nativeEnvironment(options: RuntimeOptions, extra: Record<string, string> = {}) {
|
|
10
|
+
const environment: NodeJS.ProcessEnv = { ...(options.environment ?? process.env), ...extra };
|
|
11
|
+
for (const key of Object.keys(environment)) {
|
|
12
|
+
// Markers of an enclosing Paseo, Codex or Claude Code session would make the native harness think it is nested.
|
|
13
|
+
if (/^(PASEO_|CODEX_THREAD_ID$|CODEX_INTERNAL_|CODEX_PARENT_|CLAUDECODE$|CLAUDE_CODE_|ANTHROPIC_AGENT_)/.test(key)) delete environment[key];
|
|
14
|
+
}
|
|
15
|
+
return environment;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Provider factories adapt native harnesses to ALP's normalized transport contract. */
|
|
19
|
+
export type ProviderContext = {
|
|
20
|
+
options: RuntimeOptions;
|
|
21
|
+
runtime: RuntimeKind;
|
|
22
|
+
cwd: string;
|
|
23
|
+
environment: NodeJS.ProcessEnv;
|
|
24
|
+
acp?: AcpProvider;
|
|
25
|
+
antigravity?: AntigravityProvider;
|
|
26
|
+
};
|
|
27
|
+
export type ProviderFactory = (context: ProviderContext) => RuntimeTransport;
|
|
28
|
+
export type ProviderFactories = Partial<Record<RuntimeKind, ProviderFactory>>;
|
|
29
|
+
|
|
30
|
+
/** Explicit registry: each runtime instance can override adapters without global state. */
|
|
31
|
+
export class ProviderRegistry {
|
|
32
|
+
private readonly factories = new Map<string, ProviderFactory>();
|
|
33
|
+
|
|
34
|
+
constructor(factories: Record<string, ProviderFactory | undefined> = {}) {
|
|
35
|
+
for (const [id, factory] of Object.entries(factories)) if (factory) this.register(id, factory);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
register(id: string, factory: ProviderFactory): this {
|
|
39
|
+
if (!id.trim()) throw new Error('A provider needs an id');
|
|
40
|
+
this.factories.set(id, factory);
|
|
41
|
+
return this;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
create(id: string, context: ProviderContext): RuntimeTransport {
|
|
45
|
+
const factory = this.factories.get(id);
|
|
46
|
+
if (!factory) throw new Error(`Unknown runtime provider: ${id}`);
|
|
47
|
+
return factory(context);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
has(id: string): boolean { return this.factories.has(id); }
|
|
51
|
+
list(): string[] { return [...this.factories.keys()]; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createProviderRegistry(overrides: ProviderFactories = {}): ProviderRegistry {
|
|
55
|
+
return new ProviderRegistry({
|
|
56
|
+
codex: ({ options, cwd, environment }) => new CodexTransport(
|
|
57
|
+
options.codexCommand ?? process.env.ALP_CODEX_BIN ?? 'codex', cwd, environment),
|
|
58
|
+
claude: ({ options, cwd, environment }) => new ClaudeTransport(
|
|
59
|
+
options.claudeCommand ?? process.env.ALP_CLAUDE_BIN ?? 'claude', cwd, environment),
|
|
60
|
+
acp: ({ acp, cwd, environment }) => {
|
|
61
|
+
if (!acp) throw new Error('An ACP session needs its provider');
|
|
62
|
+
return new AcpTransport(acp, cwd, environment);
|
|
63
|
+
},
|
|
64
|
+
antigravity: ({ options, antigravity, cwd, environment }) => new AntigravityTransport(
|
|
65
|
+
options.antigravityCommand ?? process.env.ALP_ANTIGRAVITY_BIN ?? antigravity?.command ?? 'agy',
|
|
66
|
+
cwd, environment, antigravity),
|
|
67
|
+
...Object.fromEntries(Object.entries(overrides).filter(([, factory]) => factory !== undefined)),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createTransport(
|
|
72
|
+
options: RuntimeOptions,
|
|
73
|
+
runtimeKind: RuntimeKind,
|
|
74
|
+
cwd: string,
|
|
75
|
+
environment: NodeJS.ProcessEnv,
|
|
76
|
+
acp?: AcpProvider,
|
|
77
|
+
antigravity?: AntigravityProvider,
|
|
78
|
+
): RuntimeTransport {
|
|
79
|
+
// Preserve the existing test/custom host hook and its positional argument contract.
|
|
80
|
+
if (options.transport) return options.transport(cwd, environment, runtimeKind, acp, antigravity);
|
|
81
|
+
const registry = options.providerRegistry ?? createProviderRegistry(options.providers);
|
|
82
|
+
const providerId = runtimeKind === 'acp' && acp && registry.has(`acp:${acp.id}`) ? `acp:${acp.id}` : runtimeKind;
|
|
83
|
+
return registry.create(providerId, { options, runtime: runtimeKind, cwd, environment, acp, antigravity });
|
|
84
|
+
}
|
package/src/recall.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import type { RuntimeKind } from './resolve.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Finished assignments whose native threads alp_recall can question (ALPD §27).
|
|
8
|
+
* The threads stay on disk for RECALL_KEEP_MS; then ALP deletes them.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type RecallEntry = {
|
|
12
|
+
assignmentId: string;
|
|
13
|
+
rootId: string;
|
|
14
|
+
/** The requester and its requesters, nearest first: who may recall it besides a root of the project. */
|
|
15
|
+
requesters: string[];
|
|
16
|
+
agent: string;
|
|
17
|
+
project: string;
|
|
18
|
+
runtime: RuntimeKind;
|
|
19
|
+
threadId: string;
|
|
20
|
+
/** Where it worked: the project, or a worktree or copy that may be gone by now. */
|
|
21
|
+
cwd: string;
|
|
22
|
+
model: string;
|
|
23
|
+
thinking?: string;
|
|
24
|
+
taskId?: string;
|
|
25
|
+
status: string;
|
|
26
|
+
finishedAt: string;
|
|
27
|
+
/** Shared work remains in cwd; a new assignment can build on this brief and handoff. */
|
|
28
|
+
shared?: { brief: string; summary: string; writeRoot?: string };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const RECALL_KEEP_MS = 14 * 24 * 60 * 60 * 1000;
|
|
32
|
+
export const RECALL_MAX = 1000;
|
|
33
|
+
export const RECALL_QUESTION_CHARS = 4000;
|
|
34
|
+
export const RECALL_TIMEOUT_MS = 300_000;
|
|
35
|
+
|
|
36
|
+
const valid = (entry: any): entry is RecallEntry => !!entry && typeof entry === 'object' &&
|
|
37
|
+
['assignmentId', 'rootId', 'agent', 'project', 'runtime', 'threadId', 'cwd', 'model', 'status', 'finishedAt'].every(key => typeof entry[key] === 'string') &&
|
|
38
|
+
Array.isArray(entry.requesters);
|
|
39
|
+
|
|
40
|
+
/** The record of recallable threads, kept in `file` when given, else in memory. */
|
|
41
|
+
export function createRecallBook(file?: string) {
|
|
42
|
+
let entries: Promise<RecallEntry[]> | undefined;
|
|
43
|
+
let writes = Promise.resolve();
|
|
44
|
+
const load = () => entries ??= file
|
|
45
|
+
? readFile(file, 'utf8').then(text => (JSON.parse(text) as unknown[]).filter(valid), () => [] as RecallEntry[])
|
|
46
|
+
: Promise.resolve([]);
|
|
47
|
+
const save = (list: RecallEntry[]) => {
|
|
48
|
+
if (!file) return writes;
|
|
49
|
+
const body = JSON.stringify(list, null, 2) + '\n';
|
|
50
|
+
writes = writes.then(async () => {
|
|
51
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
52
|
+
const temporary = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
53
|
+
await writeFile(temporary, body, { mode: 0o600 });
|
|
54
|
+
await rename(temporary, file);
|
|
55
|
+
}).catch(() => {});
|
|
56
|
+
return writes;
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
async all() { return [...await load()]; },
|
|
60
|
+
async add(entry: RecallEntry) {
|
|
61
|
+
const list = await load();
|
|
62
|
+
list.push(entry);
|
|
63
|
+
await save(list);
|
|
64
|
+
},
|
|
65
|
+
/** Removes and returns the entries past their time, and the oldest beyond RECALL_MAX. */
|
|
66
|
+
async expire(now = Date.now()) {
|
|
67
|
+
const list = await load();
|
|
68
|
+
const kept = list.filter(entry => now - Date.parse(entry.finishedAt) < RECALL_KEEP_MS).slice(-RECALL_MAX);
|
|
69
|
+
if (kept.length === list.length) return [];
|
|
70
|
+
const gone = list.filter(entry => !kept.includes(entry));
|
|
71
|
+
list.splice(0, list.length, ...kept);
|
|
72
|
+
await save(list);
|
|
73
|
+
return gone;
|
|
74
|
+
},
|
|
75
|
+
flush: () => writes,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** What a recalled assignment is told before the question. */
|
|
80
|
+
export function recallPrompt(asker: string, question: string) {
|
|
81
|
+
return `ALP recall: ${asker} asks about the assignment you finished in this conversation. ` +
|
|
82
|
+
'Answer from what you did, saw and decided then; read files only to check a detail. ' +
|
|
83
|
+
'This is a read-only copy of your session: change nothing, and do not call ALP tools; they are unavailable. ' +
|
|
84
|
+
`Answer briefly and concretely.\n\nQuestion: ${question}`;
|
|
85
|
+
}
|
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { access, readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { resolveWorkflow } from '@anhlp/alp-core/workflow';
|
|
4
|
+
import { initProject } from '@anhlp/alp-core/init';
|
|
5
|
+
import { profileModel, agentProfiles, selectedProfile } from '@anhlp/alp-core/profiles';
|
|
6
|
+
import { jsonObject, resolveAgent } from '@anhlp/alp-core/resolver';
|
|
7
|
+
import { resolveTeam, teamOf } from '@anhlp/alp-core/teams';
|
|
8
|
+
import { loadHooks } from '@anhlp/alp-core/hooks';
|
|
9
|
+
import { acpModel, loadProvider } from '@anhlp/alp-core/providers';
|
|
10
|
+
import type { AcpProvider } from './acp-transport.js';
|
|
11
|
+
import type { AntigravityProvider } from './antigravity-transport.js';
|
|
12
|
+
import { ensureLibrary } from '@anhlp/alp-core/library';
|
|
13
|
+
import { compileAgent } from '@anhlp/alp-core/adapter';
|
|
14
|
+
import { capMode, profileFor } from '@anhlp/alp-core/permissions';
|
|
15
|
+
import { userLanguage } from '@anhlp/alp-core/user-settings';
|
|
16
|
+
import { claudeSandboxAvailable } from './claude-transport.js';
|
|
17
|
+
import type { ResolvedAgent } from '@anhlp/alp-core/types';
|
|
18
|
+
import type { AlpRuntimeAdapter } from '@anhlp/alp-core/adapter';
|
|
19
|
+
import { DEFAULT_ANTIGRAVITY_MODEL, DEFAULT_CLAUDE_MODEL, DEFAULT_MODEL, ORACLE_MODELS, ORACLE_THINKING, models, modes, thinkingOptions, thinkingOptionsFor } from './catalog.js';
|
|
20
|
+
|
|
21
|
+
/** Codex and Claude Code, or an ACP agent the user's library defines (ALPD §46). */
|
|
22
|
+
import type { RuntimeKind } from '@anhlp/alp-protocol/dto';
|
|
23
|
+
export type { RuntimeKind } from '@anhlp/alp-protocol/dto';
|
|
24
|
+
|
|
25
|
+
import type { HostMcpServer, SessionSpec } from '@anhlp/alp-protocol/session';
|
|
26
|
+
export type { HostMcpServer, SessionSpec } from '@anhlp/alp-protocol/session';
|
|
27
|
+
|
|
28
|
+
export type ResolvedSession = Awaited<ReturnType<typeof resolveSession>>;
|
|
29
|
+
|
|
30
|
+
async function exists(target: string) {
|
|
31
|
+
try {
|
|
32
|
+
await access(target);
|
|
33
|
+
return true;
|
|
34
|
+
} catch (error: any) {
|
|
35
|
+
if (error?.code === 'ENOENT') return false;
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** How much of a skill's description its listing carries. */
|
|
41
|
+
const SKILL_DESCRIPTION_CHARS = 400;
|
|
42
|
+
|
|
43
|
+
/** The description in a SKILL.md's frontmatter, on one line; empty when it has none. */
|
|
44
|
+
export async function skillDescription(file: string) {
|
|
45
|
+
const text = await readFile(file, 'utf8').catch(() => '');
|
|
46
|
+
const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1] ?? '';
|
|
47
|
+
const match = /^description:[ \t]*(.*(?:\r?\n[ \t]+.*)*)/m.exec(front)?.[1] ?? '';
|
|
48
|
+
const flat = match.replace(/^[>|][-+]?/, '').replace(/\s+/g, ' ').trim().replace(/^(['"])(.*)\1$/, '$2');
|
|
49
|
+
return flat.length > SKILL_DESCRIPTION_CHARS ? `${flat.slice(0, SKILL_DESCRIPTION_CHARS - 1)}…` : flat;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Composes project and agent instructions with the agent's skills (ALPD §59): each by
|
|
54
|
+
* name, file and description, so an agent knows which work a skill is for without
|
|
55
|
+
* opening it, and opens it before that work.
|
|
56
|
+
*/
|
|
57
|
+
export class InstructionsAdapter implements AlpRuntimeAdapter<{ instructions: string; mcp: ResolvedAgent['mcp']; runtime: ResolvedAgent['runtime'] }> {
|
|
58
|
+
id = 'native';
|
|
59
|
+
// ALP runs an agent's hooks itself, at its own events (ALPD §45).
|
|
60
|
+
capabilities() { return { instructions: 'emulated', skills: 'emulated', hooks: 'emulated', mcp: 'native' } as const; }
|
|
61
|
+
async compile(agent: ResolvedAgent) {
|
|
62
|
+
const listed = await Promise.all(agent.skills.map(async skill => {
|
|
63
|
+
const description = await skillDescription(skill.path);
|
|
64
|
+
return `- ${skill.name} (${skill.path})${description ? `: ${description}` : ''}`;
|
|
65
|
+
}));
|
|
66
|
+
const skills = listed.length
|
|
67
|
+
? 'Your skills: methods written for kinds of work. Before you start work that a skill\'s description matches, read its SKILL.md and follow it; say which skill you use.\n' + listed.join('\n')
|
|
68
|
+
: '';
|
|
69
|
+
return { adapterId: this.id, agentName: agent.name, projectRoot: agent.projectRoot, material: {
|
|
70
|
+
instructions: [agent.instructions.project, agent.instructions.agent, skills].filter(Boolean).join('\n\n'),
|
|
71
|
+
mcp: agent.mcp, runtime: agent.runtime,
|
|
72
|
+
} };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Lessons main recorded after supervisor reviews; the newest are kept when the file grows. */
|
|
77
|
+
export const LESSONS_FILE = 'lessons.md';
|
|
78
|
+
const LESSON_CHARS = 6000;
|
|
79
|
+
|
|
80
|
+
async function lessons(file: string) {
|
|
81
|
+
const text = (await readFile(file, 'utf8').catch(() => '')).trim();
|
|
82
|
+
return text.length > LESSON_CHARS ? `…\n${text.slice(-LESSON_CHARS)}` : text;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Agents that advise, review, or watch: they never write, and take no tasks. */
|
|
86
|
+
export const READ_ONLY_AGENTS = ['oracle', 'reviewer', 'supervisor'];
|
|
87
|
+
|
|
88
|
+
export async function resolveSession(spec: SessionSpec, options: { templates?: Record<string, string>; library?: string; language?: string; /** False for a preview: it writes nothing (D31). */ initialize?: boolean } = {}) {
|
|
89
|
+
if (!path.isAbsolute(spec.cwd) || !(await stat(spec.cwd)).isDirectory()) throw new Error('Session cwd must be an existing absolute directory');
|
|
90
|
+
if (spec.workdir !== undefined && (!path.isAbsolute(spec.workdir) || !(await stat(spec.workdir)).isDirectory())) throw new Error('Session workdir must be an existing absolute directory');
|
|
91
|
+
const starter = options.templates ? { templates: options.templates } : {};
|
|
92
|
+
if (options.initialize !== false) {
|
|
93
|
+
const settingsFile = path.join(spec.cwd, '.alp', 'settings.json');
|
|
94
|
+
const scaffoldMissing = !(await exists(path.join(spec.cwd, 'ALP.md'))) || !(await exists(settingsFile));
|
|
95
|
+
if (scaffoldMissing) await initProject(spec.cwd, { ...starter, library: options.library });
|
|
96
|
+
else if (!spec.restore) {
|
|
97
|
+
const settings = await jsonObject(settingsFile, 'INVALID_SETTINGS');
|
|
98
|
+
await initProject(spec.cwd, { ...starter, library: options.library, onlyManagedProfiles: true, teamIds: [teamOf(spec.workflow ?? settings.workflow?.mode ?? 'custom')] });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (options.library) await ensureLibrary(options.library, options.templates ? { templates: options.templates } : {});
|
|
102
|
+
const restored = spec.restore;
|
|
103
|
+
if (restored && spec.agent !== undefined && spec.agent !== restored.agent) throw new Error('Cannot resume a thread as a different ALP agent');
|
|
104
|
+
const { team, ...workflow } = await resolveWorkflow(spec.cwd, spec.workflow, restored?.workflow, { library: options.library, templates: options.templates });
|
|
105
|
+
const agent = await resolveAgent(spec.cwd, { agent: spec.agent ?? restored?.agent, library: options.library, templates: options.templates });
|
|
106
|
+
const oracleModels = team?.oracleModels ?? ORACLE_MODELS;
|
|
107
|
+
if (agent.name === 'oracle' && !restored && !oracleModels.includes(spec.model ?? '')) throw new Error(`Oracle runs on ${oracleModels.join(' or ')}; choose one`);
|
|
108
|
+
const projectSettings: import('@anhlp/alp-core/types').RawSettings = await jsonObject(path.join(spec.cwd, '.alp/settings.json'), 'INVALID_SETTINGS');
|
|
109
|
+
const profile = (projectSettings.team ?? projectSettings.profile)?.[workflow.mode]?.[agent.name];
|
|
110
|
+
const modelProfiles: Record<string, import('@anhlp/alp-core/types').AgentModelConfig[]> = Object.fromEntries(Object.entries((projectSettings.team ?? projectSettings.profile)?.[workflow.mode] ?? {}).map(([name, config]) => [name, agentProfiles(config, name)]));
|
|
111
|
+
const requestedModel = restored?.model ? `${restored.runtime ?? agent.runtime.provider ?? 'codex'}:${restored.model}` : spec.model;
|
|
112
|
+
const profileChoice = selectedProfile(modelProfiles[agent.name] ?? [], requestedModel);
|
|
113
|
+
const projectModel = profileModel(profileChoice);
|
|
114
|
+
const compiled = await compileAgent(new InstructionsAdapter(), agent);
|
|
115
|
+
// Order (ALPD §42): the caller, then settings.json's runtime, then the team's choice for this member, then the agent's own.
|
|
116
|
+
// A resumed session keeps its model; it keeps the team's effort while it still runs on the team's model.
|
|
117
|
+
// A custom graph has no team; its main runs as Phở's does, and follows Phở's house rules, as before teams.
|
|
118
|
+
const base = team ?? await resolveTeam(undefined, 'pho', { templates: options.templates });
|
|
119
|
+
const main = base.main;
|
|
120
|
+
const member = Object.hasOwn(base.members, agent.name) && (team || agent.name === main) ? base.members[agent.name] : undefined;
|
|
121
|
+
const sameModel = (id?: string) => id === undefined || restored?.model === undefined || `${restored.runtime}:${restored.model}` === id || restored.model === id;
|
|
122
|
+
const teamChoice = member && !agent.projectRuntime?.model && !agent.projectRuntime?.provider && spec.model === undefined && sameModel(member.model) ? member : undefined;
|
|
123
|
+
let runtimeKind = restored?.runtime ?? profileChoice?.provider ?? (teamChoice?.model ? undefined : agent.runtime.provider) ?? 'codex';
|
|
124
|
+
let model = restored?.model ?? spec.model ?? projectModel ?? teamChoice?.model ?? agent.runtime.model ?? (runtimeKind === 'claude' ? DEFAULT_CLAUDE_MODEL : DEFAULT_MODEL);
|
|
125
|
+
if (model.startsWith('codex:')) { runtimeKind = 'codex'; model = model.slice('codex:'.length); }
|
|
126
|
+
if (model.startsWith('claude:')) { runtimeKind = 'claude'; model = model.slice('claude:'.length); }
|
|
127
|
+
if (model.startsWith('codex/')) { runtimeKind = 'codex'; model = model.slice('codex/'.length); }
|
|
128
|
+
if (model.startsWith('claude/')) { runtimeKind = 'claude'; model = model.slice('claude/'.length); }
|
|
129
|
+
if (model.startsWith('antigravity:')) { runtimeKind = 'antigravity'; model = model.slice('antigravity:'.length); }
|
|
130
|
+
if (model.startsWith('antigravity/')) { runtimeKind = 'antigravity'; model = model.slice('antigravity/'.length); }
|
|
131
|
+
if (model.startsWith('acp:')) {
|
|
132
|
+
const raw = model.slice('acp:'.length);
|
|
133
|
+
if (raw.startsWith('antigravity/') || raw === 'antigravity') {
|
|
134
|
+
runtimeKind = 'antigravity';
|
|
135
|
+
model = raw.startsWith('antigravity/') ? raw.slice('antigravity/'.length) : '';
|
|
136
|
+
} else {
|
|
137
|
+
runtimeKind = 'acp';
|
|
138
|
+
model = raw;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (runtimeKind === 'antigravity') {
|
|
142
|
+
if (!model.trim() || model === DEFAULT_MODEL) model = DEFAULT_ANTIGRAVITY_MODEL;
|
|
143
|
+
}
|
|
144
|
+
// An agent.json provider other than builtins names an ACP provider of the library; its model is that provider's.
|
|
145
|
+
if (!['codex', 'claude', 'acp', 'antigravity'].includes(runtimeKind)) { model = model === DEFAULT_MODEL && agent.runtime.model !== DEFAULT_MODEL ? runtimeKind : `${runtimeKind}/${model}`; runtimeKind = 'acp'; }
|
|
146
|
+
if (restored?.runtime && runtimeKind !== restored.runtime) throw new Error('Cannot resume a thread with a different runtime provider');
|
|
147
|
+
let acp: AcpProvider | undefined;
|
|
148
|
+
if (runtimeKind === 'acp') {
|
|
149
|
+
const { provider: id, model: native } = acpModel(model);
|
|
150
|
+
acp = await loadProvider(options.library, id) as AcpProvider | undefined;
|
|
151
|
+
if (!acp) throw new Error(`No ACP provider '${id}': add one to your library with alp provider add ${id}, or name codex or claude`);
|
|
152
|
+
if (acp.enabled === false) throw new Error(`ACP provider '${id}' is turned off; turn it on in the web app's Providers settings or in providers/${id}.json`);
|
|
153
|
+
const listed = [...(acp.models ?? []), ...(acp.additionalModels ?? [])];
|
|
154
|
+
if (native && acp.models && !listed.some(candidate => candidate.id === native)) throw new Error(`ACP provider '${id}' lists no model '${native}'`);
|
|
155
|
+
if (spec.copy) throw new Error('A review copy cannot run on an ACP agent: ALP cannot keep its commands inside the copy');
|
|
156
|
+
}
|
|
157
|
+
let antigravity: AntigravityProvider | undefined;
|
|
158
|
+
if (runtimeKind === 'antigravity') {
|
|
159
|
+
const provider = await loadProvider(options.library, 'antigravity');
|
|
160
|
+
if (provider && provider.kind === 'antigravity') {
|
|
161
|
+
antigravity = provider as unknown as AntigravityProvider;
|
|
162
|
+
if (antigravity.enabled === false) throw new Error("Antigravity provider 'antigravity' is turned off; turn it on in the web app's Providers settings or in providers/antigravity.json");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Main has full access unless the caller limits it; a permission profile caps the mode at its base.
|
|
166
|
+
// agy runs natively with full access; read-only instructions require an explicit opt-in.
|
|
167
|
+
const permissions = await profileFor(agent.projectRoot, options.library, agent.name);
|
|
168
|
+
const requested = spec.mode ?? agent.mode ?? (agent.name === main ? 'full-access' : 'read-only');
|
|
169
|
+
const mode = permissions ? capMode(requested, permissions.base) : requested;
|
|
170
|
+
if (runtimeKind === 'antigravity') {
|
|
171
|
+
if (mode !== 'full-access' && !(mode === 'read-only' && antigravity?.readOnlyMode === 'instructions')) throw new Error('Antigravity currently supports only full-access; read-only requires explicit readOnlyMode: "instructions" and is not sandboxed');
|
|
172
|
+
if (spec.copy || permissions?.workdir === 'copy') throw new Error('Antigravity cannot enforce isolation for a review copy');
|
|
173
|
+
if (permissions && (permissions.deny.length || permissions.ask.length || permissions.allow.length || permissions.beyondMode === 'ask')) {
|
|
174
|
+
throw new Error('Antigravity cannot enforce custom permission rules');
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// An ACP or Antigravity agent chooses its own effort.
|
|
178
|
+
const availableThinking = ['acp', 'antigravity'].includes(runtimeKind) ? [] : thinkingOptionsFor(runtimeKind as 'codex' | 'claude' | 'antigravity', model);
|
|
179
|
+
const preferred = ['acp', 'antigravity'].includes(runtimeKind) ? 'none' : spec.thinking ?? profileChoice?.effort ?? agent.projectRuntime?.reasoning ?? (agent.name === 'oracle' ? team?.oracleThinking?.[`${runtimeKind}:${model}`] ?? team?.oracleThinking?.[runtimeKind] : undefined) ?? teamChoice?.thinking ?? agent.runtime.reasoning ??
|
|
180
|
+
(agent.name === 'oracle' ? ORACLE_THINKING : availableThinking.length ? 'medium' : 'none');
|
|
181
|
+
// A default the model does not take gives way to the model's own (GPT-6 Astra runs at low only); an explicit choice is checked below.
|
|
182
|
+
const thinking = spec.thinking === undefined && availableThinking.length && !availableThinking.some(candidate => candidate.id === preferred)
|
|
183
|
+
? models.find(candidate => candidate.id === `${runtimeKind}:${model}`)?.defaultThinkingOptionId ?? availableThinking[0].id
|
|
184
|
+
: preferred;
|
|
185
|
+
if (!model.trim()) throw new Error('Model must be nonempty');
|
|
186
|
+
if (!modes.some(m => m.id === mode)) throw new Error(`Unsupported mode '${mode}'`);
|
|
187
|
+
// Only Claude's Bash sandbox keeps a session in a copy from writing elsewhere.
|
|
188
|
+
if (spec.copy && runtimeKind === 'claude' && !claudeSandboxAvailable()) throw new Error('A review copy on Claude needs its Bash sandbox (Seatbelt on macOS; bubblewrap and socat on Linux)');
|
|
189
|
+
if (!thinkingOptions.some(m => m.id === thinking)) throw new Error(`Unsupported thinking option '${thinking}'`);
|
|
190
|
+
if (availableThinking.length && !availableThinking.some(candidate => candidate.id === thinking)) throw new Error(`Thinking option '${thinking}' is unsupported by ${runtimeKind}:${model}`);
|
|
191
|
+
const mcp: Record<string, unknown> = {};
|
|
192
|
+
for (const [name, value] of Object.entries(compiled.material.mcp.mcpServers) as [string, any][]) {
|
|
193
|
+
const { headers, ...rest } = value;
|
|
194
|
+
Object.defineProperty(mcp, name, { value: { ...rest, ...(headers ? { http_headers: headers } : {}) }, enumerable: true, writable: true });
|
|
195
|
+
}
|
|
196
|
+
for (const [name, value] of Object.entries(spec.mcpServers ?? {})) {
|
|
197
|
+
if (Object.hasOwn(mcp, name)) throw new Error(`MCP server name collision: '${name}'`);
|
|
198
|
+
if (value.type === 'sse') throw new Error(`SSE MCP server '${name}' is unsupported by this runtime`);
|
|
199
|
+
const server = value.type === 'stdio'
|
|
200
|
+
? { command: value.command, args: value.args ?? [], env: value.env ?? {} }
|
|
201
|
+
: { url: value.url, http_headers: value.headers ?? {} };
|
|
202
|
+
Object.defineProperty(mcp, name, { value: server, enumerable: true, writable: true });
|
|
203
|
+
}
|
|
204
|
+
// Main follows its lessons; the supervisor checks them.
|
|
205
|
+
let learned = '';
|
|
206
|
+
const supervisorAgent = team?.supervisor ? team.supervisor.agent : undefined;
|
|
207
|
+
if ([main, supervisorAgent].includes(agent.name) && workflow.supervisor) {
|
|
208
|
+
const user = options.library ? await lessons(path.join(options.library, LESSONS_FILE)) : '';
|
|
209
|
+
const project = await lessons(path.join(agent.projectRoot, '.alp', LESSONS_FILE));
|
|
210
|
+
if (user || project) {
|
|
211
|
+
learned = 'Lessons main recorded after earlier supervisor reviews. Follow them; they are the user\'s process, not a task.' +
|
|
212
|
+
(user ? `\n\nFor every project:\n${user}` : '') + (project ? `\n\nFor this project:\n${project}` : '');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const houseRules = base.houseRules;
|
|
216
|
+
const hooks = await loadHooks(agent);
|
|
217
|
+
return {
|
|
218
|
+
agent, workflow, team, oracleModels, modelProfiles, houseRules, hooks, runtimeKind: runtimeKind as RuntimeKind, model, ...(acp ? { acp } : {}), ...(antigravity ? { antigravity } : {}), mode, permissions, thinking, threadId: restored?.threadId,
|
|
219
|
+
workdir: spec.workdir ?? agent.projectRoot,
|
|
220
|
+
copy: Boolean(spec.copy && spec.workdir),
|
|
221
|
+
...(spec.copy && spec.copyOf ? { copyOf: spec.copyOf } : {}),
|
|
222
|
+
instructions: [compiled.material.instructions, learned, spec.systemPrompt].filter(Boolean).join('\n\n'),
|
|
223
|
+
mcp, env: { ...spec.env }, persist: spec.persist ?? false, keepThread: spec.keepThread ?? false,
|
|
224
|
+
// ALPD §57: the window the agent works in before its runtime compacts; absent, the model's.
|
|
225
|
+
...(agent.context ? { context: agent.context } : {}),
|
|
226
|
+
autocompact: typeof profile?.autocompact === 'number' ? profile.autocompact as number : typeof agent.autocompact === 'number' ? agent.autocompact : undefined,
|
|
227
|
+
// ALPD §54: the language everything the user reads is written in.
|
|
228
|
+
language: options.language ?? await userLanguage(options.library),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export const COMPACT_SHARE = 0.9;
|
|
2
|
+
|
|
3
|
+
/** Bounded optional reads. Never expose account identifiers or credentials. */
|
|
4
|
+
export async function optionalRead<T>(read: () => Promise<T>, timeout = 1500): Promise<T | undefined> {
|
|
5
|
+
let timer: NodeJS.Timeout | undefined;
|
|
6
|
+
try {
|
|
7
|
+
return await Promise.race([Promise.resolve().then(read).catch(() => undefined), new Promise<undefined>(resolve => { timer = setTimeout(() => resolve(undefined), timeout); })]);
|
|
8
|
+
} finally { clearTimeout(timer); }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function codexUsage(result: any) {
|
|
12
|
+
if (!result?.rateLimits) return { available: false };
|
|
13
|
+
const clean = (limit: any) => ({
|
|
14
|
+
name: limit?.limitName ?? null, model: limit?.normalModelSlug ?? null,
|
|
15
|
+
plan: limit?.planType ?? null,
|
|
16
|
+
windows: [limit?.primary, limit?.secondary].filter(Boolean).map(window => ({
|
|
17
|
+
usedPercent: window.usedPercent,
|
|
18
|
+
remainingPercent: typeof window.usedPercent === 'number' ? Math.max(0, 100 - window.usedPercent) : null,
|
|
19
|
+
windowMinutes: window.windowDurationMins, resetsAt: window.resetsAt,
|
|
20
|
+
})),
|
|
21
|
+
spendControlReached: limit?.spendControlReached ?? null,
|
|
22
|
+
});
|
|
23
|
+
return { available: true, ordinaryUsageAllowed: result.ordinaryUsageAllowed ?? null,
|
|
24
|
+
limits: Object.values(result.rateLimitsByLimitId ?? { default: result.rateLimits }).map(clean) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function claudeUsage(result: any) {
|
|
28
|
+
if (!result) return { available: false };
|
|
29
|
+
return { available: result.rate_limits_available === true, plan: result.subscription_type ?? null,
|
|
30
|
+
windows: Object.entries(result.rate_limits ?? {}).flatMap(([name, window]: [string, any]) =>
|
|
31
|
+
window && ('utilization' in window) ? [{ name, usedPercent: window.utilization,
|
|
32
|
+
remainingPercent: typeof window.utilization === 'number' ? Math.max(0, 100 - window.utilization) : null,
|
|
33
|
+
resetsAt: window.resets_at }] : []) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether a runtime's usage report (codexUsage or claudeUsage) lets it work again (ALPD §58):
|
|
38
|
+
* true when every window has room, false when one is used up or spending is capped, and
|
|
39
|
+
* undefined when the report says nothing either way.
|
|
40
|
+
*/
|
|
41
|
+
export function usageAllows(usage: any): boolean | undefined {
|
|
42
|
+
if (!usage?.available) return undefined;
|
|
43
|
+
const limits: any[] = Array.isArray(usage.limits) ? usage.limits : [];
|
|
44
|
+
if (limits.some(limit => limit?.spendControlReached === true)) return false;
|
|
45
|
+
const windows: any[] = limits.length ? limits.flatMap(limit => limit?.windows ?? []) : usage.windows ?? [];
|
|
46
|
+
const known = windows.filter(window => typeof window?.usedPercent === 'number');
|
|
47
|
+
if (!known.length) return undefined;
|
|
48
|
+
return known.every(window => window.usedPercent < 100);
|
|
49
|
+
}
|