@anhlp/alp-runtime 0.0.0-stage → 0.0.1-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +7 -0
  3. package/README.md +6 -2
  4. package/dist/acp-transport.d.ts +130 -0
  5. package/dist/acp-transport.js +511 -0
  6. package/dist/antigravity-transport.d.ts +82 -0
  7. package/dist/antigravity-transport.js +619 -0
  8. package/dist/assignment-results.d.ts +18 -0
  9. package/dist/assignment-results.js +77 -0
  10. package/dist/attachments.d.ts +84 -0
  11. package/dist/attachments.js +213 -0
  12. package/dist/board-domain.d.ts +56 -0
  13. package/dist/board-domain.js +157 -0
  14. package/dist/board.d.ts +21 -0
  15. package/dist/board.js +69 -0
  16. package/dist/catalog.d.ts +31 -0
  17. package/dist/catalog.js +38 -0
  18. package/dist/claude-transport.d.ts +202 -0
  19. package/dist/claude-transport.js +817 -0
  20. package/dist/configure.d.ts +44 -0
  21. package/dist/configure.js +64 -0
  22. package/dist/contracts.d.ts +327 -0
  23. package/dist/contracts.js +16 -0
  24. package/dist/conversation.d.ts +45 -0
  25. package/dist/conversation.js +62 -0
  26. package/dist/events.d.ts +1 -0
  27. package/dist/events.js +0 -0
  28. package/dist/github.d.ts +10 -0
  29. package/dist/github.js +39 -0
  30. package/dist/handoff.d.ts +36 -0
  31. package/dist/handoff.js +73 -0
  32. package/dist/index.d.ts +30 -0
  33. package/dist/index.js +87 -0
  34. package/dist/language.d.ts +41 -0
  35. package/dist/language.js +68 -0
  36. package/dist/live.d.ts +48 -0
  37. package/dist/live.js +45 -0
  38. package/dist/mailbox.d.ts +26 -0
  39. package/dist/mailbox.js +38 -0
  40. package/dist/mcp-tools.d.ts +115 -0
  41. package/dist/mcp-tools.js +71 -0
  42. package/dist/permission-path.d.ts +2 -0
  43. package/dist/permission-path.js +44 -0
  44. package/dist/process-info.d.ts +14 -0
  45. package/dist/process-info.js +32 -0
  46. package/dist/providers.d.ts +28 -0
  47. package/dist/providers.js +71 -0
  48. package/dist/recall.d.ts +42 -0
  49. package/dist/recall.js +60 -0
  50. package/dist/resolve.d.ts +121 -0
  51. package/dist/resolve.js +238 -0
  52. package/dist/runtime-context.d.ts +43 -0
  53. package/dist/runtime-context.js +61 -0
  54. package/dist/runtime.d.ts +11 -0
  55. package/dist/runtime.js +3976 -0
  56. package/dist/session-config.d.ts +2102 -0
  57. package/dist/session-config.js +205 -0
  58. package/dist/session-state.d.ts +166 -0
  59. package/dist/session-state.js +0 -0
  60. package/dist/tool-detail.d.ts +81 -0
  61. package/dist/tool-detail.js +332 -0
  62. package/dist/tool-results.d.ts +12 -0
  63. package/dist/tool-results.js +25 -0
  64. package/dist/tools.d.ts +719 -0
  65. package/dist/tools.js +396 -0
  66. package/dist/transport.d.ts +47 -0
  67. package/dist/transport.js +169 -0
  68. package/dist/usage.d.ts +25 -0
  69. package/dist/usage.js +60 -0
  70. package/dist/workspace.d.ts +101 -0
  71. package/dist/workspace.js +259 -0
  72. package/dist/worktree-domain.d.ts +89 -0
  73. package/dist/worktree-domain.js +199 -0
  74. package/package.json +262 -4
  75. package/src/acp-transport.ts +508 -0
  76. package/src/antigravity-transport.ts +667 -0
  77. package/src/assignment-results.ts +64 -0
  78. package/src/attachments.ts +254 -0
  79. package/src/board-domain.ts +185 -0
  80. package/src/board.ts +80 -0
  81. package/src/catalog.ts +32 -0
  82. package/src/claude-transport.ts +938 -0
  83. package/src/configure.ts +93 -0
  84. package/src/contracts.ts +318 -0
  85. package/src/conversation.ts +81 -0
  86. package/src/events.ts +1 -0
  87. package/src/github.ts +37 -0
  88. package/src/handoff.ts +86 -0
  89. package/src/index.ts +32 -0
  90. package/src/language.ts +102 -0
  91. package/src/live.ts +86 -0
  92. package/src/mailbox.ts +49 -0
  93. package/src/mcp-tools.ts +73 -0
  94. package/src/permission-path.ts +37 -0
  95. package/src/process-info.ts +40 -0
  96. package/src/providers.ts +84 -0
  97. package/src/recall.ts +85 -0
  98. package/src/resolve.ts +230 -0
  99. package/src/runtime-context.ts +49 -0
  100. package/src/runtime.ts +4691 -0
  101. package/src/session-config.ts +304 -0
  102. package/src/session-state.ts +164 -0
  103. package/src/tool-detail.ts +397 -0
  104. package/src/tool-results.ts +26 -0
  105. package/src/tools.ts +397 -0
  106. package/src/transport.ts +116 -0
  107. package/src/usage.ts +79 -0
  108. package/src/workspace.ts +318 -0
  109. package/src/worktree-domain.ts +220 -0
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 { defaultTeam, 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: [spec.workflow === undefined ? defaultTeam(settings) : teamOf(spec.workflow)] });
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, scaffold: options.initialize === false });
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 on explicit provider denial, a used-up window or spending cap, 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 (usage.ordinaryUsageAllowed === false || 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
+ }