@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.
Files changed (94) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +7 -0
  3. package/README.md +7 -0
  4. package/dist/acp-transport.d.ts +114 -0
  5. package/dist/acp-transport.js +478 -0
  6. package/dist/antigravity-transport.d.ts +80 -0
  7. package/dist/antigravity-transport.js +609 -0
  8. package/dist/board-domain.d.ts +56 -0
  9. package/dist/board-domain.js +157 -0
  10. package/dist/board.d.ts +21 -0
  11. package/dist/board.js +69 -0
  12. package/dist/catalog.d.ts +31 -0
  13. package/dist/catalog.js +38 -0
  14. package/dist/claude-transport.d.ts +157 -0
  15. package/dist/claude-transport.js +671 -0
  16. package/dist/contracts.d.ts +163 -0
  17. package/dist/contracts.js +5 -0
  18. package/dist/events.d.ts +1 -0
  19. package/dist/events.js +0 -0
  20. package/dist/github.d.ts +10 -0
  21. package/dist/github.js +39 -0
  22. package/dist/handoff.d.ts +36 -0
  23. package/dist/handoff.js +73 -0
  24. package/dist/index.d.ts +24 -0
  25. package/dist/index.js +64 -0
  26. package/dist/language.d.ts +41 -0
  27. package/dist/language.js +68 -0
  28. package/dist/live.d.ts +48 -0
  29. package/dist/live.js +45 -0
  30. package/dist/mailbox.d.ts +24 -0
  31. package/dist/mailbox.js +36 -0
  32. package/dist/mcp-tools.d.ts +115 -0
  33. package/dist/mcp-tools.js +71 -0
  34. package/dist/permission-path.d.ts +2 -0
  35. package/dist/permission-path.js +44 -0
  36. package/dist/process-info.d.ts +14 -0
  37. package/dist/process-info.js +32 -0
  38. package/dist/providers.d.ts +28 -0
  39. package/dist/providers.js +71 -0
  40. package/dist/recall.d.ts +42 -0
  41. package/dist/recall.js +60 -0
  42. package/dist/resolve.d.ts +121 -0
  43. package/dist/resolve.js +238 -0
  44. package/dist/runtime-context.d.ts +43 -0
  45. package/dist/runtime-context.js +61 -0
  46. package/dist/runtime.d.ts +4 -0
  47. package/dist/runtime.js +2979 -0
  48. package/dist/session-config.d.ts +1973 -0
  49. package/dist/session-config.js +205 -0
  50. package/dist/session-state.d.ts +140 -0
  51. package/dist/session-state.js +0 -0
  52. package/dist/tool-results.d.ts +12 -0
  53. package/dist/tool-results.js +25 -0
  54. package/dist/tools.d.ts +674 -0
  55. package/dist/tools.js +375 -0
  56. package/dist/transport.d.ts +47 -0
  57. package/dist/transport.js +169 -0
  58. package/dist/usage.d.ts +15 -0
  59. package/dist/usage.js +35 -0
  60. package/dist/workspace.d.ts +101 -0
  61. package/dist/workspace.js +259 -0
  62. package/dist/worktree-domain.d.ts +88 -0
  63. package/dist/worktree-domain.js +196 -0
  64. package/package.json +256 -0
  65. package/src/acp-transport.ts +473 -0
  66. package/src/antigravity-transport.ts +658 -0
  67. package/src/board-domain.ts +185 -0
  68. package/src/board.ts +80 -0
  69. package/src/catalog.ts +32 -0
  70. package/src/claude-transport.ts +771 -0
  71. package/src/contracts.ts +166 -0
  72. package/src/events.ts +1 -0
  73. package/src/github.ts +37 -0
  74. package/src/handoff.ts +86 -0
  75. package/src/index.ts +26 -0
  76. package/src/language.ts +102 -0
  77. package/src/live.ts +86 -0
  78. package/src/mailbox.ts +46 -0
  79. package/src/mcp-tools.ts +73 -0
  80. package/src/permission-path.ts +37 -0
  81. package/src/process-info.ts +40 -0
  82. package/src/providers.ts +84 -0
  83. package/src/recall.ts +85 -0
  84. package/src/resolve.ts +230 -0
  85. package/src/runtime-context.ts +49 -0
  86. package/src/runtime.ts +3579 -0
  87. package/src/session-config.ts +301 -0
  88. package/src/session-state.ts +141 -0
  89. package/src/tool-results.ts +26 -0
  90. package/src/tools.ts +379 -0
  91. package/src/transport.ts +116 -0
  92. package/src/usage.ts +42 -0
  93. package/src/workspace.ts +318 -0
  94. package/src/worktree-domain.ts +216 -0
package/src/tools.ts ADDED
@@ -0,0 +1,379 @@
1
+ import { CLOSE_REASONS, GATE_KINDS, TASK_STATUSES, TASK_TYPES, TASKS_DIR, type Task } from '@anhlp/alp-core/tasks';
2
+ import { READ_ONLY_AGENTS, type ResolvedSession } from './resolve.js';
3
+
4
+ import { PIN_KINDS } from './board.js';
5
+ import { RECALL_KEEP_MS } from './recall.js';
6
+ import { HANDOFF_OUTCOMES, VERDICT_RESULTS, CRITERION_RESULTS, SEVERITIES } from './handoff.js';
7
+
8
+ export const handoffList = (description: string) => ({ type: 'array', items: { type: 'string' }, description });
9
+
10
+ export const HANDOFF_TOOL = {
11
+ type: 'function',
12
+ name: 'alp_handoff',
13
+ description: 'File the structured handoff for your current assignment. The requesting agent receives it when your turn ends. A partial handoff stops your parked children, preserves their changes, and returns unfinished work to your requester.',
14
+ inputSchema: {
15
+ type: 'object',
16
+ properties: {
17
+ outcome: { type: 'string', enum: HANDOFF_OUTCOMES, description: 'complete, partial, blocked, or reconsider (the premise needs reconsideration).' },
18
+ summary: { type: 'string', description: 'Result, answer, or findings the requester needs.' },
19
+ candidate: handoffList('Artifacts or files produced; base and candidate SHA when applicable.'),
20
+ scope: handoffList('Paths changed or read.'),
21
+ verification: handoffList('Commands run with actual results, and checks not run.'),
22
+ risks: handoffList('Unresolved findings, assumptions, and decisions needed.'),
23
+ discovered: handoffList('Work you found outside your scope that should be tracked; your requester records it as a task.'),
24
+ ownership: { type: 'string', description: 'Resources released or retained.' },
25
+ verdict: {
26
+ type: 'object',
27
+ description: 'For a review: required of reviewer when the outcome is complete. Judge each acceptance criterion of the brief (or the ones you derived, said so), then give the result they lead to: fail when a criterion failed or a finding is critical or high; pass_with_findings when only medium or low findings remain; pass when there is nothing to fix; blocked when the review could not be done.',
28
+ properties: {
29
+ result: { type: 'string', enum: VERDICT_RESULTS },
30
+ criteria: {
31
+ type: 'array',
32
+ items: {
33
+ type: 'object',
34
+ properties: {
35
+ criterion: { type: 'string', description: 'What the change must do or respect.' },
36
+ result: { type: 'string', enum: CRITERION_RESULTS },
37
+ evidence: { type: 'string', description: 'What you observed or ran, with file:line; for not_checked, why.' },
38
+ },
39
+ required: ['criterion', 'result', 'evidence'],
40
+ additionalProperties: false,
41
+ },
42
+ },
43
+ findings: {
44
+ type: 'array',
45
+ items: {
46
+ type: 'object',
47
+ properties: {
48
+ severity: { type: 'string', enum: SEVERITIES, description: 'critical: security, data loss, crash; high: bug or real performance problem; medium: maintainability or minor bug; low: style.' },
49
+ where: { type: 'string', description: 'file:lines in the new version.' },
50
+ problem: { type: 'string', description: 'What is wrong and why it matters.' },
51
+ fix: { type: 'string', description: 'The recommended fix.' },
52
+ },
53
+ required: ['severity', 'where', 'problem'],
54
+ additionalProperties: false,
55
+ },
56
+ },
57
+ },
58
+ required: ['result', 'criteria'],
59
+ additionalProperties: false,
60
+ },
61
+ },
62
+ required: ['outcome', 'summary'],
63
+ additionalProperties: false,
64
+ },
65
+ };
66
+
67
+ export const WAIT_TOOL = {
68
+ type: 'function',
69
+ name: 'alp_wait',
70
+ description: 'Wait for mail from your assignments: results, questions, notes, and stall reports. Returns as soon as any arrives, or with an empty list and a status snapshot on timeout.',
71
+ inputSchema: {
72
+ type: 'object',
73
+ properties: {
74
+ assignments: { type: 'array', items: { type: 'string' }, description: 'Assignment ids to wait for; omit for all of yours.' },
75
+ timeoutMs: { type: 'integer', description: 'Maximum wait. Default 300000, maximum 900000.' },
76
+ },
77
+ additionalProperties: false,
78
+ },
79
+ };
80
+
81
+ export const SEND_TOOL = {
82
+ type: 'function',
83
+ name: 'alp_send',
84
+ description: 'Send mail to one of your live assignments, or to "parent", your requester. Siblings are not addressable.',
85
+ inputSchema: {
86
+ type: 'object',
87
+ properties: {
88
+ to: { type: 'string', description: 'An assignment id you started (or its agent name when only one is live), or "parent".' },
89
+ kind: { type: 'string', enum: ['answer', 'note', 'steer'], description: 'answer replies to a question (needs replyTo); steer changes an instruction (requester only); note is information.' },
90
+ body: { type: 'string' },
91
+ replyTo: { type: 'string', description: 'Question id, for example #4.' },
92
+ },
93
+ required: ['to', 'kind', 'body'],
94
+ additionalProperties: false,
95
+ },
96
+ };
97
+
98
+ export const MERGE_TOOL = {
99
+ type: 'function',
100
+ name: 'alp_merge',
101
+ description: 'Apply the change of a finished worktree assignment to your checkout, uncommitted. Conflicts are left as conflict markers for you to resolve. When the project configures verify commands, ALP runs them in the worktree first and applies nothing if they fail.',
102
+ inputSchema: {
103
+ type: 'object',
104
+ properties: {
105
+ assignmentId: { type: 'string' },
106
+ skipVerify: { type: 'string', description: 'Merge without running the verify commands; say why. It is recorded on the task.' },
107
+ },
108
+ required: ['assignmentId'],
109
+ additionalProperties: false,
110
+ },
111
+ };
112
+
113
+ export const VERIFY_TOOL = {
114
+ type: 'function',
115
+ name: 'alp_verify',
116
+ description: "Run the project's verify commands (setup, typecheck, test from .alp/settings.json) in your checkout, for example after resolving merge conflicts. Main may pass taskId to record the result on the task.",
117
+ inputSchema: {
118
+ type: 'object',
119
+ properties: { taskId: { type: 'string', description: 'Main only: the task the result counts for.' } },
120
+ additionalProperties: false,
121
+ },
122
+ };
123
+
124
+ export const DISCARD_TOOL = {
125
+ type: 'function',
126
+ name: 'alp_discard',
127
+ description: 'Drop the change of a finished worktree assignment, deleting its worktree and branch.',
128
+ inputSchema: {
129
+ type: 'object',
130
+ properties: { assignmentId: { type: 'string' } },
131
+ required: ['assignmentId'],
132
+ additionalProperties: false,
133
+ },
134
+ };
135
+
136
+ export const CANCEL_TOOL = {
137
+ type: 'function',
138
+ name: 'alp_cancel',
139
+ description: 'Stop one of your running or parked assignments, with its own assignments. Its changes stay where it made them (your checkout, or its worktree for alp_merge or alp_discard) and its task goes back to open. Use it to hand the rest to another agent or runtime, for example when one is parked on a usage limit.',
140
+ inputSchema: {
141
+ type: 'object',
142
+ properties: {
143
+ assignmentId: { type: 'string' },
144
+ reason: { type: 'string', description: 'Why, for the run log and its result.' },
145
+ },
146
+ required: ['assignmentId'],
147
+ additionalProperties: false,
148
+ },
149
+ };
150
+
151
+ export const RECALL_TOOL = {
152
+ type: 'function',
153
+ name: 'alp_recall',
154
+ description: `Ask a finished assignment about its work: why it chose an approach, what it tried, where it got stuck. ALP forks its session read-only, asks your question, and returns the answer. Assignments stay recallable for ${RECALL_KEEP_MS / 86_400_000} days.`,
155
+ inputSchema: {
156
+ type: 'object',
157
+ properties: {
158
+ assignmentId: { type: 'string', description: 'The finished assignment to ask, from its result.' },
159
+ taskId: { type: 'string', description: 'Instead of assignmentId: the last assignment that worked on this task.' },
160
+ question: { type: 'string', description: 'What you want to know.' },
161
+ },
162
+ required: ['question'],
163
+ additionalProperties: false,
164
+ },
165
+ };
166
+
167
+ export const PIN_TOOL = {
168
+ type: 'function',
169
+ name: 'alp_pin',
170
+ description: 'Pin to the project board that every agent on this project reads. claim: paths you are about to change (refused if another agent holds an overlapping claim); decision: the approach others should follow; finding: something others need to know.',
171
+ inputSchema: {
172
+ type: 'object',
173
+ properties: {
174
+ kind: { type: 'string', enum: PIN_KINDS },
175
+ body: { type: 'string', description: 'For a claim, what you are changing; otherwise the decision or finding itself.' },
176
+ paths: { type: 'array', items: { type: 'string' }, description: 'Project-relative files or directories. Required for a claim.' },
177
+ },
178
+ required: ['kind', 'body'],
179
+ additionalProperties: false,
180
+ },
181
+ };
182
+
183
+ export const BOARD_TOOL = {
184
+ type: 'function',
185
+ name: 'alp_board',
186
+ description: 'Read the project board: live claims, decisions and findings pinned by agents working on this project.',
187
+ inputSchema: {
188
+ type: 'object',
189
+ properties: {
190
+ kinds: { type: 'array', items: { type: 'string', enum: PIN_KINDS } },
191
+ limit: { type: 'integer', description: 'Most recent decisions and findings to return. Default 30.' },
192
+ },
193
+ additionalProperties: false,
194
+ },
195
+ };
196
+
197
+ export const UNPIN_TOOL = {
198
+ type: 'function',
199
+ name: 'alp_unpin',
200
+ description: 'Take down one of your pins, such as a claim you no longer need. Claims also end with your session.',
201
+ inputSchema: {
202
+ type: 'object',
203
+ properties: { pinId: { type: 'string' } },
204
+ required: ['pinId'],
205
+ additionalProperties: false,
206
+ },
207
+ };
208
+
209
+ export const ASK_TOOL = {
210
+ type: 'function',
211
+ name: 'alp_ask',
212
+ description: 'Ask a question and wait for the answer: your requester, or the user. Only main talks to the user, unless the user has written to you. Returns unanswered after the ask timeout.',
213
+ inputSchema: {
214
+ type: 'object',
215
+ properties: {
216
+ question: { type: 'string' },
217
+ to: { type: 'string', enum: ['parent', 'user'], description: 'parent (default for assignments) or user (the default without a requester; for an assignment, only after the user has written to it).' },
218
+ options: { type: 'array', items: { type: 'string' }, description: 'Suggested answers for the user; they may answer otherwise.' },
219
+ },
220
+ required: ['question'],
221
+ additionalProperties: false,
222
+ },
223
+ };
224
+
225
+ export const LESSON_TOOL = {
226
+ type: 'function',
227
+ name: 'alp_lesson',
228
+ description: 'Record a lesson about your process, so you follow it in later sessions. Write it as a rule: what to do, and when. scope project: this project only; user: every project of this user.',
229
+ inputSchema: {
230
+ type: 'object',
231
+ properties: {
232
+ scope: { type: 'string', enum: ['project', 'user'] },
233
+ lesson: { type: 'string', description: 'One rule, at most 600 characters.' },
234
+ },
235
+ required: ['scope', 'lesson'],
236
+ additionalProperties: false,
237
+ },
238
+ };
239
+
240
+ export const SKILL_TOOL = {
241
+ type: 'function',
242
+ name: 'alp_skill',
243
+ description: 'Propose a skill distilled from your lessons. ALP shows the whole skill to the user, and saves it to the user\'s skill library and gives it to the roles only when the user approves; any other answer comes back as feedback.',
244
+ inputSchema: {
245
+ type: 'object',
246
+ properties: {
247
+ name: { type: 'string', description: 'Directory name: lowercase letters, digits and hyphens.' },
248
+ description: { type: 'string', description: 'One line: what the skill is for and when to use it.' },
249
+ body: { type: 'string', description: 'The SKILL.md body in Markdown, without frontmatter: when to use it, the method as steps, and the checks.' },
250
+ roles: { type: 'array', items: { type: 'string' }, description: 'The roles whose work the skill guides, and only those: any of main (yourself), lead, peer, oracle, reviewer, supervisor, or a custom agent of this project.' },
251
+ lessons: { type: 'array', items: { type: 'string' }, description: 'The exact text of the lessons this skill replaces; they leave the lessons files once it is saved.' },
252
+ replace: { type: 'boolean', description: 'Propose a new version of an existing skill of this name.' },
253
+ },
254
+ required: ['name', 'description', 'body', 'roles'],
255
+ additionalProperties: false,
256
+ },
257
+ };
258
+
259
+ export const ISSUE_TOOL = {
260
+ type: 'function',
261
+ name: 'alp_issue',
262
+ description: 'GitHub issues of this project (target project, from its origin remote) or of ALP itself (target alp). search needs no approval. create and comment show the whole draft to the user and post it only when the user approves; any other answer comes back as feedback.',
263
+ inputSchema: {
264
+ type: 'object',
265
+ properties: {
266
+ action: { type: 'string', enum: ['search', 'create', 'comment'] },
267
+ target: { type: 'string', enum: ['project', 'alp'] },
268
+ query: { type: 'string', description: 'For search: GitHub search terms.' },
269
+ title: { type: 'string', description: 'For create.' },
270
+ body: { type: 'string', description: 'For create and comment: Markdown. Facts, reproduction and evidence; no secrets.' },
271
+ issue: { type: 'integer', description: 'For comment: the issue number.' },
272
+ labels: { type: 'array', items: { type: 'string' }, description: 'For create: existing labels of the repository.' },
273
+ },
274
+ required: ['action', 'target'],
275
+ additionalProperties: false,
276
+ },
277
+ };
278
+
279
+ export const TASK_ACTIONS = ['create', 'update', 'link', 'start', 'close', 'reopen', 'gate', 'clear', 'pour', 'formulas', 'show', 'list', 'ready'] as const;
280
+ export type TaskAction = typeof TASK_ACTIONS[number];
281
+ export const TASK_PAST: Record<string, string> = { create: 'created', update: 'updated', link: 'linked', start: 'started', close: 'closed', reopen: 'reopened', gate: 'gated', clear: 'cleared a gate of', delegate: 'delegated', submit: 'submitted', release: 'released', pour: 'poured', orphaned: 'reopened (its assignment ended with alpd)' };
282
+ export const TASK_FIELDS: Record<TaskAction, string[]> = {
283
+ create: ['title', 'description', 'type', 'priority', 'labels', 'paths', 'parent', 'blockedBy', 'discoveredFrom'],
284
+ update: ['id', 'title', 'description', 'type', 'priority', 'labels', 'paths', 'note'],
285
+ link: ['id', 'add', 'remove'],
286
+ start: ['id'],
287
+ close: ['id', 'reason', 'summary', 'unverified'],
288
+ reopen: ['id', 'note'],
289
+ gate: ['id', 'kind', 'note', 'until', 'ref'],
290
+ clear: ['id', 'gate', 'note'],
291
+ pour: ['formula', 'vars', 'parent'],
292
+ formulas: [],
293
+ show: ['id'],
294
+ list: ['status', 'label', 'limit'],
295
+ ready: ['limit'],
296
+ };
297
+
298
+ /** The agent a session's team has as main (ALPD §42); `main` outside a team. */
299
+ export const mainOf = (mapping: Pick<ResolvedSession, 'team'>) => mapping.team?.main ?? 'main';
300
+
301
+ /** A member's role in the session's team; without a team, the built-in agents' roles by name. */
302
+ export const roleOf = (mapping: Pick<ResolvedSession, 'team'>, agent: string) =>
303
+ mapping.team ? mapping.team.members[agent]?.role : ({ lead: 'lead', peer: 'peer', oracle: 'advisor', reviewer: 'reviewer' } as Record<string, string>)[agent];
304
+
305
+ /** What each role may do with the task graph: main changes it, the others read it. */
306
+ export function taskActions(mapping: Pick<ResolvedSession, 'agent' | 'team'>, parentAgent?: string, role?: 'supervisor'): TaskAction[] {
307
+ if (role === 'supervisor') return ['show', 'list'];
308
+ if (!parentAgent && mapping.agent.name === mainOf(mapping)) return [...TASK_ACTIONS];
309
+ return READ_ONLY_AGENTS.includes(mapping.agent.name) ? ['show'] : ['show', 'ready'];
310
+ }
311
+
312
+ export const taskLinks = (description: string) => ({
313
+ type: 'object',
314
+ description,
315
+ properties: {
316
+ blockedBy: { type: 'array', items: { type: 'string' }, description: 'Tasks that must close first.' },
317
+ related: { type: 'array', items: { type: 'string' } },
318
+ parent: { type: 'string', description: 'An epic or larger task this one belongs to.' },
319
+ },
320
+ additionalProperties: false,
321
+ });
322
+
323
+ export function taskTool(actions: TaskAction[]) {
324
+ const edits = actions.includes('create');
325
+ return {
326
+ type: 'function',
327
+ name: 'alp_task',
328
+ description: edits
329
+ ? `The project's task graph in ${TASKS_DIR}, shared with the user. ready lists open tasks nothing blocks or gates, most urgent first. gate holds a task back until the user approves (human), a time passes (timer), a pull request merges (gh:pr) or a workflow run succeeds (gh:run); ALP checks GitHub gates at the start of your turns, and clear clears one by hand. formulas lists workflow templates; pour turns one into an epic with a task per step, the steps ordered by blockedBy. create records work to track (discoveredFrom: the task during which you found it); start takes a ready task for yourself; close it with a reason and summary once verified; link adds or removes blockedBy, related and parent; reopen puts a task back to open.`
330
+ : `Read the project's task graph in ${TASKS_DIR}. Only main and the user create or change tasks; report work you find outside your scope to your requester.`,
331
+ inputSchema: {
332
+ type: 'object',
333
+ properties: {
334
+ action: { type: 'string', enum: actions },
335
+ id: { type: 'string', description: 'Task id, such as t-a3f8; for every action except create, list and ready.' },
336
+ ...(edits ? {
337
+ title: { type: 'string', description: 'For create and update: at most 200 characters.' },
338
+ description: { type: 'string', description: 'For create and update: what done means, context, and how to verify.' },
339
+ type: { type: 'string', enum: TASK_TYPES },
340
+ priority: { type: 'integer', description: '0 urgent, 1 high, 2 normal (default), 3 low, 4 backlog.' },
341
+ labels: { type: 'array', items: { type: 'string' } },
342
+ paths: { type: 'array', items: { type: 'string' }, description: 'Project-relative files or directories the work changes.' },
343
+ parent: { type: 'string', description: 'For create: the parent task.' },
344
+ blockedBy: { type: 'array', items: { type: 'string' }, description: 'For create: tasks that must close first.' },
345
+ discoveredFrom: { type: 'string', description: 'For create: the task during which this work was found.' },
346
+ add: taskLinks('For link: relations to add.'),
347
+ remove: taskLinks('For link: relations to remove.'),
348
+ reason: { type: 'string', enum: CLOSE_REASONS, description: 'For close. Default done.' },
349
+ kind: { type: 'string', enum: GATE_KINDS, description: 'For gate: human (the user approves; give the question as note), timer (until), gh:pr (until a pull request merges; ref), gh:run (until a workflow run succeeds; ref).' },
350
+ until: { type: 'string', description: 'For a timer gate: an ISO time, or +30m, +2h, +3d.' },
351
+ ref: { type: 'string', description: 'For a gh:pr or gh:run gate: 123, or owner/repo#123.' },
352
+ gate: { type: 'string', description: 'For clear: the gate id, such as g1. You cannot clear a human gate; the user does.' },
353
+ formula: { type: 'string', description: 'For pour: the formula name, from action formulas.' },
354
+ vars: { type: 'object', additionalProperties: { type: 'string' }, description: 'For pour: values of the formula variables.' },
355
+ summary: { type: 'string', description: 'For close: the outcome and its evidence.' },
356
+ unverified: { type: 'string', description: "For close as done when the task's last verification failed: why it is done anyway. Recorded on the task." },
357
+ note: { type: 'string', description: 'For update and reopen: why.' },
358
+ } : {}),
359
+ ...(actions.includes('list') ? {
360
+ status: { type: 'string', enum: TASK_STATUSES, description: 'For list. Default: every task not closed.' },
361
+ label: { type: 'string', description: 'For list.' },
362
+ } : {}),
363
+ ...(actions.includes('ready') || actions.includes('list') ? { limit: { type: 'integer', description: 'For ready and list. Default 20.' } } : {}),
364
+ },
365
+ required: ['action'],
366
+ additionalProperties: false,
367
+ },
368
+ };
369
+ }
370
+
371
+ /** The task an assignment takes, as the start of its brief. */
372
+ export function taskBrief(task: Task, writing: boolean) {
373
+ return [
374
+ `Task ${task.id} (${task.type}, P${task.priority}): ${task.title}`,
375
+ ...(task.description ? [task.description] : []),
376
+ ...(task.paths.length ? [`Paths: ${task.paths.join(', ')}${writing ? ' (ALP claimed them for you on the project board)' : ''}`] : []),
377
+ 'Your handoff moves this task to review for your requester to accept. List work you found outside the task under discovered; only main and the user create tasks.',
378
+ ].join('\n');
379
+ }
@@ -0,0 +1,116 @@
1
+ import { optionalRead, codexUsage } from './runtime-context.js';
2
+ import { spawn, execFile, type ChildProcessWithoutNullStreams } from 'node:child_process';
3
+ import { createInterface } from 'node:readline';
4
+
5
+ /**
6
+ * A request that was written but never answered: the process died, timed out or closed, so
7
+ * it may or may not have acted on it (ALPD §66). A request never written is not marked.
8
+ */
9
+ function unanswered(error: Error) {
10
+ return Object.assign(new Error(error.message), { outcomeUnknown: true });
11
+ }
12
+
13
+ /** Small stdio JSON-RPC transport for the selected runtime; never a shell command. */
14
+ export class CodexTransport {
15
+ private child: ChildProcessWithoutNullStreams;
16
+ private sequence = 0;
17
+ private pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }>();
18
+ private listeners = new Set<(method: string, params: any) => void>();
19
+ private failures = new Set<(error: Error) => void>();
20
+ private closed = false;
21
+ private requestHandler?: (method: string, params: any) => Promise<unknown>;
22
+ private exit: Promise<void>;
23
+
24
+ constructor(command: string, cwd: string, env: NodeJS.ProcessEnv, args = ['app-server', '--listen', 'stdio://']) {
25
+ if (/\.(cmd|bat|ps1)$/i.test(command)) throw new Error('Codex executable must be a native binary, not a shell launcher');
26
+ // Windows cannot exec a script by its shebang; a JavaScript command (the fake Codex of the tests) runs under this Node, still without a shell.
27
+ const script = process.platform === 'win32' && /\.[cm]?js$/i.test(command);
28
+ this.child = spawn(script ? process.execPath : command, script ? [command, ...args] : args, { cwd, env, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
29
+ this.exit = new Promise(resolve => { this.child.once('close', resolve); this.child.once('error', () => resolve()); });
30
+ // Drain diagnostics without writing credentials or user configuration to the host log.
31
+ this.child.stderr.on('data', () => {});
32
+ this.child.stdin.on('error', error => this.fail(error));
33
+ this.child.on('error', error => this.fail(error));
34
+ this.child.on('close', code => { if (!this.closed) this.fail(new Error(`Codex app-server exited (${code})`)); });
35
+ const lines = createInterface({ input: this.child.stdout });
36
+ lines.on('line', line => {
37
+ if (line.length > 16 * 1024 * 1024) { this.fail(new Error('Codex frame exceeds size limit')); return; }
38
+ try {
39
+ const message = JSON.parse(line);
40
+ if (message.method && message.id !== undefined) {
41
+ // Tool calls, and Codex asking to run a command or change files, which ALP answers by permission profile.
42
+ if (['item/tool/call', 'item/commandExecution/requestApproval', 'item/fileChange/requestApproval'].includes(message.method) && this.requestHandler) {
43
+ void Promise.resolve().then(() => this.requestHandler!(message.method, message.params)).then(
44
+ result => { if (!this.closed) this.write({ id: message.id, result }); },
45
+ error => { if (!this.closed) this.write({ id: message.id, error: { code: -32603, message: error instanceof Error ? error.message : String(error) } }); },
46
+ ).catch(error => this.fail(error));
47
+ } else {
48
+ // Delegation does not grant approval for unrelated interactive requests.
49
+ this.write({ id: message.id, error: { code: -32601, message: 'Unsupported runtime request' } });
50
+ }
51
+ } else if (message.id !== undefined) {
52
+ const waiter = this.pending.get(message.id);
53
+ if (waiter) {
54
+ this.pending.delete(message.id); clearTimeout(waiter.timer);
55
+ if (message.error) waiter.reject(new Error(message.error.message ?? 'Runtime request failed'));
56
+ else waiter.resolve(message.result);
57
+ }
58
+ } else if (message.method) {
59
+ for (const listener of this.listeners) listener(message.method, message.params);
60
+ }
61
+ } catch (error) { this.fail(error instanceof Error ? error : new Error(String(error))); }
62
+ });
63
+ }
64
+ private write(value: unknown) {
65
+ if (this.closed) throw new Error('Runtime is closed');
66
+ this.child.stdin.write(`${JSON.stringify(value)}\n`);
67
+ }
68
+ private fail(error: Error) {
69
+ if (this.closed) return;
70
+ for (const p of this.pending.values()) { clearTimeout(p.timer); p.reject(unanswered(error)); }
71
+ this.pending.clear();
72
+ for (const listener of this.failures) listener(error);
73
+ void this.close();
74
+ }
75
+ request(method: string, params: unknown, timeout = 30_000, fatalTimeout = true): Promise<any> {
76
+ if (this.closed) return Promise.reject(new Error('Runtime is closed'));
77
+ const id = ++this.sequence;
78
+ return new Promise((resolve, reject) => {
79
+ const timer = setTimeout(() => { const error = new Error(`Runtime request timed out: ${method}`); if (fatalTimeout) this.fail(error); else { this.pending.delete(id); reject(unanswered(error)); } }, timeout);
80
+ this.pending.set(id, { resolve, reject, timer });
81
+ try { this.write({ id, method, params }); }
82
+ catch (error) { clearTimeout(timer); this.pending.delete(id); reject(error); }
83
+ });
84
+ }
85
+ async orchestrationContext() {
86
+ const [catalog, usage] = await Promise.all([
87
+ optionalRead(() => this.request('model/list', { limit: 100, includeHidden: false }, 1200, false)),
88
+ optionalRead(() => this.request('account/rateLimits/read', {}, 1200, false)),
89
+ ]);
90
+ return { runtime: 'codex', observedAt: new Date().toISOString(),
91
+ catalogAvailable: Array.isArray(catalog?.data), catalogComplete: catalog ? !catalog.nextCursor : false,
92
+ models: (catalog?.data ?? []).map((model: any) => ({ id: `codex:${model.model}`, description: model.description,
93
+ label: model.displayName, thinking: model.supportedReasoningEfforts })), usage: codexUsage(usage) };
94
+ }
95
+ async initialize() {
96
+ await this.request('initialize', { clientInfo: { name: 'alp', title: 'ALP', version: '0.0.0' }, capabilities: { experimentalApi: true } });
97
+ this.write({ method: 'initialized' });
98
+ }
99
+ onNotification(listener: (method: string, params: any) => void) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
100
+ onFailure(listener: (error: Error) => void) { this.failures.add(listener); return () => this.failures.delete(listener); }
101
+ onRequest(handler: (method: string, params: any) => Promise<unknown>) { this.requestHandler = handler; }
102
+ async close() {
103
+ if (this.closed) return this.exit;
104
+ this.closed = true;
105
+ for (const p of this.pending.values()) { clearTimeout(p.timer); p.reject(unanswered(new Error('Runtime closed'))); }
106
+ this.pending.clear(); this.listeners.clear(); this.failures.clear();
107
+ this.child.stdin.end();
108
+ const timer = setTimeout(() => {
109
+ if (process.platform === 'win32' && this.child.pid) execFile('taskkill.exe', ['/PID', String(this.child.pid), '/T', '/F'], { windowsHide: true }, () => {});
110
+ else this.child.kill('SIGKILL');
111
+ }, 1500);
112
+ let deadline: NodeJS.Timeout | undefined;
113
+ try { await Promise.race([this.exit, new Promise<void>(resolve => { deadline = setTimeout(resolve, 4000); })]); }
114
+ finally { clearTimeout(timer); clearTimeout(deadline); }
115
+ }
116
+ }
package/src/usage.ts ADDED
@@ -0,0 +1,42 @@
1
+ /** Account-free usage data shown in ALP's viewers. */
2
+ export type UsageWindow = { name: string; usedPercent: number | null; resetsAt: string | null };
3
+ export type ProviderUsage = {
4
+ runtime: 'codex' | 'claude';
5
+ available: boolean;
6
+ checkedAt: string;
7
+ plan: string | null;
8
+ limited: boolean;
9
+ windows: UsageWindow[];
10
+ };
11
+
12
+ const text = (value: unknown): string | null => typeof value === 'string' && value.trim() ? value : null;
13
+ function resetTime(value: unknown): string | null {
14
+ const ms = typeof value === 'number' ? value * 1000 : typeof value === 'string' ? Date.parse(value) : NaN;
15
+ return Number.isFinite(ms) && Math.abs(ms) <= 8.64e15 ? new Date(ms).toISOString() : null;
16
+ }
17
+ function windowName(window: any, index: number) {
18
+ if (text(window.name)) return window.name.replaceAll('_', ' ');
19
+ const minutes = window.windowMinutes;
20
+ if (typeof minutes === 'number' && minutes > 0) {
21
+ if (minutes % 1440 === 0) return `${minutes / 1440} day window`;
22
+ if (minutes % 60 === 0) return `${minutes / 60} hour window`;
23
+ return `${minutes} minute window`;
24
+ }
25
+ return `Window ${index + 1}`;
26
+ }
27
+ export function providerUsage(runtime: ProviderUsage['runtime'], usage: any, checkedAt = new Date().toISOString()): ProviderUsage {
28
+ const limits: any[] = Array.isArray(usage?.limits) ? usage.limits : [];
29
+ const windows = (limits.length ? limits.flatMap(limit => (Array.isArray(limit?.windows) ? limit.windows : []).map((window: any) => ({ ...window, group: text(limit.name) ?? text(limit.model) }))) : Array.isArray(usage?.windows) ? usage.windows : [])
30
+ .filter((window: any) => window && typeof window === 'object')
31
+ .map((window: any, index: number) => ({
32
+ name: `${window.group ? `${window.group} · ` : ''}${windowName(window, index)}`,
33
+ usedPercent: typeof window.usedPercent === 'number' && Number.isFinite(window.usedPercent) ? Math.min(100, Math.max(0, window.usedPercent)) : null,
34
+ resetsAt: resetTime(window.resetsAt),
35
+ }));
36
+ return {
37
+ runtime, available: usage?.available === true, checkedAt,
38
+ plan: text(usage?.plan) ?? limits.map(limit => text(limit?.plan)).find(Boolean) ?? null,
39
+ limited: usage?.ordinaryUsageAllowed === false || limits.some(limit => limit?.spendControlReached === true) || windows.some((window: UsageWindow) => window.usedPercent === 100),
40
+ windows,
41
+ };
42
+ }