@ikenga/contract 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,205 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ AgentCapabilitiesSchema,
5
+ EngineOnboardingSchema,
6
+ EngineProvidesSchema,
7
+ type AgentCapabilities,
8
+ type Engine,
9
+ type EngineProvides,
10
+ } from './engine.js';
11
+ import { ManifestSchema, PkgManifestSchema } from './manifest.js';
12
+
13
+ const fullCaps: AgentCapabilities = {
14
+ streaming: true,
15
+ toolUse: true,
16
+ thinking: true,
17
+ artifacts: true,
18
+ fileAttachments: true,
19
+ imageInput: true,
20
+ slashCommands: true,
21
+ modelSwitching: true,
22
+ promptCaching: true,
23
+ agenticTools: true,
24
+ mcp: true,
25
+ sessionResume: true,
26
+ };
27
+
28
+ const noopCaps: AgentCapabilities = {
29
+ streaming: false,
30
+ toolUse: false,
31
+ thinking: false,
32
+ artifacts: false,
33
+ fileAttachments: false,
34
+ imageInput: false,
35
+ slashCommands: false,
36
+ modelSwitching: false,
37
+ promptCaching: false,
38
+ agenticTools: false,
39
+ mcp: false,
40
+ sessionResume: false,
41
+ };
42
+
43
+ // ---------------- AgentCapabilitiesSchema ----------------
44
+
45
+ test('AgentCapabilitiesSchema accepts a fully-true snapshot', () => {
46
+ const r = AgentCapabilitiesSchema.safeParse(fullCaps);
47
+ assert.equal(r.success, true);
48
+ });
49
+
50
+ test('AgentCapabilitiesSchema accepts an all-false (noop) snapshot', () => {
51
+ const r = AgentCapabilitiesSchema.safeParse(noopCaps);
52
+ assert.equal(r.success, true);
53
+ });
54
+
55
+ test('AgentCapabilitiesSchema rejects missing fields', () => {
56
+ const { streaming: _drop, ...partial } = fullCaps;
57
+ const r = AgentCapabilitiesSchema.safeParse(partial);
58
+ assert.equal(r.success, false);
59
+ });
60
+
61
+ test('AgentCapabilitiesSchema rejects non-boolean values', () => {
62
+ const r = AgentCapabilitiesSchema.safeParse({ ...fullCaps, streaming: 'yes' });
63
+ assert.equal(r.success, false);
64
+ });
65
+
66
+ // ---------------- EngineOnboardingSchema ----------------
67
+
68
+ test('EngineOnboardingSchema defaults arrays when omitted', () => {
69
+ const r = EngineOnboardingSchema.parse({});
70
+ assert.deepEqual(r.requiredVaultKeys, []);
71
+ assert.deepEqual(r.requiredEnvVars, []);
72
+ assert.equal(r.authCommand, undefined);
73
+ assert.equal(r.docsUrl, undefined);
74
+ });
75
+
76
+ test('EngineOnboardingSchema accepts full onboarding hints', () => {
77
+ const r = EngineOnboardingSchema.safeParse({
78
+ requiredVaultKeys: ['ANTHROPIC_API_KEY'],
79
+ requiredEnvVars: ['CLAUDE_HOME'],
80
+ authCommand: 'claude login',
81
+ docsUrl: 'https://docs.anthropic.com/claude-code',
82
+ });
83
+ assert.equal(r.success, true);
84
+ });
85
+
86
+ test('EngineOnboardingSchema rejects non-URL docsUrl', () => {
87
+ const r = EngineOnboardingSchema.safeParse({ docsUrl: 'not-a-url' });
88
+ assert.equal(r.success, false);
89
+ });
90
+
91
+ // ---------------- EngineProvidesSchema ----------------
92
+
93
+ test('EngineProvidesSchema accepts a minimal engine block', () => {
94
+ const r = EngineProvidesSchema.safeParse({
95
+ agentId: 'claude-code',
96
+ capabilities: fullCaps,
97
+ });
98
+ assert.equal(r.success, true);
99
+ if (r.success) {
100
+ assert.deepEqual(r.data.onboarding.requiredVaultKeys, []);
101
+ assert.deepEqual(r.data.onboarding.requiredEnvVars, []);
102
+ }
103
+ });
104
+
105
+ test('EngineProvidesSchema rejects empty agentId', () => {
106
+ const r = EngineProvidesSchema.safeParse({
107
+ agentId: '',
108
+ capabilities: fullCaps,
109
+ });
110
+ assert.equal(r.success, false);
111
+ });
112
+
113
+ test('EngineProvidesSchema rejects missing capabilities', () => {
114
+ const r = EngineProvidesSchema.safeParse({ agentId: 'codex' });
115
+ assert.equal(r.success, false);
116
+ });
117
+
118
+ // ---------------- Manifest integration ----------------
119
+
120
+ test('Manifest accepts an optional engine block', () => {
121
+ const m = {
122
+ id: 'com.ikenga.engine-claude-code',
123
+ name: 'Claude Code Engine',
124
+ version: '0.1.0',
125
+ ikenga_api: '1',
126
+ kind: 'engine',
127
+ engine: {
128
+ agentId: 'claude-code',
129
+ display: 'Claude Code',
130
+ capabilities: fullCaps,
131
+ onboarding: {
132
+ requiredVaultKeys: ['ANTHROPIC_API_KEY'],
133
+ authCommand: 'claude login',
134
+ },
135
+ },
136
+ };
137
+ const r = ManifestSchema.safeParse(m);
138
+ assert.equal(r.success, true, r.success ? '' : JSON.stringify(r.error.issues));
139
+ });
140
+
141
+ test('Manifest still parses without an engine block (non-engine pkg)', () => {
142
+ const m = {
143
+ id: 'com.ikenga.studio',
144
+ name: 'Studio',
145
+ version: '0.1.0',
146
+ ikenga_api: '1',
147
+ };
148
+ const r = ManifestSchema.safeParse(m);
149
+ assert.equal(r.success, true);
150
+ if (r.success) assert.equal(r.data.engine, undefined);
151
+ });
152
+
153
+ test('Manifest rejects a malformed engine block', () => {
154
+ const m = {
155
+ id: 'com.ikenga.engine-broken',
156
+ name: 'Broken',
157
+ version: '0.1.0',
158
+ ikenga_api: '1',
159
+ engine: { agentId: 'broken' }, // missing capabilities
160
+ };
161
+ const r = ManifestSchema.safeParse(m);
162
+ assert.equal(r.success, false);
163
+ });
164
+
165
+ test('PkgManifestSchema alias is identical to ManifestSchema', () => {
166
+ // Same instance — symmetry check with the Rust side.
167
+ assert.equal(PkgManifestSchema, ManifestSchema);
168
+ });
169
+
170
+ // ---------------- Engine.metadata type-level check ----------------
171
+
172
+ test('Engine.metadata is structurally required at type level', () => {
173
+ // The type-level check is enforced by tsc at build time. This runtime
174
+ // test just exercises the shape so a regression that drops `metadata`
175
+ // would force a compile error here.
176
+ const sample: Engine['metadata'] = {
177
+ agentId: 'noop',
178
+ display: 'No-op',
179
+ capabilities: noopCaps,
180
+ onboarding: { requiredVaultKeys: [], requiredEnvVars: [] },
181
+ };
182
+ assert.equal(sample.agentId, 'noop');
183
+ });
184
+
185
+ // ---------------- AdapterLoader manifest input shape ----------------
186
+
187
+ test('AdapterLoader.load consumes a manifest carrying EngineProvides', () => {
188
+ const provides: EngineProvides = {
189
+ agentId: 'codex',
190
+ display: 'OpenAI Codex',
191
+ capabilities: { ...noopCaps, streaming: true, mcp: true },
192
+ onboarding: {
193
+ requiredVaultKeys: ['OPENAI_API_KEY'],
194
+ requiredEnvVars: [],
195
+ docsUrl: 'https://openai.com/codex',
196
+ },
197
+ };
198
+ // Loader input is `{ id: string; engine: EngineProvides }` — verify
199
+ // we can construct one structurally.
200
+ const input: { id: string; engine: EngineProvides } = {
201
+ id: 'com.ikenga.engine-codex',
202
+ engine: provides,
203
+ };
204
+ assert.equal(input.engine.agentId, 'codex');
205
+ });
package/src/engine.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  // - engine-aider (future)
5
5
  // - engine-noop (testing / shell-without-AI mode)
6
6
 
7
+ import { z } from 'zod';
8
+
7
9
  export interface SessionOpts {
8
10
  cwd?: string;
9
11
  systemPrompt?: string;
@@ -33,6 +35,94 @@ export interface McpServerSpec {
33
35
  env?: Record<string, string>;
34
36
  }
35
37
 
38
+ // ---------- Capabilities (single source of truth) ----------
39
+
40
+ /**
41
+ * Capability flags every engine adapter advertises. This is the canonical
42
+ * shape consumed by both the shell-side ChatAdapter layer and any pkg that
43
+ * needs to reason about adapter features (wizard, settings UI, telemetry).
44
+ *
45
+ * Fields are intentionally a *superset* of what any single adapter
46
+ * supports — an adapter sets the ones it implements to `true` and the
47
+ * rest to `false`. Adding a new flag is a non-breaking schema change so
48
+ * long as adapters default it to `false` until they implement it.
49
+ */
50
+ export const AgentCapabilitiesSchema = z.object({
51
+ /** Streams partial responses as they arrive (vs. all-at-once). */
52
+ streaming: z.boolean(),
53
+ /** Invokes external tools / function calls during a turn. */
54
+ toolUse: z.boolean(),
55
+ /** Emits an extended-thinking / reasoning channel separate from output. */
56
+ thinking: z.boolean(),
57
+ /** Produces structured artifacts (code blocks, files, images) the host renders. */
58
+ artifacts: z.boolean(),
59
+ /** Accepts file attachments as part of an input. */
60
+ fileAttachments: z.boolean(),
61
+ /** Accepts image input (vision). */
62
+ imageInput: z.boolean(),
63
+ /** Recognises a `/slash` command vocabulary. */
64
+ slashCommands: z.boolean(),
65
+ /** Lets the user switch models mid-thread. */
66
+ modelSwitching: z.boolean(),
67
+ /** Uses prompt-caching for repeated context (Anthropic-specific today). */
68
+ promptCaching: z.boolean(),
69
+ /** Runs agentic tools (recursive sub-agents, long-running loops). */
70
+ agenticTools: z.boolean(),
71
+ /** Speaks MCP — can register and route through MCP servers. */
72
+ mcp: z.boolean(),
73
+ /** Can resume a prior session by id. */
74
+ sessionResume: z.boolean(),
75
+ });
76
+ export type AgentCapabilities = z.infer<typeof AgentCapabilitiesSchema>;
77
+
78
+ // ---------- Engine pkg manifest "engine" block ----------
79
+
80
+ /**
81
+ * Per-adapter onboarding requirements surfaced in the first-run wizard.
82
+ * The wizard composes these instead of hardcoding per-agent forms —
83
+ * every engine pkg owns its own setup story.
84
+ */
85
+ export const EngineOnboardingSchema = z.object({
86
+ /** Stronghold-vault keys this adapter needs at runtime (e.g. `ANTHROPIC_API_KEY`). */
87
+ requiredVaultKeys: z.array(z.string()).default([]),
88
+ /** Plain env-vars the adapter expects on the host shell. */
89
+ requiredEnvVars: z.array(z.string()).default([]),
90
+ /**
91
+ * CLI command the user can run to authenticate. The wizard surfaces this
92
+ * as a copy-to-clipboard hint — it never shells out on the user's behalf.
93
+ */
94
+ authCommand: z.string().optional(),
95
+ /** Docs URL for setting up this adapter. */
96
+ docsUrl: z.string().url().optional(),
97
+ });
98
+ export type EngineOnboarding = z.infer<typeof EngineOnboardingSchema>;
99
+
100
+ /**
101
+ * Manifest block declared by engine-* pkgs. Read by the shell's
102
+ * `AdapterLoader` at pkg-discovery time; consumed by the wizard to compose
103
+ * adapter-specific onboarding hints.
104
+ */
105
+ export const EngineProvidesSchema = z.object({
106
+ /**
107
+ * Stable id — matches the detection-side agent id (e.g. `claude-code`,
108
+ * `codex`, `aider`, `noop`). The wizard's `selected_agent_id` is matched
109
+ * against this field at adapter-resolve time.
110
+ */
111
+ agentId: z.string().min(1),
112
+ /** Display name; overrides any detection-side display if both present. */
113
+ display: z.string().optional(),
114
+ /** Snapshot of what this adapter implements. */
115
+ capabilities: AgentCapabilitiesSchema,
116
+ /** Onboarding requirements composed by the wizard. */
117
+ onboarding: EngineOnboardingSchema.default({
118
+ requiredVaultKeys: [],
119
+ requiredEnvVars: [],
120
+ }),
121
+ });
122
+ export type EngineProvides = z.infer<typeof EngineProvidesSchema>;
123
+
124
+ // ---------- Engine adapter runtime contract ----------
125
+
36
126
  export interface Engine {
37
127
  /** Stable identifier — matches the pkg id of the engine adapter. */
38
128
  readonly id: string;
@@ -40,6 +130,19 @@ export interface Engine {
40
130
  /** Human-readable adapter version. */
41
131
  readonly version: string;
42
132
 
133
+ /**
134
+ * Static metadata copied from the loading manifest's `engine` block.
135
+ * Required: every adapter must surface its agentId / display / capabilities
136
+ * / onboarding so the shell and wizard can introspect without re-parsing
137
+ * the manifest.
138
+ */
139
+ readonly metadata: {
140
+ agentId: string;
141
+ display: string;
142
+ capabilities: AgentCapabilities;
143
+ onboarding: EngineOnboarding;
144
+ };
145
+
43
146
  /** Open a new session. Sessions are cheap; create one per pkg invocation. */
44
147
  startSession(opts: SessionOpts): Promise<Session>;
45
148
 
@@ -55,3 +158,328 @@ export interface Engine {
55
158
  /** Health check — used by the kernel before routing pkg requests. */
56
159
  healthCheck(): Promise<{ ok: boolean; reason?: string }>;
57
160
  }
161
+
162
+ // ---------- Adapter loader contract ----------
163
+
164
+ /**
165
+ * Loader the shell uses to bring engine-* pkgs online at boot, tear them
166
+ * down on removal, and gracefully fall back when the user's
167
+ * `selected_agent_id` has no installed adapter.
168
+ *
169
+ * Implementation lives shell-side (post-Phase-7); this interface lets
170
+ * the contract pin the shape so engine pkgs and the shell agree on the
171
+ * load lifecycle.
172
+ */
173
+ export interface AdapterLoader {
174
+ /**
175
+ * Load + register the engine for a given pkg manifest. The manifest must
176
+ * carry an `engine` block (see `EngineProvidesSchema`); the loader
177
+ * resolves the adapter implementation and threads the manifest's
178
+ * metadata into the returned `Engine.metadata`.
179
+ */
180
+ load(manifest: { id: string; engine: EngineProvides }): Promise<Engine>;
181
+
182
+ /** Unload — used on pkg removal. After this resolves the agentId is unregistered. */
183
+ unload(agentId: string): Promise<void>;
184
+
185
+ /**
186
+ * Fallback returned when the requested agentId has no registered loader.
187
+ * Implementations MUST return a working `engine-noop` (or equivalent
188
+ * inert) adapter so the chat surface never dead-ends.
189
+ */
190
+ fallback(): Engine;
191
+ }
192
+
193
+ // ─── ACP (Agent Client Protocol) shapes ───────────────────────────────────────
194
+ //
195
+ // Phase 10: a second, ACP-shaped contract sits alongside the legacy `Engine`
196
+ // interface above. The two coexist while Phase 11 retires the legacy
197
+ // adapter. New engines (in-process Rust ACP, Node ACP sidecars, etc.) target
198
+ // `AcpEngine`; existing consumers keep the `Engine` shape until they migrate.
199
+ //
200
+ // The names mirror ACP's method names verbatim so the wire layer and the
201
+ // adapter layer share vocabulary — `newSession`, `prompt`, `cancel`,
202
+ // `setMode`, `loadSession`, `forkSession`, `requestPermission`.
203
+
204
+ /** ACP `ProtocolVersion`. Numeric. V1 = 1. */
205
+ export type AcpProtocolVersion = number;
206
+
207
+ export interface AcpInitializeRequest {
208
+ protocolVersion: AcpProtocolVersion;
209
+ /** Optional `_meta` passthrough; the in-process shell ignores it. */
210
+ _meta?: Record<string, unknown>;
211
+ }
212
+
213
+ export interface AcpPromptCapabilities {
214
+ image: boolean;
215
+ audio: boolean;
216
+ embeddedContext: boolean;
217
+ }
218
+
219
+ export interface AcpMcpCapabilities {
220
+ http: boolean;
221
+ sse: boolean;
222
+ }
223
+
224
+ export interface AcpAgentCapabilities {
225
+ loadSession: boolean;
226
+ promptCapabilities: AcpPromptCapabilities;
227
+ mcpCapabilities: AcpMcpCapabilities;
228
+ }
229
+
230
+ export interface AcpInitializeResponse {
231
+ protocolVersion: AcpProtocolVersion;
232
+ agentCapabilities: AcpAgentCapabilities;
233
+ /** Authentication methods the agent supports — opaque structure passed
234
+ * through to the wizard. */
235
+ authMethods?: unknown[];
236
+ _meta?: Record<string, unknown>;
237
+ }
238
+
239
+ export interface AcpTextContentBlock {
240
+ type: 'text';
241
+ text: string;
242
+ }
243
+
244
+ /** ACP `ContentBlock::Image`. `data` is raw base64 with NO `data:` URI prefix. */
245
+ export interface AcpImageContentBlock {
246
+ type: 'image';
247
+ data: string;
248
+ mimeType: string;
249
+ uri?: string;
250
+ }
251
+
252
+ export type AcpContentBlock =
253
+ | AcpTextContentBlock
254
+ | AcpImageContentBlock
255
+ | { type: 'audio'; data: string; mimeType: string }
256
+ | { type: 'resource_link'; name: string; uri: string }
257
+ | { type: 'resource'; resource: unknown };
258
+
259
+ export interface AcpNewSessionRequest {
260
+ cwd: string;
261
+ mcpServers: McpServerSpec[];
262
+ _meta?: Record<string, unknown>;
263
+ }
264
+
265
+ /** The four canonical ACP session modes the Rust ACP server advertises. */
266
+ export type AcpSessionModeId = 'plan' | 'default' | 'auto' | 'bypassPermissions';
267
+
268
+ export interface AcpSessionMode {
269
+ id: AcpSessionModeId;
270
+ name: string;
271
+ description?: string;
272
+ _meta?: Record<string, unknown>;
273
+ }
274
+
275
+ export interface AcpSessionModes {
276
+ currentModeId: AcpSessionModeId;
277
+ availableModes: AcpSessionMode[];
278
+ _meta?: Record<string, unknown>;
279
+ }
280
+
281
+ export interface AcpNewSessionResponse {
282
+ sessionId: string;
283
+ modes?: AcpSessionModes;
284
+ models?: unknown;
285
+ configOptions?: unknown[];
286
+ _meta?: Record<string, unknown>;
287
+ }
288
+
289
+ export interface AcpPromptRequest {
290
+ sessionId: string;
291
+ prompt: AcpContentBlock[];
292
+ messageId?: string;
293
+ _meta?: Record<string, unknown>;
294
+ }
295
+
296
+ export type AcpStopReason =
297
+ | 'end_turn'
298
+ | 'max_tokens'
299
+ | 'max_turn_requests'
300
+ | 'refusal'
301
+ | 'cancelled';
302
+
303
+ export interface AcpPromptResponse {
304
+ stopReason: AcpStopReason;
305
+ userMessageId?: string;
306
+ usage?: unknown;
307
+ _meta?: Record<string, unknown>;
308
+ }
309
+
310
+ /** ACP `SessionUpdate` discriminated union. Open-ended on the `string` tail
311
+ * so adapter-specific extensions don't break TS consumers. */
312
+ export type AcpSessionUpdate =
313
+ | { sessionUpdate: 'agent_message_chunk'; content: AcpContentBlock; messageId?: string }
314
+ | { sessionUpdate: 'agent_thought_chunk'; content: AcpContentBlock; messageId?: string }
315
+ | { sessionUpdate: 'user_message_chunk'; content: AcpContentBlock }
316
+ | {
317
+ sessionUpdate: 'tool_call';
318
+ toolCallId: string;
319
+ title: string;
320
+ kind?: string;
321
+ status?: string;
322
+ content?: unknown[];
323
+ rawInput?: unknown;
324
+ _meta?: Record<string, unknown>;
325
+ }
326
+ | {
327
+ sessionUpdate: 'tool_call_update';
328
+ toolCallId: string;
329
+ fields: {
330
+ status?: string;
331
+ content?: unknown[];
332
+ rawOutput?: unknown;
333
+ };
334
+ _meta?: Record<string, unknown>;
335
+ }
336
+ | { sessionUpdate: 'current_mode_update'; currentModeId: AcpSessionModeId }
337
+ | { sessionUpdate: 'plan_update'; plan: unknown }
338
+ | { sessionUpdate: string; [k: string]: unknown };
339
+
340
+ export interface AcpSessionNotification {
341
+ sessionId: string;
342
+ update: AcpSessionUpdate;
343
+ _meta?: Record<string, unknown>;
344
+ }
345
+
346
+ // ── Permission round-trip (Phase 4) ───────────────────────────────────────────
347
+
348
+ export type AcpPermissionOptionKind =
349
+ | 'allow_once'
350
+ | 'allow_always'
351
+ | 'reject_once'
352
+ | 'reject_always';
353
+
354
+ export interface AcpPermissionOption {
355
+ optionId: string;
356
+ name: string;
357
+ kind: AcpPermissionOptionKind;
358
+ }
359
+
360
+ export interface AcpToolCallSummary {
361
+ toolCallId: string;
362
+ title?: string;
363
+ kind?: string;
364
+ status?: string;
365
+ content?: unknown[];
366
+ rawInput?: unknown;
367
+ rawOutput?: unknown;
368
+ }
369
+
370
+ export interface AcpRequestPermissionRequest {
371
+ sessionId: string;
372
+ toolCall: AcpToolCallSummary;
373
+ options: AcpPermissionOption[];
374
+ _meta?: Record<string, unknown>;
375
+ }
376
+
377
+ export type AcpRequestPermissionOutcome =
378
+ | { outcome: 'cancelled' }
379
+ | { outcome: 'selected'; optionId: string };
380
+
381
+ export interface AcpRequestPermissionResponse {
382
+ outcome: AcpRequestPermissionOutcome;
383
+ _meta?: Record<string, unknown>;
384
+ }
385
+
386
+ /** Wire envelope: pairs a `requestId` with the request body so the client
387
+ * reply can address the parked Rust-side oneshot. */
388
+ export interface AcpPermissionRequestEnvelope {
389
+ requestId: string;
390
+ request: AcpRequestPermissionRequest;
391
+ }
392
+
393
+ // ── Session load + fork (Phase 8) ─────────────────────────────────────────────
394
+
395
+ export interface AcpLoadSessionResponse {
396
+ /** Optional mode advertisement so the picker can hydrate without paying
397
+ * the cold-spawn cost of `newSession`. */
398
+ modes?: AcpSessionModes;
399
+ }
400
+
401
+ export interface AcpForkResult {
402
+ newThreadId: string;
403
+ sourceThreadId: string;
404
+ branchedFromTurn?: number;
405
+ }
406
+
407
+ export interface AcpForkOpts {
408
+ upToTurn?: number;
409
+ label?: string;
410
+ }
411
+
412
+ // ── OS-attention notify (Phase 9) ─────────────────────────────────────────────
413
+
414
+ export type AcpNotifyKind = 'notification' | 'permissionRequest';
415
+
416
+ export interface AcpNotifyPayload {
417
+ threadId: string;
418
+ title: string;
419
+ body: string;
420
+ kind: AcpNotifyKind;
421
+ }
422
+
423
+ // ── Engine adapter (ACP-shaped) ───────────────────────────────────────────────
424
+
425
+ /**
426
+ * ACP-shaped engine adapter. Implementations:
427
+ * - `pkgs/engine-claude-code` — wraps the in-process Rust ACP server.
428
+ * - future: `pkgs/engine-codex`, `pkgs/engine-aider` — each speaking the
429
+ * same wire shapes.
430
+ *
431
+ * Method names mirror ACP verbatim (`newSession`, `prompt`, `cancel`,
432
+ * `setMode`, `loadSession`, `forkSession`, `respondPermission`) so the wire
433
+ * layer, adapter layer, and host shell share one vocabulary.
434
+ *
435
+ * Subscriptions return a synchronous `() => void` unsubscribe — the
436
+ * implementation may resolve the underlying tauri-listener asynchronously
437
+ * but the caller can drop the registration immediately on unmount.
438
+ */
439
+ export interface AcpEngine {
440
+ initialize(req: AcpInitializeRequest): Promise<AcpInitializeResponse>;
441
+
442
+ /** Mint a new session. The agent may lazily spawn its child on the first
443
+ * `prompt` rather than during `newSession` itself. */
444
+ newSession(req: AcpNewSessionRequest): Promise<AcpNewSessionResponse>;
445
+
446
+ /** Send a turn. Resolves when the turn ends; in-progress events flow via
447
+ * `onSessionUpdate`. */
448
+ prompt(req: AcpPromptRequest): Promise<AcpPromptResponse>;
449
+
450
+ /** Clean interrupt. Preserves the transcript and keeps the child alive
451
+ * for the next turn. */
452
+ cancel(sessionId: string): Promise<void>;
453
+
454
+ /** Switch the session's permission mode. */
455
+ setMode(sessionId: string, modeId: AcpSessionModeId): Promise<void>;
456
+
457
+ /** Re-attach to an existing session by id. The child stays lazy. */
458
+ loadSession(sessionId: string): Promise<AcpLoadSessionResponse>;
459
+
460
+ /** Clone a session from a chosen turn. The new thread inherits the source's
461
+ * on-disk transcript so the first prompt resumes from the cutoff. */
462
+ forkSession(sourceSessionId: string, opts?: AcpForkOpts): Promise<AcpForkResult>;
463
+
464
+ /** Subscribe to session updates. Returns a sync unsubscribe. */
465
+ onSessionUpdate(
466
+ sessionId: string,
467
+ callback: (update: AcpSessionUpdate) => void,
468
+ ): () => void;
469
+
470
+ /** Subscribe to permission requests for this session. Reply via
471
+ * `respondPermission`. Returns a sync unsubscribe. */
472
+ onPermissionRequest(
473
+ sessionId: string,
474
+ callback: (envelope: AcpPermissionRequestEnvelope) => void,
475
+ ): () => void;
476
+
477
+ /** Reply to a parked permission request. */
478
+ respondPermission(
479
+ requestId: string,
480
+ response: AcpRequestPermissionResponse,
481
+ ): Promise<void>;
482
+
483
+ /** Subscribe to OS-attention notifications. Returns a sync unsubscribe. */
484
+ onNotify(callback: (payload: AcpNotifyPayload) => void): () => void;
485
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ export * from './engine.js';
4
4
  export * from './scopes.js';
5
5
  export * from './iyke.js';
6
6
  export * from './artifact.js';
7
+ export * from './registry.js';
7
8
 
8
9
  /** This package's own version. */
9
- export const CONTRACT_PACKAGE_VERSION = '0.3.0' as const;
10
+ export const CONTRACT_PACKAGE_VERSION = '0.5.0' as const;
package/src/manifest.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  // On disk: `<pkg-root>/manifest.json` (JSON, not TOML).
10
10
 
11
11
  import { z } from 'zod';
12
+ import { EngineProvidesSchema } from './engine.js';
12
13
 
13
14
  export const IKENGA_API_VERSION = 1 as const;
14
15
  export const IKENGA_API_MIN_SUPPORTED = 1 as const;
@@ -162,9 +163,20 @@ export const ManifestSchema = z.object({
162
163
  cron: z.array(CronEntrySchema).default([]),
163
164
  window: WindowBlockSchema.optional(),
164
165
  queries: QueriesBlockSchema.optional(),
166
+
167
+ /**
168
+ * Engine-adapter manifest block. Present iff this pkg is an engine-*
169
+ * adapter. Declares the agent id, display name, capability snapshot,
170
+ * and onboarding hints surfaced by the first-run wizard.
171
+ * See `@ikenga/contract/engine` for the source-of-truth schema.
172
+ */
173
+ engine: EngineProvidesSchema.optional(),
165
174
  });
166
175
 
167
176
  export type Manifest = z.infer<typeof ManifestSchema>;
177
+ /** Alias retained for symmetry with the Rust side / external consumers. */
178
+ export const PkgManifestSchema = ManifestSchema;
179
+ export type PkgManifest = Manifest;
168
180
 
169
181
  // ---------- Helpers ----------
170
182