@anhlp/alp-runtime 0.0.0-beta.1 → 0.0.0-beta.2

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.
@@ -7,6 +7,7 @@ import os from 'node:os';
7
7
  import path from 'node:path';
8
8
  import { createInterface, type Interface } from 'node:readline';
9
9
  import { openMcpTools } from './mcp-tools.js';
10
+ import { antigravityToolDetail } from './tool-detail.js';
10
11
 
11
12
  /**
12
13
  * Antigravity provider definition for ALP.
@@ -72,6 +73,8 @@ export function encodePrompt(text: string): string {
72
73
  * Emits line-delimited JSON events and parses stdout line by line.
73
74
  */
74
75
  export class AntigravityTransport {
76
+ /** Antigravity's prompt is one line of text: no images. */
77
+ takesImages() { return false; }
75
78
  private child?: ChildProcessWithoutNullStreams;
76
79
  private lines?: Interface;
77
80
  private exit: Promise<void> = Promise.resolve();
@@ -383,6 +386,9 @@ export class AntigravityTransport {
383
386
  const toolName = step.tool_name || 'tool';
384
387
  const callId = `agy-tool-${this.activeTurn}-${step.step_index ?? ++this.messages}`;
385
388
 
389
+ // The command stays the tool's name, as the journal reads it; the typed detail rides along.
390
+ const detail = antigravityToolDetail(step.tool_info ?? { name: toolName });
391
+ const typed = { tool: typeof step.tool_info?.name === 'string' && step.tool_info.name ? step.tool_info.name : toolName, detail };
386
392
  if (step.state === 'ACTIVE') {
387
393
  this.emit('item/started', {
388
394
  threadId: this.conversationId,
@@ -393,6 +399,7 @@ export class AntigravityTransport {
393
399
  cwd: this.cwd,
394
400
  status: 'inProgress',
395
401
  aggregatedOutput: '',
402
+ ...typed,
396
403
  },
397
404
  });
398
405
  } else if (step.state === 'DONE' || step.state === 'ERROR') {
@@ -409,6 +416,8 @@ export class AntigravityTransport {
409
416
  status: step.state === 'DONE' ? 'completed' : 'failed',
410
417
  exitCode: step.state === 'DONE' ? 0 : 1,
411
418
  aggregatedOutput: output,
419
+ ...typed,
420
+ ...(step.state === 'ERROR' ? { error: step.tool_info?.error?.message } : {}),
412
421
  },
413
422
  });
414
423
  }
@@ -6,6 +6,7 @@ import { optionalRead, claudeUsage } from './runtime-context.js';
6
6
  import { randomUUID } from 'node:crypto';
7
7
  import { z } from 'zod';
8
8
  import { PIN_KINDS } from './board.js';
9
+ import { claudeToolDetail, resultText } from './tool-detail.js';
9
10
  import { CLOSE_REASONS, GATE_KINDS, TASK_STATUSES, TASK_TYPES } from '@anhlp/alp-core/tasks';
10
11
  import { commandDecision } from '@anhlp/alp-core/permissions';
11
12
 
@@ -217,6 +218,13 @@ export function claudeSandboxAvailable() {
217
218
 
218
219
  const WRITERS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'];
219
220
  /** What ALP asks the user for a permission Claude would otherwise not have. */
221
+ /** One turn-input part as Claude's: text, or a data-URL image as a base64 image block. */
222
+ export function claudeContent(part: any) {
223
+ const image = part?.type === 'image' && typeof part.url === 'string' ? /^data:(image\/(?:png|jpeg|gif|webp));base64,([A-Za-z0-9+/]+={0,2})$/.exec(part.url) : null;
224
+ if (part?.type === 'image' && !image) throw new Error('Claude takes images as inline base64 PNG, JPEG, GIF or WebP only');
225
+ return image ? { type: 'image', source: { type: 'base64', media_type: image[1], data: image[2] } } : { type: 'text', text: part.text };
226
+ }
227
+
220
228
  export type PermissionRequest = { tool: string; input: Record<string, unknown>; reason: 'rule' | 'mode'; rule?: string };
221
229
  export type PermissionAnswer = { allow: boolean; always?: boolean; message?: string };
222
230
 
@@ -337,7 +345,7 @@ export class ClaudeTransport {
337
345
  private activeTurn?: string;
338
346
  private closed = false;
339
347
  private assistantText = new Map<string, string>();
340
- private toolCalls = new Map<string, { name: string; command: string }>();
348
+ private toolCalls = new Map<string, { name: string; command: string; input: unknown }>();
341
349
  /** The last rate limit Claude reported, and whether the running turn hit one. */
342
350
  private rateLimit?: { status: string; resetsAt?: number; overageStatus?: string; rateLimitType?: string; utilization?: number };
343
351
  private limitedTurn = false;
@@ -448,10 +456,7 @@ export class ClaudeTransport {
448
456
  type: 'user',
449
457
  message: {
450
458
  role: 'user',
451
- content: params.input.map((part: any) => ({
452
- type: 'text',
453
- text: part.text,
454
- })),
459
+ content: params.input.map(claudeContent),
455
460
  },
456
461
  parent_tool_use_id: null,
457
462
  uuid: randomUUID(),
@@ -658,6 +663,9 @@ export class ClaudeTransport {
658
663
  });
659
664
  }
660
665
  if (message.type === 'result') {
666
+ // The session's cost so far, as Claude counts it; a viewer shows it with the context meter.
667
+ const cost = (message as any).total_cost_usd;
668
+ if (typeof cost === 'number' && Number.isFinite(cost) && cost >= 0) this.emit('thread/tokenUsage/updated', { threadId: this.threadId, turnId: this.activeTurn, tokenUsage: { totalCostUsd: cost } });
661
669
  const windows = Object.values((message as any).modelUsage ?? {}).map((entry: any) => entry?.contextWindow).filter((value): value is number => typeof value === 'number' && value > 0);
662
670
  // Only when it was not measured: the measured window may be the setting's, smaller than the model's.
663
671
  if (windows.length && !this.compactAt) this.contextWindow = Math.max(...windows);
@@ -676,7 +684,9 @@ export class ClaudeTransport {
676
684
  const command = block.name === 'Bash'
677
685
  ? String(block.input?.command ?? '')
678
686
  : `${block.name} ${JSON.stringify(block.input ?? {})}`;
679
- this.toolCalls.set(block.id, { name: block.name, command });
687
+ this.toolCalls.set(block.id, { name: block.name, command, input: block.input });
688
+ // The command stays what skill use, copy escapes and the journal read; the typed detail rides along.
689
+ const detail = claudeToolDetail(block.name, block.input);
680
690
  this.emit('item/started', {
681
691
  threadId: this.threadId,
682
692
  item: {
@@ -686,6 +696,7 @@ export class ClaudeTransport {
686
696
  cwd: this.cwd,
687
697
  status: 'inProgress',
688
698
  aggregatedOutput: '',
699
+ ...(detail ? { tool: block.name, detail } : {}),
689
700
  },
690
701
  });
691
702
  }
@@ -697,13 +708,17 @@ export class ClaudeTransport {
697
708
  const content = Array.isArray(message.message.content)
698
709
  ? message.message.content as any[]
699
710
  : [];
700
- for (const block of content) {
701
- if (block.type !== 'tool_result') continue;
711
+ // The SDK's structured output belongs to the message's one tool result.
712
+ const results = content.filter(block => block.type === 'tool_result');
713
+ const structured = results.length === 1 ? (message as any).tool_use_result : undefined;
714
+ for (const block of results) {
702
715
  const call = this.toolCalls.get(block.tool_use_id);
703
716
  if (!call) continue;
704
717
  const output = typeof block.content === 'string'
705
718
  ? block.content
706
719
  : JSON.stringify(block.content ?? '');
720
+ const text = resultText(block.content);
721
+ const detail = claudeToolDetail(call.name, call.input, { text, structured, error: Boolean(block.is_error) });
707
722
  this.emit('item/completed', {
708
723
  threadId: this.threadId,
709
724
  item: {
@@ -714,6 +729,7 @@ export class ClaudeTransport {
714
729
  status: block.is_error ? 'failed' : 'completed',
715
730
  exitCode: block.is_error ? 1 : 0,
716
731
  aggregatedOutput: output,
732
+ ...(detail ? { tool: call.name, detail, ...(block.is_error ? { error: text } : {}) } : {}),
717
733
  },
718
734
  });
719
735
  this.toolCalls.delete(block.tool_use_id);
package/src/contracts.ts CHANGED
@@ -16,6 +16,8 @@ export type RuntimeTransport = {
16
16
  onFailure(listener: (error: unknown) => void): void;
17
17
  close(): Promise<void>;
18
18
  onRequest?: (listener: (method: string, params: any) => Promise<unknown>) => void;
19
+ /** Whether turn input may hold images (`{ type: 'image', url: 'data:…' }`); omitted, Codex and Claude do. */
20
+ takesImages?: () => boolean;
19
21
  };
20
22
 
21
23
  export type RuntimeOptions = {
@@ -156,8 +158,12 @@ export type AlpRuntime = {
156
158
  import type { SessionPreview } from '@anhlp/alp-protocol/session';
157
159
  export type { SessionPreview } from '@anhlp/alp-protocol/session';
158
160
 
159
- /** The team a viewer starts a root in when neither it nor the project's settings choose one, as Paseo does. */
160
- export const DEFAULT_TEAM = 'pho';
161
+ /**
162
+ * The team `alp init` names in a new project's settings (core INIT_TEAM). Kept for compatibility;
163
+ * it is not the default of a session: preview and create both resolve that through core
164
+ * `defaultTeam` (settings without workflow.mode run `custom`).
165
+ */
166
+ export { INIT_TEAM as DEFAULT_TEAM } from '@anhlp/alp-core/workflow';
161
167
 
162
168
  import type { RecoveryOutcome, Pause, PauseState } from '@anhlp/alp-protocol/session';
163
169
  export type { RecoveryOutcome, Pause, PauseState } from '@anhlp/alp-protocol/session';
package/src/index.ts CHANGED
@@ -24,3 +24,5 @@ export type { Pin, PinKind } from './board.js';
24
24
 
25
25
  export { ProviderRegistry, createProviderRegistry } from './providers.js';
26
26
  export type { ProviderContext, ProviderFactory, ProviderFactories } from './providers.js';
27
+ export { acpToolDetail, antigravityToolDetail, boundDetail, claudeToolDetail, clipText, codexToolCall, stripReadGutter, toolError, DETAIL_FILE_PATHS, DETAIL_TEXT_CHARS, DETAIL_WEB_RESULTS, ERROR_CHARS, TOOL_FAILED } from './tool-detail.js';
28
+ export type { AcpCall, ClaudeResult, CodexToolCall, ToolCallDetail } from './tool-detail.js';
package/src/resolve.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { access, readFile, stat } from 'node:fs/promises';
3
- import { resolveWorkflow } from '@anhlp/alp-core/workflow';
3
+ import { defaultTeam, resolveWorkflow } from '@anhlp/alp-core/workflow';
4
4
  import { initProject } from '@anhlp/alp-core/init';
5
5
  import { profileModel, agentProfiles, selectedProfile } from '@anhlp/alp-core/profiles';
6
6
  import { jsonObject, resolveAgent } from '@anhlp/alp-core/resolver';
@@ -95,13 +95,13 @@ export async function resolveSession(spec: SessionSpec, options: { templates?: R
95
95
  if (scaffoldMissing) await initProject(spec.cwd, { ...starter, library: options.library });
96
96
  else if (!spec.restore) {
97
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')] });
98
+ await initProject(spec.cwd, { ...starter, library: options.library, onlyManagedProfiles: true, teamIds: [spec.workflow === undefined ? defaultTeam(settings) : teamOf(spec.workflow)] });
99
99
  }
100
100
  }
101
101
  if (options.library) await ensureLibrary(options.library, options.templates ? { templates: options.templates } : {});
102
102
  const restored = spec.restore;
103
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 });
104
+ const { team, ...workflow } = await resolveWorkflow(spec.cwd, spec.workflow, restored?.workflow, { library: options.library, templates: options.templates, scaffold: options.initialize === false });
105
105
  const agent = await resolveAgent(spec.cwd, { agent: spec.agent ?? restored?.agent, library: options.library, templates: options.templates });
106
106
  const oracleModels = team?.oracleModels ?? ORACLE_MODELS;
107
107
  if (agent.name === 'oracle' && !restored && !oracleModels.includes(spec.model ?? '')) throw new Error(`Oracle runs on ${oracleModels.join(' or ')}; choose one`);
package/src/runtime.ts CHANGED
@@ -2,9 +2,10 @@ import { constants as fsConstants } from 'node:fs';
2
2
  import type { Session, Assignment, Waiter } from './session-state.js';
3
3
  import { errorData, plainObject, toolResult } from './tool-results.js';
4
4
  import { createLogger } from '@anhlp/alp-core/log';
5
+ import { PROMPT_IMAGES, base64Bytes } from '@anhlp/alp-protocol';
5
6
  import { createBoardDomain } from './board-domain.js';
6
7
  import { createWorktreeDomain } from './worktree-domain.js';
7
- import { DEFAULT_TEAM, type RuntimeTransport, type RuntimeOptions, type OpenOptions, type PromptContent, type PromptInput, type AlpRuntime, type SessionPreview, type RecoveryOutcome, type Pause, type PauseState, type RecallAnswer } from './contracts.js';
8
+ import { type RuntimeTransport, type RuntimeOptions, type OpenOptions, type PromptContent, type PromptInput, type AlpRuntime, type SessionPreview, type RecoveryOutcome, type Pause, type PauseState, type RecallAnswer } from './contracts.js';
8
9
  export type { RuntimeTransport, RuntimeOptions, OpenOptions, PromptContent, PromptInput, AlpRuntime, SessionPreview, RecoveryOutcome, Pause, PauseState, RecallAnswer } from './contracts.js';
9
10
  export { DEFAULT_TEAM } from './contracts.js';
10
11
  import { verdictLine, parseHandoff, type Handoff } from './handoff.js';
@@ -40,9 +41,10 @@ import { OWN_START, sameProcessAlive } from './process-info.js';
40
41
  import { createRecallBook, recallPrompt, RECALL_KEEP_MS, RECALL_QUESTION_CHARS, RECALL_TIMEOUT_MS, type RecallEntry } from './recall.js';
41
42
  import { branchExists, checkoutFingerprint, checkoutKey, createCopy, createWorktree, reattachWorktree, removeCopy, removeWorktree } from './workspace.js';
42
43
  import { claudeSandboxAvailable, SKILL_PLUGIN } from './claude-transport.js';
44
+ import { boundDetail, codexToolCall, toolError } from './tool-detail.js';
43
45
  import { ALP_REPO, gh, projectRepo } from './github.js';
44
46
  import { optionalRead, usageAllows, COMPACT_SHARE } from './runtime-context.js';
45
- import { providerUsage, type ProviderUsage } from './usage.js';
47
+ import { providerUsage, sessionUsage, type ProviderUsage } from './usage.js';
46
48
  import { parse as toml } from 'smol-toml';
47
49
  import { findFormula, formulaDirs, listFormulas, pourFormula } from '@anhlp/alp-core/formulas';
48
50
  import { addAllowRule, capMode, commandDecision, profileFor, unwrapShell } from '@anhlp/alp-core/permissions';
@@ -183,6 +185,11 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
183
185
  return run;
184
186
  }
185
187
 
188
+ /** Whether the session's runtime takes images in a prompt: the transport says, else Codex and Claude do. */
189
+ function takesImages(session: Session) {
190
+ return session.runtime.takesImages ? session.runtime.takesImages() : session.runtimeKind === 'codex' || session.runtimeKind === 'claude';
191
+ }
192
+
186
193
  function snapshot(sessionId: string, session: Session): SessionSnapshot {
187
194
  return {
188
195
  id: sessionId,
@@ -201,6 +208,8 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
201
208
  ...(session.active ? { activeTurnId: session.active } : {}),
202
209
  ...(session.parked ? { parked: session.parked.reason } : {}),
203
210
  ...(session.instructionsSha ? { instructionsSha: session.instructionsSha } : {}),
211
+ ...(takesImages(session) ? { promptImages: true } : {}),
212
+ ...(session.usage ? { usage: session.usage } : {}),
204
213
  busy: !!(session.active || session.pending || session.children.size || session.assignments.size || hasActiveMail(session) || reviewing(session)),
205
214
  };
206
215
  }
@@ -273,6 +282,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
273
282
  sessionId: string,
274
283
  session: Session,
275
284
  item: any,
285
+ done = false,
276
286
  ) {
277
287
  if (item.type === 'agentMessage') {
278
288
  // The first words main shows after the user wrote: how long the user waited.
@@ -290,7 +300,8 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
290
300
  .filter((c: any) => c.type === 'text')
291
301
  .map((c: any) => c.text)
292
302
  .join('\n');
293
- emit(sessionId, { type: 'item', item: { kind: 'user_message', id: item.id, text } });
303
+ const images = (item.content ?? []).filter((c: any) => c.type === 'image' || c.type === 'localImage').length;
304
+ emit(sessionId, { type: 'item', item: { kind: 'user_message', id: item.id, text, ...(images ? { images } : {}) } });
294
305
  return;
295
306
  }
296
307
 
@@ -335,24 +346,37 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
335
346
  session.journal.push(stamp(`${session.mapping.agent.name} shell ${status}${item.exitCode != null ? ` (exit ${item.exitCode})` : ''}: ${clip(String(item.command ?? ''))}`));
336
347
  }
337
348
 
349
+ // A transport that knows the tool attaches its typed detail; the command stays as it was.
350
+ // The protocol's bounds hold here for every transport (boundDetail passes unknown as it is).
351
+ const typed = item.detail && typeof item.detail === 'object' && item.detail.type !== 'shell' ? boundDetail(item.detail) : undefined;
338
352
  emit(sessionId, {
339
353
  type: 'item',
340
354
  item: {
341
355
  kind: 'tool_call',
342
356
  id: item.id,
343
357
  callId: item.id,
344
- name: 'shell',
358
+ name: typed ? String(item.tool || 'tool') : 'shell',
345
359
  status,
346
- ...(status === 'failed' ? { error: 'Command failed' } : {}),
347
- detail: {
360
+ ...(status === 'failed' ? { error: typed ? toolError(item.error) : 'Command failed' } : {}),
361
+ detail: typed ?? (item.detail?.type === 'shell' ? item.detail : {
348
362
  type: 'shell',
349
363
  command: item.command,
350
364
  cwd: item.cwd,
351
365
  output: item.aggregatedOutput ?? '',
352
366
  exitCode: item.exitCode,
353
- },
367
+ }),
354
368
  },
355
369
  });
370
+ return;
371
+ }
372
+
373
+ // Codex's own file changes, web searches, MCP calls and sub-agents (ALPD §4.2).
374
+ const call = codexToolCall(item, done);
375
+ if (call) {
376
+ emit(sessionId, {
377
+ type: 'item',
378
+ item: { kind: 'tool_call', id: item.id, callId: item.id, name: call.name, status: call.status, ...(call.error ? { error: call.error } : {}), detail: call.detail },
379
+ });
356
380
  }
357
381
  }
358
382
 
@@ -390,19 +414,30 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
390
414
  if (method === 'item/completed' || method === 'item/started') {
391
415
  if (method === 'item/completed') session.progressAt = Date.now();
392
416
  if (params.item?.type !== 'userMessage') {
393
- nativeItem(sessionId, session, params.item);
417
+ nativeItem(sessionId, session, params.item, method === 'item/completed');
394
418
  }
395
419
  if (method === 'item/started' && params.item?.type === 'commandExecution') watchCopy(sessionId, session, params.item);
396
420
  if (method === 'item/started' && params.item) skillUse(sessionId, session, params.item);
397
421
  return;
398
422
  }
399
423
 
424
+ // A running tool call that learned more, such as an ACP call's path or diff.
425
+ if (method === 'item/updated') {
426
+ if (params.item?.type === 'commandExecution' && params.item.status === 'inProgress') nativeItem(sessionId, session, params.item);
427
+ return;
428
+ }
429
+
400
430
  if (method === 'account/rateLimits/updated') {
401
431
  usageReport(session, params);
402
432
  return;
403
433
  }
404
434
 
405
435
  if (method === 'thread/tokenUsage/updated') {
436
+ const usage = sessionUsage(session.usage, params?.tokenUsage);
437
+ if (usage) {
438
+ session.usage = usage;
439
+ emit(sessionId, { type: 'usage', usage });
440
+ }
406
441
  contextReport(sessionId, session, params?.tokenUsage);
407
442
  return;
408
443
  }
@@ -3165,15 +3200,21 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
3165
3200
  return;
3166
3201
  }
3167
3202
 
3168
- if (
3169
- prompt.content.some(
3170
- (content) =>
3171
- content.type !== 'text',
3172
- )
3173
- ) {
3174
- throw new Error(
3175
- 'Only text prompts are supported',
3176
- );
3203
+ if (prompt.content.some(content => content.type !== 'text' && content.type !== 'image')) {
3204
+ throw new Error('Only text and image prompts are supported');
3205
+ }
3206
+ // Inline images only, never a path or URL: the native input gets a data URL ALP builds itself.
3207
+ const images = prompt.content.filter(content => content.type === 'image') as Array<{ type: 'image'; mimeType: string; data: string }>;
3208
+ if (images.length) {
3209
+ if (!takesImages(session)) throw new Error(`${session.runtimeKind === 'acp' ? 'This ACP agent' : session.runtimeKind[0].toUpperCase() + session.runtimeKind.slice(1)} takes no images; send the message as text`);
3210
+ if (images.length > PROMPT_IMAGES.maxCount) throw new Error(`A prompt takes at most ${PROMPT_IMAGES.maxCount} images`);
3211
+ if (images.reduce((sum, image) => sum + (typeof image.data === 'string' ? base64Bytes(image.data) : 0), 0) > PROMPT_IMAGES.maxTotalBytes) throw new Error(`The images of a prompt are at most ${PROMPT_IMAGES.maxTotalBytes} bytes together`);
3212
+ for (const image of images) {
3213
+ const keys = Object.keys(image).filter(key => !['type', 'mimeType', 'data', 'name'].includes(key));
3214
+ if (keys.length || !PROMPT_IMAGES.mimeTypes.includes(image.mimeType) || typeof image.data !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(image.data) || base64Bytes(image.data) > PROMPT_IMAGES.maxBytes) {
3215
+ throw new Error(`An image must be inline base64 ${PROMPT_IMAGES.mimeTypes.join(', ')} of at most ${PROMPT_IMAGES.maxBytes} bytes`);
3216
+ }
3217
+ }
3177
3218
  }
3178
3219
 
3179
3220
  if (
@@ -3195,9 +3236,8 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
3195
3236
  }
3196
3237
 
3197
3238
  const text = prompt.content
3198
- .map(
3199
- (content) => content.text ?? '',
3200
- )
3239
+ .filter(content => content.type === 'text')
3240
+ .map(content => content.text ?? '')
3201
3241
  .join('\n');
3202
3242
 
3203
3243
  // Steering changes the brief; live assignments keep running and report by mail.
@@ -3251,6 +3291,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
3251
3291
  text: origin === 'user' ? text : promptSafe(text),
3252
3292
  text_elements: [],
3253
3293
  },
3294
+ ...images.map(image => ({ type: 'image', url: `data:${image.mimeType};base64,${image.data}` })),
3254
3295
  ...(mail.length && !wake ? [{ type: 'text', text: promptSafe(renderMail(mail, session.parentAgent)), text_elements: [] }] : []),
3255
3296
  ];
3256
3297
 
@@ -3305,6 +3346,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
3305
3346
  id: `user:${prompt.clientMessageId}`,
3306
3347
  clientMessageId: prompt.clientMessageId,
3307
3348
  text,
3349
+ ...(images.length ? { images: images.length } : {}),
3308
3350
  },
3309
3351
  });
3310
3352
  if (origin === 'user' && !session.parent) session.userWaiting ??= Date.now();
@@ -3477,12 +3519,10 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
3477
3519
 
3478
3520
  async preview(spec) {
3479
3521
  const resolveOptions = { templates: options.templates, library: options.libraryDir, language: options.language };
3480
- // As Paseo: the team settings name, else Phở; a new project's settings name Phở.
3481
- const settings = JSON.parse(await readFile(path.join(spec.cwd, '.alp', 'settings.json'), 'utf8').catch(() => '{}'));
3482
- const named = typeof settings?.workflow?.mode === 'string' && settings.workflow.mode !== 'custom' ? settings.workflow.mode : undefined;
3483
- const workflow = spec.workflow ?? named ?? DEFAULT_TEAM;
3522
+ // The team session.create would run (resolveWorkflow, ALPD §42): the one chosen, else settings'
3523
+ // workflow.mode, else `custom`; a folder without settings gets init's, which name Phở.
3484
3524
  // A preview only reads: a folder the user has not run anything in stays as it is.
3485
- const mapping = await resolveSession({ ...spec, workflow }, { ...resolveOptions, initialize: false });
3525
+ const mapping = await resolveSession(spec, { ...resolveOptions, initialize: false });
3486
3526
  const teams = (await listTeams(spec.cwd, { library: options.libraryDir, templates: options.templates }))
3487
3527
  .filter(team => !team.error)
3488
3528
  .map(team => ({ id: team.id, label: team.label ?? team.id, ...(team.description ? { description: team.description } : {}) }));
@@ -4,6 +4,7 @@ import type { MailEvent } from './mailbox.js';
4
4
  import type { Worktree, WorktreeChange, Copy } from './workspace.js';
5
5
  import type { Verification } from '@anhlp/alp-core/verify';
6
6
  import type { Handoff } from './handoff.js';
7
+ import type { SessionUsage } from './events.js';
7
8
 
8
9
  export type Session = {
9
10
  runtimeKind: RuntimeKind;
@@ -52,6 +53,8 @@ export type Session = {
52
53
  progressAt?: number;
53
54
  /** A digest of the instructions the session runs with. */
54
55
  instructionsSha?: string;
56
+ /** The latest usage its runtime reported, shown in viewers' context meters (ALPD §57). */
57
+ usage?: SessionUsage;
55
58
  /** The context advisory was given since the context last emptied (ALPD §57). */
56
59
  contextLevel?: number;
57
60
  /** An assignment's brief as its requester gave it, repeated after a compaction. */