@link-assistant/agent 0.25.8 → 0.26.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.
package/README.md CHANGED
@@ -376,6 +376,30 @@ echo "hi" | agent --json-standard claude
376
376
  - **Event Types**: `init`, `message`, `tool_use`, `tool_result`, `result`
377
377
  - **Timestamps**: ISO 8601 strings
378
378
  - **Session ID**: `session_id` (snake_case)
379
+ - **Model**: the `init` event reports the resolved model as `"model": "<provider>/<model>"`
380
+
381
+ #### Model Routing Attestation
382
+
383
+ Both standards emit exactly one `model_resolved` event per run, immediately after
384
+ the model is resolved and before the first request reaches a provider:
385
+
386
+ ```json
387
+ {
388
+ "type": "model_resolved",
389
+ "timestamp": "2026-08-11T00:12:31.320Z",
390
+ "requested": "formalai/formal-ai",
391
+ "selector": "formalai/formal-ai",
392
+ "providerID": "formalai",
393
+ "modelID": "formal-ai",
394
+ "source": "cli",
395
+ "matchesRequest": true
396
+ }
397
+ ```
398
+
399
+ Switch on `type` rather than matching the human-readable
400
+ `using explicit provider/model` log message — the log message is prose and may be
401
+ reworded, the event type is a contract. See
402
+ [docs/model-resolved-event.md](../docs/model-resolved-event.md).
379
403
 
380
404
  ### Input Formats
381
405
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.25.8",
3
+ "version": "0.26.1",
4
4
  "description": "A minimal, public domain AI CLI agent compatible with OpenCode's JSON interface. Bun-only runtime.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -50,8 +50,6 @@
50
50
  "LICENSE"
51
51
  ],
52
52
  "dependencies": {
53
- "@actions/core": "^1.11.1",
54
- "@actions/github": "^6.0.1",
55
53
  "@agentclientprotocol/sdk": "^0.5.1",
56
54
  "@ai-sdk/amazon-bedrock": "^3.0.74",
57
55
  "@ai-sdk/anthropic": "^2.0.58",
@@ -134,5 +132,12 @@
134
132
  "prettier --check"
135
133
  ]
136
134
  },
137
- "license": "Unlicense"
135
+ "license": "Unlicense",
136
+ "allowScripts": {
137
+ "@parcel/watcher@2.5.1": true,
138
+ "tree-sitter-bash@0.25.1": true
139
+ },
140
+ "overrides": {
141
+ "undici": "^7.16.0"
142
+ }
138
143
  }
@@ -1,7 +1,7 @@
1
1
  import { Config } from '../config/file-config';
2
2
  import z from 'zod';
3
3
  import { Provider } from '../provider/provider';
4
- import { generateObject, type ModelMessage } from 'ai';
4
+ import { generateObject } from 'ai';
5
5
  import PROMPT_GENERATE from './generate.txt';
6
6
  import { SystemPrompt } from '../session/system';
7
7
  import { Instance } from '../project/instance';
@@ -138,13 +138,14 @@ export namespace Agent {
138
138
  const existing = await list();
139
139
  const result = await generateObject({
140
140
  temperature: 0.3,
141
+ // The system prompt belongs in `system`, not in `prompt`: the AI SDK
142
+ // warns that a system message inside the prompt enables prompt
143
+ // injection, and providers are free to treat it as ordinary content.
144
+ system: system.map((content) => ({
145
+ role: 'system' as const,
146
+ content,
147
+ })),
141
148
  prompt: [
142
- ...system.map(
143
- (item): ModelMessage => ({
144
- role: 'system',
145
- content: item,
146
- })
147
- ),
148
149
  {
149
150
  role: 'user',
150
151
  content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(', ')}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
@@ -303,8 +303,11 @@ export async function runContinuousServerMode(
303
303
  }
304
304
  }
305
305
 
306
- // Create event handler for the selected JSON standard
307
- const eventHandler = createEventHandler(jsonStandard, sessionID);
306
+ // Create event handler for the selected JSON standard.
307
+ // The resolved model rides along so the Claude `init` event reports it (#295).
308
+ const eventHandler = createEventHandler(jsonStandard, sessionID, {
309
+ model: `${providerID}/${modelID}`,
310
+ });
308
311
 
309
312
  // Track if we're currently processing a message
310
313
  let isProcessing = false;
@@ -526,8 +529,11 @@ export async function runContinuousDirectMode(
526
529
  sessionID = session.id;
527
530
  }
528
531
 
529
- // Create event handler for the selected JSON standard
530
- const eventHandler = createEventHandler(jsonStandard, sessionID);
532
+ // Create event handler for the selected JSON standard.
533
+ // The resolved model rides along so the Claude `init` event reports it (#295).
534
+ const eventHandler = createEventHandler(jsonStandard, sessionID, {
535
+ model: `${providerID}/${modelID}`,
536
+ });
531
537
 
532
538
  // Track if we're currently processing a message
533
539
  let isProcessing = false;
@@ -10,10 +10,12 @@ import {
10
10
  DEFAULT_MODEL_ID,
11
11
  getDefaultModel,
12
12
  getDefaultModelParts,
13
+ getDefaultModelSource,
13
14
  getDefaultCompactionModel,
14
15
  getDefaultCompactionModels,
15
16
  getDefaultCompactionSafetyMarginPercent,
16
17
  } from './defaults.ts';
18
+ import { outputModelResolved } from './model-resolution.ts';
17
19
 
18
20
  export class ModelResolutionError extends Error {
19
21
  constructor({ modelArgv, defaultModel }) {
@@ -96,6 +98,19 @@ export async function parseModelConfig(
96
98
  }
97
99
  }
98
100
 
101
+ // Attestation inputs (#295): record what was requested, and where it came
102
+ // from, before resolution can rewrite the effective model. `argv.model` is
103
+ // only treated as a request when it differs from the default, because yargs
104
+ // fills it with the default value even when no --model flag was passed.
105
+ const argvModelArg =
106
+ typeof argv.model === 'string' && argv.model !== defaultModel
107
+ ? argv.model
108
+ : null;
109
+ const requestedModel = cliModelArg ?? argvModelArg;
110
+ const modelSource = requestedModel
111
+ ? 'cli'
112
+ : getDefaultModelSource(defaultOptions);
113
+
99
114
  let providerID;
100
115
  let modelID;
101
116
 
@@ -257,6 +272,28 @@ export async function parseModelConfig(
257
272
  }
258
273
  }
259
274
 
275
+ // Machine-readable routing attestation (#295).
276
+ // Emitted last inside this function, which is still the earliest point where
277
+ // the effective model is final: --use-existing-claude-oauth above is the one
278
+ // path that can replace an already selected model, and the attestation must
279
+ // describe what actually runs. It is emitted before session creation and
280
+ // before any completion request, so a consumer can terminate the run before a
281
+ // request reaches a provider it did not ask for. Claude NDJSON streams must
282
+ // stay one line, so that standard forces compact output regardless of
283
+ // --compact-json.
284
+ const usesClaudeStandard =
285
+ argv['json-standard'] === 'claude' || argv.jsonStandard === 'claude';
286
+ outputModelResolved(
287
+ {
288
+ requested: requestedModel,
289
+ selector: modelArg,
290
+ providerID,
291
+ modelID,
292
+ source: modelSource,
293
+ },
294
+ usesClaudeStandard || argv['compact-json'] === true ? true : undefined
295
+ );
296
+
260
297
  return { providerID, modelID, compactionModel: compactionModelResult };
261
298
  }
262
299
 
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Machine-readable model routing attestation.
3
+ *
4
+ * The CLI already logs which provider/model it settled on, but that record is
5
+ * discriminated by a free-text `message` ("using explicit provider/model").
6
+ * A consumer that must *verify* routing — for example a supervisor that kills
7
+ * the run when the agent resolves anything other than the requested model —
8
+ * would have to match English prose, so an ordinary reword upstream silently
9
+ * disables a downstream safety check.
10
+ *
11
+ * `model_resolved` is the contract instead: one event per run, emitted on
12
+ * every resolution path (explicit `provider/model`, bare model id and default),
13
+ * before the first `step_start` and before any completion request.
14
+ *
15
+ * @see https://github.com/link-assistant/agent/issues/295
16
+ */
17
+
18
+ import { output } from './output';
19
+
20
+ /**
21
+ * Stable `type` of the routing attestation event.
22
+ *
23
+ * This string is a machine contract: consumers switch on it. Never reword it —
24
+ * a change here is a breaking change, unlike a change to any log message.
25
+ */
26
+ export const MODEL_RESOLVED_EVENT_TYPE = 'model_resolved';
27
+
28
+ /**
29
+ * Where the applied model selector came from.
30
+ * - `cli`: the `--model`/`-m` command line argument.
31
+ * - `config`: no CLI flag; an operator-configured default is in effect.
32
+ * - `default`: no CLI flag and no override; the built-in default model.
33
+ */
34
+ export type ModelSource = 'cli' | 'config' | 'default';
35
+
36
+ export interface ModelResolvedDetails {
37
+ /** Raw `--model` value, or `null` when no model was requested. */
38
+ requested: string | null;
39
+ /** Selector actually applied: `requested`, or the effective default. */
40
+ selector: string;
41
+ providerID: string;
42
+ modelID: string;
43
+ source: ModelSource;
44
+ /** ISO timestamp; injectable so tests stay deterministic. */
45
+ timestamp?: string;
46
+ }
47
+
48
+ export interface ModelResolvedEvent extends ModelResolvedDetails {
49
+ type: typeof MODEL_RESOLVED_EVENT_TYPE;
50
+ timestamp: string;
51
+ /**
52
+ * Whether the resolved provider/model satisfies `selector`. `false` means the
53
+ * run is about to talk to a model other than the one that was selected, even
54
+ * when the selector came from a default rather than from `--model`.
55
+ */
56
+ matchesRequest: boolean;
57
+ [key: string]: unknown;
58
+ }
59
+
60
+ /**
61
+ * Does `providerID`/`modelID` satisfy the selector the caller supplied?
62
+ *
63
+ * A selector with a `/` names both provider and model and must match both.
64
+ * A bare selector names only the model, so the resolved provider is free.
65
+ */
66
+ export function selectorMatchesModel(
67
+ selector: string,
68
+ providerID: string,
69
+ modelID: string
70
+ ): boolean {
71
+ if (selector.includes('/')) {
72
+ return selector === `${providerID}/${modelID}`;
73
+ }
74
+ return selector === modelID;
75
+ }
76
+
77
+ /** Build the attestation event without emitting it (pure; used by tests). */
78
+ export function buildModelResolvedEvent(
79
+ details: ModelResolvedDetails
80
+ ): ModelResolvedEvent {
81
+ const { requested, selector, providerID, modelID, source, timestamp } =
82
+ details;
83
+ return {
84
+ type: MODEL_RESOLVED_EVENT_TYPE,
85
+ timestamp: timestamp ?? new Date().toISOString(),
86
+ requested: requested ?? null,
87
+ selector,
88
+ providerID,
89
+ modelID,
90
+ source,
91
+ matchesRequest: selectorMatchesModel(selector, providerID, modelID),
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Emit the attestation on stdout and return the emitted event.
97
+ *
98
+ * @param compact - Force single-line JSON (required for NDJSON streams such as
99
+ * `--output-format stream-json`); defaults to the global compact setting.
100
+ */
101
+ export function outputModelResolved(
102
+ details: ModelResolvedDetails,
103
+ compact?: boolean
104
+ ): ModelResolvedEvent {
105
+ const event = buildModelResolvedEvent(details);
106
+ output(event, compact);
107
+ return event;
108
+ }
package/src/cli/output.ts CHANGED
@@ -28,7 +28,9 @@ export type OutputType =
28
28
  | 'init'
29
29
  | 'message'
30
30
  | 'tool_result'
31
- | 'input';
31
+ | 'input'
32
+ // Routing attestation emitted once per run, see cli/model-resolution.ts (#295)
33
+ | 'model_resolved';
32
34
 
33
35
  /**
34
36
  * Base interface for all output messages
@@ -112,6 +112,25 @@ export function getDefaultModel(options: DefaultConfigOptions = {}): string {
112
112
  );
113
113
  }
114
114
 
115
+ /**
116
+ * Report where the effective default model came from.
117
+ *
118
+ * `'config'` means an operator override is in effect (`defaultOptions.defaultModel`
119
+ * or `LINK_ASSISTANT_AGENT_DEFAULT_MODEL`); `'default'` means the built-in
120
+ * `DEFAULT_MODEL`. Used by the `model_resolved` attestation so a consumer can
121
+ * tell a configured fallback from the shipped one.
122
+ *
123
+ * @see https://github.com/link-assistant/agent/issues/295
124
+ */
125
+ export function getDefaultModelSource(
126
+ options: DefaultConfigOptions = {}
127
+ ): 'config' | 'default' {
128
+ const env = options.env ?? process.env;
129
+ if (optionString(options.defaultModel)) return 'config';
130
+ if (envString(env, DEFAULT_MODEL_ENV)) return 'config';
131
+ return 'default';
132
+ }
133
+
115
134
  export function getDefaultCompactionModel(
116
135
  options: DefaultConfigOptions = {}
117
136
  ): string {
package/src/index.js CHANGED
@@ -486,8 +486,11 @@ async function runServerMode(
486
486
  }
487
487
  }
488
488
 
489
- // Create event handler for the selected JSON standard
490
- const eventHandler = createEventHandler(jsonStandard, sessionID);
489
+ // Create event handler for the selected JSON standard.
490
+ // The resolved model rides along so the Claude `init` event reports it (#295).
491
+ const eventHandler = createEventHandler(jsonStandard, sessionID, {
492
+ model: `${providerID}/${modelID}`,
493
+ });
491
494
 
492
495
  // Subscribe to all bus events and output in selected format
493
496
  const { unsub: eventUnsub, idlePromise: eventPromise } =
@@ -580,8 +583,11 @@ async function runDirectMode(
580
583
  sessionID = session.id;
581
584
  }
582
585
 
583
- // Create event handler for the selected JSON standard
584
- const eventHandler = createEventHandler(jsonStandard, sessionID);
586
+ // Create event handler for the selected JSON standard.
587
+ // The resolved model rides along so the Claude `init` event reports it (#295).
588
+ const eventHandler = createEventHandler(jsonStandard, sessionID, {
589
+ model: `${providerID}/${modelID}`,
590
+ });
585
591
 
586
592
  // Subscribe to all bus events and output in selected format
587
593
  const { unsub: eventUnsub, idlePromise: eventPromise } =
@@ -75,10 +75,14 @@ export function serializeOutput(
75
75
 
76
76
  /**
77
77
  * Convert OpenCode event to Claude event format
78
+ *
79
+ * @param model - Resolved `providerID/modelID`, reported on the `init` event so
80
+ * Claude-standard consumers see which model the run settled on (#295)
78
81
  */
79
82
  export function convertOpenCodeToClaude(
80
83
  event: OpenCodeEvent,
81
- startTime: number
84
+ startTime: number,
85
+ model?: string
82
86
  ): ClaudeEvent | null {
83
87
  const timestamp = new Date(event.timestamp).toISOString();
84
88
  const session_id = event.sessionID;
@@ -89,6 +93,7 @@ export function convertOpenCodeToClaude(
89
93
  type: 'init',
90
94
  timestamp,
91
95
  session_id,
96
+ ...(model ? { model } : {}),
92
97
  };
93
98
 
94
99
  case 'text':
@@ -157,9 +162,17 @@ export function convertOpenCodeToClaude(
157
162
 
158
163
  /**
159
164
  * Create an event output handler based on the selected standard
165
+ *
166
+ * @param options.model - Resolved `providerID/modelID` for the run; carried on
167
+ * the Claude `init` event (#295)
160
168
  */
161
- export function createEventHandler(standard: JsonStandard, sessionID: string) {
169
+ export function createEventHandler(
170
+ standard: JsonStandard,
171
+ sessionID: string,
172
+ options: { model?: string } = {}
173
+ ) {
162
174
  const startTime = Date.now();
175
+ const { model } = options;
163
176
 
164
177
  return {
165
178
  /**
@@ -169,7 +182,7 @@ export function createEventHandler(standard: JsonStandard, sessionID: string) {
169
182
  const outputStream =
170
183
  event.type === 'error' ? process.stderr : process.stdout;
171
184
  if (standard === 'claude') {
172
- const claudeEvent = convertOpenCodeToClaude(event, startTime);
185
+ const claudeEvent = convertOpenCodeToClaude(event, startTime, model);
173
186
  if (claudeEvent) {
174
187
  outputStream.write(serializeOutput(claudeEvent, standard));
175
188
  }
@@ -58,6 +58,7 @@ export const Instance = {
58
58
  async dispose() {
59
59
  Log.Default.info('disposing instance', { directory: Instance.directory });
60
60
  await State.dispose(Instance.directory);
61
+ await Project.prune();
61
62
  },
62
63
  async disposeAll() {
63
64
  Log.Default.info('disposing all instances');
@@ -4,9 +4,12 @@ import path from 'path';
4
4
  import { $ } from 'bun';
5
5
  import { Storage } from '../storage/storage';
6
6
  import { Log } from '../util/log';
7
+ import fs from 'node:fs/promises';
8
+ import { Global } from '../global';
7
9
 
8
10
  export namespace Project {
9
11
  const log = Log.create({ service: 'project' });
12
+ const recentSnapshotAge = 15 * 60_000;
10
13
  export const Info = z
11
14
  .object({
12
15
  id: z.string(),
@@ -24,6 +27,7 @@ export namespace Project {
24
27
 
25
28
  export async function fromDirectory(directory: string) {
26
29
  log.info(() => ({ message: 'fromDirectory', directory }));
30
+ await prune();
27
31
  const matches = Filesystem.up({ targets: ['.git'], start: directory });
28
32
  const git = await matches.next().then((x) => x.value);
29
33
  await matches.return();
@@ -101,4 +105,66 @@ export namespace Project {
101
105
  const keys = await Storage.list(['project']);
102
106
  return await Promise.all(keys.map((x) => Storage.read<Info>(x)));
103
107
  }
108
+
109
+ export async function prune() {
110
+ const root = path.join(Global.Path.data, 'snapshot');
111
+ const entries = await fs
112
+ .readdir(root, { withFileTypes: true })
113
+ .catch(() => []);
114
+ const snapshots: { id: string; path: string; modified: number }[] = [];
115
+ for (const entry of entries) {
116
+ if (!entry.isDirectory()) continue;
117
+ const snapshot = path.join(root, entry.name);
118
+ const modified = await fs
119
+ .stat(snapshot)
120
+ .then((stat) => stat.mtimeMs)
121
+ .catch(() => undefined);
122
+ if (modified === undefined) continue;
123
+ snapshots.push({ id: entry.name, path: snapshot, modified });
124
+ }
125
+ snapshots.sort((a, b) => b.modified - a.modified);
126
+
127
+ const newest = snapshots[0]?.id;
128
+ const recent = Date.now() - recentSnapshotAge;
129
+ for (const snapshot of snapshots) {
130
+ if (snapshot.id === newest || snapshot.modified >= recent) continue;
131
+ const project = await Storage.read<Info>(['project', snapshot.id]).catch(
132
+ () => undefined
133
+ );
134
+ const live = project
135
+ ? await fs
136
+ .stat(project.worktree)
137
+ .then((stat) => stat.isDirectory())
138
+ .catch(() => false)
139
+ : false;
140
+ if (live) continue;
141
+
142
+ await remove(snapshot).catch((error) =>
143
+ log.warn(() => ({
144
+ message: 'failed to prune snapshot',
145
+ projectID: snapshot.id,
146
+ error: error instanceof Error ? error.message : String(error),
147
+ }))
148
+ );
149
+ }
150
+ }
151
+
152
+ async function remove(snapshot: { id: string; path: string }) {
153
+ await fs.rm(snapshot.path, { recursive: true, force: true });
154
+ for (const session of await Storage.list(['session', snapshot.id])) {
155
+ const sessionID = session.at(-1)!;
156
+ for (const message of await Storage.list(['message', sessionID])) {
157
+ const messageID = message.at(-1)!;
158
+ for (const part of await Storage.list(['part', messageID])) {
159
+ await Storage.remove(part);
160
+ }
161
+ await Storage.remove(message);
162
+ }
163
+ await Storage.remove(['session_diff', sessionID]);
164
+ await Storage.remove(['todo', sessionID]);
165
+ await Storage.remove(session);
166
+ }
167
+ await Storage.remove(['project', snapshot.id]);
168
+ log.info(() => ({ message: 'pruned snapshot', projectID: snapshot.id }));
169
+ }
104
170
  }
@@ -2,7 +2,7 @@ import { Provider } from '../provider/provider';
2
2
  import { fn } from '../util/fn';
3
3
  import z from 'zod';
4
4
  import { Session } from '.';
5
- import { generateText, type ModelMessage } from 'ai';
5
+ import { generateText } from 'ai';
6
6
  import { MessageV2 } from './message-v2';
7
7
  import { Identifier } from '../id/id';
8
8
  import { Snapshot } from '../snapshot';
@@ -187,13 +187,14 @@ export namespace SessionSummary {
187
187
  model.providerID,
188
188
  {}
189
189
  ),
190
+ // The system prompt belongs in `system`, not in `messages`: the AI SDK
191
+ // warns that a system message inside `messages` enables prompt
192
+ // injection, and providers are free to treat it as ordinary content.
193
+ system: systemPrompts.map((content) => ({
194
+ role: 'system' as const,
195
+ content,
196
+ })),
190
197
  messages: [
191
- ...systemPrompts.map(
192
- (x): ModelMessage => ({
193
- role: 'system',
194
- content: x,
195
- })
196
- ),
197
198
  {
198
199
  role: 'user' as const,
199
200
  content: userContent,
@@ -30,6 +30,25 @@ export namespace Snapshot {
30
30
  .nothrow();
31
31
  log.info(() => ({ message: 'initialized' }));
32
32
  }
33
+ const objects =
34
+ await $`git rev-parse --path-format=absolute --git-path objects`
35
+ .quiet()
36
+ .cwd(Instance.worktree)
37
+ .nothrow();
38
+ if (objects.exitCode === 0) {
39
+ const info = path.join(git, 'objects', 'info');
40
+ await fs.mkdir(info, { recursive: true });
41
+ await fs.writeFile(
42
+ path.join(info, 'alternates'),
43
+ `${objects.text().trim()}\n`
44
+ );
45
+ } else {
46
+ log.warn(() => ({
47
+ message: 'failed to resolve repository objects',
48
+ exitCode: objects.exitCode,
49
+ stderr: objects.stderr.toString(),
50
+ }));
51
+ }
33
52
  await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`
34
53
  .quiet()
35
54
  .cwd(Instance.directory)