@inixiative/agent-session 0.2.0 → 0.3.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.
@@ -0,0 +1,215 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Transports — how a session reaches its model
3
+ // ---------------------------------------------------------------------------
4
+ //
5
+ // Every transport implements the same HarnessSession contract. Switching how a
6
+ // model is reached (native CLI on a subscription, a warm app-server, the Agent
7
+ // SDK, ACP, a metered API) is a routing change, not a rewrite. Each transport
8
+ // declares what it can actually do; callers branch on capabilities, never on
9
+ // the transport kind.
10
+ //
11
+ // Capabilities describe this package's implementation, not what the runtime
12
+ // could do in principle. A native feature that exists but is not wired up here
13
+ // is declared absent, with a note.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export type TransportKind =
17
+ | 'claude-cli'
18
+ | 'codex-mcp'
19
+ | 'codex-app-server'
20
+ | 'claude-agent-sdk'
21
+ | 'acp'
22
+ | 'api';
23
+
24
+ export type TransportRuntime = 'claude' | 'codex' | 'external';
25
+
26
+ export interface TransportCapabilities {
27
+ /** Continue a thread by native ID in a new process (`loaded-only`: only while the original process holds it). */
28
+ readonly resume: 'native' | 'loaded-only' | 'none';
29
+ /** `native`: a new branch with its own ID; `same-thread`: continues the parent thread (not a real fork). */
30
+ readonly fork: 'native' | 'same-thread' | 'none';
31
+ /** Drop the last N turns of native history. */
32
+ readonly rollback: 'native' | 'none';
33
+ /** `acknowledged`: interruptNative() confirms the stop through the turn's terminal; `local-only`: interrupt() releases the local waiter only. */
34
+ readonly interrupt: 'acknowledged' | 'local-only';
35
+ /** `steer`: push() adds input to the in-flight turn; `none`: push() records push_ignored. */
36
+ readonly push: 'steer' | 'none';
37
+ /** `callback`: per-call decision hook; `mode`: a fixed launch policy (bypass); `refuse`: runtime requests are refused. */
38
+ readonly approvals: 'callback' | 'mode' | 'refuse';
39
+ /** Token usage reported per turn. */
40
+ readonly usage: boolean;
41
+ /** Account limits: `stream` as rate_limit events during turns, `poll` via readLimits() without a model turn. */
42
+ readonly limits: { readonly stream: boolean; readonly poll: boolean };
43
+ /** A tool-free, text-only session can be enforced (decisions). */
44
+ readonly textOnly: boolean;
45
+ /** Primed decision host on this transport and how it returns to its primed state each cycle (see primed.ts). */
46
+ readonly primed: 'fork' | 'none';
47
+ readonly billing: 'subscription' | 'api';
48
+ }
49
+
50
+ export interface TransportDescriptor {
51
+ readonly kind: TransportKind;
52
+ readonly runtime: TransportRuntime;
53
+ /** `stub`: the session constructor throws TransportUnavailableError. */
54
+ readonly status: 'implemented' | 'stub';
55
+ /** Strongest evidence behind the capabilities: `live` runs against the real CLI, `controlled` uses fake processes only. */
56
+ readonly verified: 'live' | 'controlled' | 'none';
57
+ readonly capabilities: TransportCapabilities;
58
+ readonly notes: readonly string[];
59
+ }
60
+
61
+ const none = { stream: false, poll: false } as const;
62
+
63
+ export const TRANSPORTS: Readonly<Record<TransportKind, TransportDescriptor>> = Object.freeze({
64
+ 'claude-cli': {
65
+ kind: 'claude-cli',
66
+ runtime: 'claude',
67
+ status: 'implemented',
68
+ verified: 'live',
69
+ capabilities: {
70
+ resume: 'native',
71
+ fork: 'native',
72
+ rollback: 'none',
73
+ interrupt: 'acknowledged',
74
+ push: 'none',
75
+ approvals: 'mode',
76
+ usage: true,
77
+ limits: { stream: true, poll: true },
78
+ textOnly: true,
79
+ primed: 'fork',
80
+ billing: 'subscription',
81
+ },
82
+ notes: [
83
+ 'Persistent `claude --print --input-format stream-json` process.',
84
+ 'textOnly launches with no tools, MCP servers, slash commands or user settings.',
85
+ 'Primed decisions (ClaudePrimedSessions) fork a persisted primed session per cycle with a pre-spawned spare.',
86
+ "interruptNative() and readLimits() use the CLI's stdio control protocol (`interrupt`, `get_usage`).",
87
+ 'Rollback (`--resume-session-at`) and per-call approvals (`--permission-prompt-tool stdio`) exist natively but are not wired here.',
88
+ ],
89
+ },
90
+ 'codex-mcp': {
91
+ kind: 'codex-mcp',
92
+ runtime: 'codex',
93
+ status: 'implemented',
94
+ verified: 'live',
95
+ capabilities: {
96
+ resume: 'loaded-only',
97
+ fork: 'same-thread',
98
+ rollback: 'none',
99
+ interrupt: 'local-only',
100
+ push: 'none',
101
+ approvals: 'mode',
102
+ usage: true,
103
+ limits: none,
104
+ textOnly: false,
105
+ primed: 'none',
106
+ billing: 'subscription',
107
+ },
108
+ notes: [
109
+ '`codex mcp-server`; `codex-reply` continues only threads loaded in the same process.',
110
+ 'fork() continues the parent thread; it is not a native fork.',
111
+ ],
112
+ },
113
+ 'codex-app-server': {
114
+ kind: 'codex-app-server',
115
+ runtime: 'codex',
116
+ status: 'implemented',
117
+ verified: 'live',
118
+ capabilities: {
119
+ resume: 'native',
120
+ fork: 'none',
121
+ rollback: 'none',
122
+ interrupt: 'acknowledged',
123
+ push: 'steer',
124
+ approvals: 'refuse',
125
+ usage: true,
126
+ limits: { stream: true, poll: true },
127
+ textOnly: false,
128
+ primed: 'fork',
129
+ billing: 'subscription',
130
+ },
131
+ notes: [
132
+ '`codex app-server --listen stdio://` with thread/start, thread/resume, turn/start.',
133
+ "CodexAppServerSession runs with the profile's tools; tool-free text sessions are CodexPrimedSessions (primed: fork).",
134
+ 'interruptNative() sends turn/interrupt; push() sends turn/steer against the owned turn.',
135
+ 'Primed decisions (CodexPrimedSessions) host every key as a thread of one process per account and fork the primed thread per cycle.',
136
+ 'thread/rollback is refused by codex-cli 0.155 for both ephemeral and paginated threads; session fork() is not wired.',
137
+ ],
138
+ },
139
+ 'claude-agent-sdk': {
140
+ kind: 'claude-agent-sdk',
141
+ runtime: 'claude',
142
+ status: 'implemented',
143
+ verified: 'live',
144
+ capabilities: {
145
+ resume: 'native',
146
+ fork: 'native',
147
+ rollback: 'none',
148
+ interrupt: 'acknowledged',
149
+ push: 'none',
150
+ approvals: 'callback',
151
+ usage: true,
152
+ limits: { stream: true, poll: false },
153
+ textOnly: true,
154
+ primed: 'none',
155
+ billing: 'subscription',
156
+ },
157
+ notes: [
158
+ 'Drives `@anthropic-ai/claude-agent-sdk` query() supplied by the caller; this package keeps zero dependencies.',
159
+ "Reuses ClaudeCodeSession's event classification and admission evidence over the SDK message stream.",
160
+ ],
161
+ },
162
+ acp: {
163
+ kind: 'acp',
164
+ runtime: 'external',
165
+ status: 'stub',
166
+ verified: 'none',
167
+ capabilities: {
168
+ resume: 'none',
169
+ fork: 'none',
170
+ rollback: 'none',
171
+ interrupt: 'local-only',
172
+ push: 'none',
173
+ approvals: 'refuse',
174
+ usage: false,
175
+ limits: none,
176
+ textOnly: false,
177
+ primed: 'none',
178
+ billing: 'subscription',
179
+ },
180
+ notes: [
181
+ 'Agent Client Protocol (JSON-RPC over stdio). Not implemented: no ACP agent is available to verify against.',
182
+ ],
183
+ },
184
+ api: {
185
+ kind: 'api',
186
+ runtime: 'external',
187
+ status: 'stub',
188
+ verified: 'none',
189
+ capabilities: {
190
+ resume: 'none',
191
+ fork: 'none',
192
+ rollback: 'none',
193
+ interrupt: 'local-only',
194
+ push: 'none',
195
+ approvals: 'refuse',
196
+ usage: false,
197
+ limits: none,
198
+ textOnly: false,
199
+ primed: 'none',
200
+ billing: 'api',
201
+ },
202
+ notes: ['Direct metered API. Explicit opt-in only; not implemented.'],
203
+ },
204
+ } satisfies Record<TransportKind, TransportDescriptor>);
205
+
206
+ /** Thrown by stub transports. Never falls back to another transport. */
207
+ export class TransportUnavailableError extends Error {
208
+ constructor(
209
+ readonly kind: TransportKind,
210
+ detail: string,
211
+ ) {
212
+ super(`Transport "${kind}" is not available: ${detail}`);
213
+ this.name = 'TransportUnavailableError';
214
+ }
215
+ }
@@ -0,0 +1,97 @@
1
+ // Construct a HarnessSession by transport kind. Switching how a model is
2
+ // reached is a change of `kind` (a routing decision), not of calling code.
3
+
4
+ import {
5
+ ClaudeAgentSdkSession,
6
+ type ClaudeAgentSdkSessionConfig,
7
+ } from './claude-agent-sdk-session';
8
+ import { ClaudeCodeSession, type ClaudeCodeSessionConfig } from './claude-code-session';
9
+ import { CodexAppServerSession, CodexMcpSession, type CodexSessionConfig } from './codex-session';
10
+ import type { HarnessSession } from './harness-session';
11
+ import {
12
+ TRANSPORTS,
13
+ type TransportDescriptor,
14
+ type TransportKind,
15
+ TransportUnavailableError,
16
+ } from './transport';
17
+
18
+ /** Agent Client Protocol agent launched over stdio. Typed for callers; not implemented. */
19
+ export interface AcpSessionConfig {
20
+ /** Agent command, e.g. ["gemini", "--experimental-acp"]. */
21
+ command: readonly string[];
22
+ cwd?: string;
23
+ env?: Record<string, string | undefined>;
24
+ timeout?: number;
25
+ }
26
+
27
+ /** Direct metered API. Requires an explicit opt-in and an explicit key; never read from the environment. */
28
+ export interface ApiSessionConfig {
29
+ provider: 'anthropic' | 'openai';
30
+ model: string;
31
+ apiKey: string;
32
+ allowApiBilling: true;
33
+ baseContext?: string;
34
+ timeout?: number;
35
+ }
36
+
37
+ export interface TransportConfigs {
38
+ 'claude-cli': ClaudeCodeSessionConfig;
39
+ 'codex-mcp': CodexSessionConfig;
40
+ 'codex-app-server': CodexSessionConfig;
41
+ 'claude-agent-sdk': ClaudeAgentSdkSessionConfig;
42
+ acp: AcpSessionConfig;
43
+ api: ApiSessionConfig;
44
+ }
45
+
46
+ /** Typed stub: constructing it throws TransportUnavailableError. */
47
+ export class AcpSession {
48
+ constructor(_config: AcpSessionConfig) {
49
+ throw new TransportUnavailableError(
50
+ 'acp',
51
+ 'no ACP client is implemented in this package; use claude-cli, claude-agent-sdk or codex-app-server',
52
+ );
53
+ }
54
+ }
55
+
56
+ /** Typed stub: constructing it throws TransportUnavailableError, even with the opt-in set. */
57
+ export class ApiSession {
58
+ constructor(config: ApiSessionConfig) {
59
+ if (config?.allowApiBilling !== true)
60
+ throw new TransportUnavailableError(
61
+ 'api',
62
+ 'metered API billing requires allowApiBilling: true',
63
+ );
64
+ throw new TransportUnavailableError(
65
+ 'api',
66
+ 'the direct API transport is not implemented in this package; subscription transports never fall back to it',
67
+ );
68
+ }
69
+ }
70
+
71
+ export function describeTransport(kind: TransportKind): TransportDescriptor {
72
+ const descriptor = TRANSPORTS[kind];
73
+ if (!descriptor) throw Error(`Unknown transport "${String(kind)}"`);
74
+ return descriptor;
75
+ }
76
+
77
+ export function createSession<K extends TransportKind>(
78
+ kind: K,
79
+ config: TransportConfigs[K],
80
+ ): HarnessSession {
81
+ switch (kind) {
82
+ case 'claude-cli':
83
+ return new ClaudeCodeSession(config as ClaudeCodeSessionConfig);
84
+ case 'codex-mcp':
85
+ return new CodexMcpSession(config as CodexSessionConfig);
86
+ case 'codex-app-server':
87
+ return new CodexAppServerSession(config as CodexSessionConfig);
88
+ case 'claude-agent-sdk':
89
+ return new ClaudeAgentSdkSession(config as ClaudeAgentSdkSessionConfig);
90
+ case 'acp':
91
+ return new AcpSession(config as AcpSessionConfig) as unknown as HarnessSession;
92
+ case 'api':
93
+ return new ApiSession(config as ApiSessionConfig) as unknown as HarnessSession;
94
+ default:
95
+ throw Error(`Unknown transport "${String(kind)}"`);
96
+ }
97
+ }
package/src/turn-state.ts CHANGED
@@ -1,42 +1,72 @@
1
- import { SessionTurnError, type SessionAttempt, type SessionEvent, type SessionIdentity, type SessionResult, type SessionTokens } from "./harness-session";
2
- import { retainEvidence } from "./retained-evidence";
1
+ import {
2
+ type SessionAttempt,
3
+ type SessionEvent,
4
+ type SessionIdentity,
5
+ type SessionResult,
6
+ type SessionTokens,
7
+ SessionTurnError,
8
+ } from './harness-session';
9
+ import { retainEvidence } from './retained-evidence';
3
10
  /** Per-admission evidence. Caller settlement never transfers native ownership. */
4
11
  export class TurnState {
5
12
  readonly admissionId = crypto.randomUUID();
6
13
  identity: SessionIdentity = {};
7
- nativeOutcome: SessionAttempt["nativeOutcome"] = "unknown";
8
- localOutcome: SessionAttempt["localOutcome"] = "pending";
9
- dispatch: SessionAttempt["dispatch"] = "not-dispatched";
10
- transportOutcome: SessionAttempt["transportOutcome"] = "open";
11
- private _terminal?: SessionAttempt["terminal"];
12
- get terminal() { return this._terminal; }
13
- set terminal(value: SessionAttempt["terminal"]) { this._terminal = retainEvidence(value); }
14
+ nativeOutcome: SessionAttempt['nativeOutcome'] = 'unknown';
15
+ localOutcome: SessionAttempt['localOutcome'] = 'pending';
16
+ dispatch: SessionAttempt['dispatch'] = 'not-dispatched';
17
+ transportOutcome: SessionAttempt['transportOutcome'] = 'open';
18
+ private _terminal?: SessionAttempt['terminal'];
19
+ get terminal() {
20
+ return this._terminal;
21
+ }
22
+ set terminal(value: SessionAttempt['terminal']) {
23
+ this._terminal = retainEvidence(value);
24
+ }
14
25
  events: SessionEvent[] = [];
15
- content = "";
26
+ content = '';
16
27
  private _tokens?: SessionTokens;
17
- get tokens() { return this._tokens; }
18
- set tokens(value: SessionTokens | undefined) { this._tokens = retainEvidence(value); }
19
- localFailure?: SessionAttempt["localFailure"];
28
+ get tokens() {
29
+ return this._tokens;
30
+ }
31
+ set tokens(value: SessionTokens | undefined) {
32
+ this._tokens = retainEvidence(value);
33
+ }
34
+ localFailure?: SessionAttempt['localFailure'];
20
35
  rpcSettled = false;
21
- rpcOutcome?: SessionAttempt["rpcOutcome"];
36
+ rpcOutcome?: SessionAttempt['rpcOutcome'];
22
37
  snapshot(): SessionAttempt {
23
- return Object.freeze({ ...this.identity, admissionId: this.admissionId, nativeOutcome: this.nativeOutcome,
24
- localOutcome: this.localOutcome, dispatch: this.dispatch, transportOutcome: this.transportOutcome,
25
- rpcOutcome: this.rpcOutcome, terminal: this.terminal, localFailure: this.localFailure,
26
- content: this.content, events: Object.freeze([...this.events]), tokens: this.tokens });
27
- }
28
- result(externalSessionId?: string): SessionResult { return Object.freeze({ ...this.snapshot(), externalSessionId }); }
29
- fail(error: Error, reason: SessionAttempt["localFailure"]): SessionTurnError {
30
- if (this.localOutcome === "pending") { this.localOutcome = "rejected"; this.localFailure = reason; }
38
+ return Object.freeze({
39
+ ...this.identity,
40
+ admissionId: this.admissionId,
41
+ nativeOutcome: this.nativeOutcome,
42
+ localOutcome: this.localOutcome,
43
+ dispatch: this.dispatch,
44
+ transportOutcome: this.transportOutcome,
45
+ rpcOutcome: this.rpcOutcome,
46
+ terminal: this.terminal,
47
+ localFailure: this.localFailure,
48
+ content: this.content,
49
+ events: Object.freeze([...this.events]),
50
+ tokens: this.tokens,
51
+ });
52
+ }
53
+ result(externalSessionId?: string): SessionResult {
54
+ return Object.freeze({ ...this.snapshot(), externalSessionId });
55
+ }
56
+ fail(error: Error, reason: SessionAttempt['localFailure']): SessionTurnError {
57
+ if (this.localOutcome === 'pending') {
58
+ this.localOutcome = 'rejected';
59
+ this.localFailure = reason;
60
+ }
31
61
  return new SessionTurnError(error.message, () => this.snapshot(), { cause: error });
32
62
  }
33
63
  record(event: SessionEvent): SessionEvent {
34
64
  const e = retainEvidence({ ...this.identity, ...event, admissionId: this.admissionId });
35
65
  this.events.push(e);
36
- if ((e.kind === "text" || e.kind === "result") && e.text) this.content = e.text;
37
- if (e.kind === "text_delta" && e.text) this.content += e.text;
66
+ if ((e.kind === 'text' || e.kind === 'result') && e.text) this.content = e.text;
67
+ if (e.kind === 'text_delta' && e.text) this.content += e.text;
38
68
  // Usage events are per-request snapshots; the terminal result carries the turn total.
39
- if (e.tokens && e.kind !== "usage") this.tokens = e.tokens;
69
+ if (e.tokens && e.kind !== 'usage') this.tokens = e.tokens;
40
70
  return e;
41
71
  }
42
72
  }