llms-ink 0.7.0 → 0.7.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
@@ -307,7 +307,7 @@ JavaScript uses a 65-second total request deadline; pass `{ signal }` to cancel
307
307
  `llms-ink/guard/claude` binds one Claude Agent SDK conversation to one authenticated local Guard session. Install the optional peer separately:
308
308
 
309
309
  ```sh
310
- npm install llms-ink@0.7.0 @anthropic-ai/claude-agent-sdk@0.3.319
310
+ npm install llms-ink@0.7.1 @anthropic-ai/claude-agent-sdk@0.3.319
311
311
  ```
312
312
 
313
313
  First [start the Python 0.10.0 local service](https://llms.ink/guard/service/) with a reviewed gateway configuration and existing Claude authentication. Then try the bundled terminal host:
@@ -330,3 +330,16 @@ The default fixed session TTL is 900 seconds (`ttl`, maximum 86400), with no ren
330
330
  Allowed results go to Claude using the host's authentication, after local inspection. The adapter does not call hosted Inspect. Other SDK entry points do not import the optional Claude dependency. [Guide](https://llms.ink/guard/claude/) · [Runnable example](examples/guard-claude.mjs).
331
331
 
332
332
  Lifecycle tests use real service sockets and subprocess effects with a simulated SDK loop. Run them with `LLMS_INK_GUARD_TEST_PYTHON=/absolute/python npm run test:guard` after installing Python `llms-ink==0.10.0`. The separately opted-in `test:guard:live` requires `LLMS_INK_CLAUDE_LIVE=1`, existing Claude authentication and the optional local model when `LLMS_INK_HOST_REAL_MODEL=1`. Scripted fixture approvals are interoperability evidence, not independent human review or proof of attack prevention.
333
+
334
+ ### Guard host diagnostics
335
+
336
+ `GuardHostError.code` identifies the failed local check or a fixed host-reported result category. `operationCode` preserves the preceding operation when cleanup fails; the primary `code` then describes cleanup. The terminal example prints both. No raw provider error, prompt, tool output, command or credential is copied into these codes.
337
+
338
+ - `service_status_failed` / `session_inactive`: check the local service with `llms-ink-guard doctor --config FILE --owner-file FILE` (Python 0.12.0). Start a new conversation after resolving it.
339
+ - `guard_connection_failed` / `unexpected_tools` / `session_mismatch` / `host_not_initialized`: the host did not establish the expected guarded conversation. Check host compatibility and configuration; permissions are not broadened.
340
+ - `host_stream_failed` / `host_stream_ended` / `host_startup_failed`: the host failed to start or continue. These codes do not establish a provider, authentication or network cause.
341
+ - `host_result_error` / `host_turn_limit` / `host_budget_limit` / `host_output_retry_limit`: the host reported an error or one of its configured limits. Only known subtype names are mapped; error text is omitted.
342
+ - `message_callback_failed`: the application output callback failed. `turn_timeout`, `session_expired` and `conversation_closed` distinguish adapter deadlines and closure.
343
+ - `revocation_unconfirmed`, `host_cleanup_failed` or `credential_cleanup_failed`: retain the reported private directory and retry `close()` or use owner-controlled revocation. Do not treat cleanup as complete.
344
+
345
+ A failed running turn closes the conversation and attempts revocation; no automatic retry or unguarded fallback occurs. Successful turns may already have performed approved actions, and an error can occur after a tool effect: these codes are not proof of non-execution. Session IDs and recovery paths remain available only for cleanup failures and should not be posted publicly. `host_error` is the fallback for argument/state errors and externally constructed errors.
@@ -31,10 +31,15 @@ export interface GuardedClaudeConversation {
31
31
  /** Revoke authority and close the host. Idempotent after confirmed cleanup; retry on cleanup errors. */
32
32
  close(): Promise<void>;
33
33
  }
34
+ /** Local failure categories. No provider text, tool output or credentials are copied into these values. */
35
+ export type GuardHostErrorCode = 'host_error' | 'session_create_failed' | 'host_startup_failed' | 'service_status_failed' | 'session_inactive' | 'host_stream_failed' | 'host_stream_ended' | 'session_mismatch' | 'guard_connection_failed' | 'unexpected_tools' | 'host_not_initialized' | 'message_callback_failed' | 'host_result_error' | 'host_turn_limit' | 'host_budget_limit' | 'host_output_retry_limit' | 'turn_timeout' | 'session_expired' | 'conversation_closed' | 'revocation_unconfirmed' | 'host_cleanup_failed' | 'credential_cleanup_failed';
34
36
  export declare class GuardHostError extends Error {
35
37
  readonly sessionId: string | undefined;
36
38
  readonly credentialDirectory: string | undefined;
37
- constructor(message: string, sessionId?: string, credentialDirectory?: string);
39
+ readonly code: GuardHostErrorCode;
40
+ /** Original operation failure when cleanup itself failed. Cleanup remains the primary error. */
41
+ readonly operationCode: GuardHostErrorCode | undefined;
42
+ constructor(message: string, sessionId?: string, credentialDirectory?: string, code?: GuardHostErrorCode, operationCode?: GuardHostErrorCode);
38
43
  }
39
44
  /**
40
45
  * Create one non-resumable, tool-scoped conversation. New conversations require new instances.
@@ -8,11 +8,16 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
8
8
  export class GuardHostError extends Error {
9
9
  sessionId;
10
10
  credentialDirectory;
11
- constructor(message, sessionId, credentialDirectory) {
11
+ code;
12
+ /** Original operation failure when cleanup itself failed. Cleanup remains the primary error. */
13
+ operationCode;
14
+ constructor(message, sessionId, credentialDirectory, code = 'host_error', operationCode) {
12
15
  super(message);
13
16
  this.name = 'GuardHostError';
14
17
  this.sessionId = sessionId;
15
18
  this.credentialDirectory = credentialDirectory;
19
+ this.code = code;
20
+ this.operationCode = operationCode;
16
21
  }
17
22
  }
18
23
  const idPattern = /^[a-f0-9]{32}$/;
@@ -102,7 +107,7 @@ export async function createGuardedClaude(options) {
102
107
  throw new Error();
103
108
  }
104
109
  catch {
105
- throw new GuardHostError('Session revocation could not be confirmed. Keep the credential directory and retry close or revoke it with the owner CLI.', sessionId, directory);
110
+ throw new GuardHostError('Session revocation could not be confirmed. Keep the credential directory and retry close or revoke it with the owner CLI.', sessionId, directory, 'revocation_unconfirmed');
106
111
  }
107
112
  }
108
113
  }
@@ -125,15 +130,26 @@ export async function createGuardedClaude(options) {
125
130
  }
126
131
  catch { /* no recoverable credential */ }
127
132
  }
128
- await revoke();
129
- await rm(directory, { recursive: true, force: true });
130
- throw new GuardHostError('Could not create a guarded conversation.');
133
+ try {
134
+ await revoke();
135
+ await rm(directory, { recursive: true, force: true });
136
+ }
137
+ catch (error) {
138
+ throw cleanupError(error, 'session_create_failed');
139
+ }
140
+ throw new GuardHostError('Could not create a guarded conversation.', undefined, undefined, 'session_create_failed');
131
141
  }
132
142
  const guardId = sessionId;
133
143
  const hostId = randomUUID(), input = new Input(), abort = new AbortController();
134
144
  let closing = false, closed = false, busy = false, initialized = false, closeWork;
135
145
  let host;
136
146
  let expiresTimer;
147
+ let stopReason;
148
+ function cleanupError(error, operationCode) {
149
+ if (error instanceof GuardHostError)
150
+ return new GuardHostError(error.message, error.sessionId, error.credentialDirectory, error.code, operationCode);
151
+ return new GuardHostError('Credential cleanup did not complete. Retry cleanup.', sessionId, directory, 'credential_cleanup_failed', operationCode);
152
+ }
137
153
  async function whileOpen(work) {
138
154
  if (abort.signal.aborted)
139
155
  throw new GuardHostError('Conversation closed.');
@@ -154,6 +170,7 @@ export async function createGuardedClaude(options) {
154
170
  return;
155
171
  if (closeWork)
156
172
  return closeWork;
173
+ stopReason ??= 'conversation_closed';
157
174
  closing = true;
158
175
  input.close();
159
176
  abort.abort();
@@ -170,11 +187,16 @@ export async function createGuardedClaude(options) {
170
187
  host?.close();
171
188
  }
172
189
  catch {
173
- failure ??= new GuardHostError('Claude host cleanup did not complete. Retry close.', guardId, directory);
190
+ failure ??= new GuardHostError('Claude host cleanup did not complete. Retry close.', guardId, directory, 'host_cleanup_failed');
174
191
  }
175
192
  if (failure)
176
193
  throw failure;
177
- await rm(directory, { recursive: true, force: true });
194
+ try {
195
+ await rm(directory, { recursive: true, force: true });
196
+ }
197
+ catch {
198
+ throw new GuardHostError('Credential cleanup did not complete. Retry close.', guardId, directory, 'credential_cleanup_failed');
199
+ }
178
200
  closed = true;
179
201
  })();
180
202
  try {
@@ -224,53 +246,74 @@ export async function createGuardedClaude(options) {
224
246
  host = query({ prompt: input, options: sdkOptions });
225
247
  // Revocation on idle expiry does not wait for another prompt. Failures remain
226
248
  // observable through close(); the service independently enforces its TTL.
227
- expiresTimer = setTimeout(() => { void close().catch(() => { }); }, ttl * 1000);
249
+ expiresTimer = setTimeout(() => { stopReason ??= 'session_expired'; void close().catch(() => { }); }, ttl * 1000);
228
250
  expiresTimer.unref();
229
251
  }
230
252
  catch {
231
- await close();
232
- throw new GuardHostError('Claude host startup failed.');
253
+ try {
254
+ await close();
255
+ }
256
+ catch (error) {
257
+ throw cleanupError(error, 'host_startup_failed');
258
+ }
259
+ throw new GuardHostError('Claude host startup failed.', undefined, undefined, 'host_startup_failed');
233
260
  }
234
261
  return Object.freeze({ sessionId: guardId,
235
262
  async run(prompt, onMessage) {
236
263
  if (closing || busy)
237
- throw new GuardHostError('The conversation is closed or another turn is running.');
264
+ throw new GuardHostError('The conversation is closed or another turn is running.', undefined, undefined, closing ? (stopReason ?? 'conversation_closed') : 'host_error');
238
265
  if (typeof prompt !== 'string' || !prompt.trim() || prompt.trimStart().startsWith('/') || Buffer.byteLength(prompt) > 131072)
239
266
  throw new GuardHostError('Provide a text prompt up to 128 KiB; host commands require a new conversation.');
240
267
  busy = true;
241
- const timer = setTimeout(() => { void close().catch(() => { }); }, timeout);
268
+ let failureCode = 'service_status_failed';
269
+ function fail(code) { failureCode = code; throw new Error(); }
270
+ const timer = setTimeout(() => { stopReason ??= 'turn_timeout'; void close().catch(() => { }); }, timeout);
242
271
  try {
243
272
  const state = await control(python, ['service-status', '--owner-file', ownerFile]);
244
273
  if (closing || state.ok !== true || !Array.isArray(state.sessions) || !state.sessions.some(s => s?.sessionId === guardId && s.active === true))
245
- throw new Error();
274
+ fail('session_inactive');
275
+ failureCode = 'host_stream_failed';
246
276
  input.send(prompt, hostId);
247
277
  while (true) {
278
+ failureCode = 'host_stream_failed';
248
279
  const next = await whileOpen(host.next());
249
280
  if (next.done || closing)
250
- throw new Error();
281
+ fail('host_stream_ended');
251
282
  const message = next.value;
252
283
  if ('session_id' in message && message.session_id !== hostId)
253
- throw new Error();
284
+ fail('session_mismatch');
254
285
  if (message.type === 'system' && message.subtype === 'init') {
255
- if (message.mcp_servers.length !== 1 || message.mcp_servers[0].name !== 'guarded' || message.mcp_servers[0].status !== 'connected'
256
- || !message.tools.length || message.tools.some(t => !t.startsWith('mcp__guarded__')))
257
- throw new Error();
286
+ if (!Array.isArray(message.mcp_servers) || message.mcp_servers.length !== 1 || message.mcp_servers[0]?.name !== 'guarded' || message.mcp_servers[0]?.status !== 'connected')
287
+ fail('guard_connection_failed');
288
+ if (!Array.isArray(message.tools) || !message.tools.length || message.tools.some(t => typeof t !== 'string' || !t.startsWith('mcp__guarded__')))
289
+ fail('unexpected_tools');
258
290
  initialized = true;
259
291
  }
292
+ // Error results can precede initialization. Only fixed subtype names are retained.
293
+ if (message.type === 'result' && message.is_error) {
294
+ const code = message.subtype === 'error_max_turns' ? 'host_turn_limit' : message.subtype === 'error_max_budget_usd' ? 'host_budget_limit' : message.subtype === 'error_max_structured_output_retries' ? 'host_output_retry_limit' : 'host_result_error';
295
+ fail(code);
296
+ }
260
297
  if (!initialized && message.type !== 'system')
261
- throw new Error();
262
- if (onMessage)
298
+ fail('host_not_initialized');
299
+ if (onMessage) {
300
+ failureCode = 'message_callback_failed';
263
301
  await whileOpen(Promise.resolve().then(() => onMessage(message)));
302
+ }
264
303
  if (message.type === 'result') {
265
- if (message.is_error)
266
- throw new Error();
267
304
  return message;
268
305
  }
269
306
  }
270
307
  }
271
308
  catch {
272
- await close();
273
- throw new GuardHostError('Guarded turn did not complete; the conversation was closed.');
309
+ const code = stopReason ?? failureCode;
310
+ try {
311
+ await close();
312
+ }
313
+ catch (error) {
314
+ throw cleanupError(error, code);
315
+ }
316
+ throw new GuardHostError(`Guarded turn did not complete (${code}); the conversation was closed.`, undefined, undefined, code);
274
317
  }
275
318
  finally {
276
319
  clearTimeout(timer);
package/dist/index.d.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import { type InspectionReceipt } from './inspect.ts';
17
17
  export type { InspectionReceipt } from './inspect.ts';
18
- export declare const VERSION = "0.7.0";
18
+ export declare const VERSION = "0.7.1";
19
19
  export declare const DEFAULT_BASE_URL = "https://llms.ink";
20
20
  /** `llms` for the index (llms.txt), `full` for llms-full.txt. */
21
21
  export type Mode = 'llms' | 'full';
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import { prepareInspection, inspectionJson, inspectionReceipt } from "./inspect.js";
17
17
  import { env } from "./env.js";
18
- export const VERSION = '0.7.0';
18
+ export const VERSION = '0.7.1';
19
19
  export const DEFAULT_BASE_URL = 'https://llms.ink';
20
20
  /**
21
21
  * An API error, or llms.ink couldn't be reached (then `status` and `code` are undefined).
@@ -2,7 +2,7 @@
2
2
  // Run from an installed llms-ink package with the optional Claude SDK peer installed.
3
3
  import {parseArgs} from 'node:util';
4
4
  import {createInterface} from 'node:readline/promises';
5
- import {createGuardedClaude} from '../dist/guard-claude.js';
5
+ import {createGuardedClaude,GuardHostError} from '../dist/guard-claude.js';
6
6
  const {values}=parseArgs({options:{'owner-file':{type:'string'},python:{type:'string'},cwd:{type:'string'},model:{type:'string'},'claude-executable':{type:'string'}}});
7
7
  if(!process.stdin.isTTY || !process.stdout.isTTY) throw Error('This example requires an interactive terminal for each approval.');
8
8
  if(!values['owner-file'] || !values.python || !values.cwd) throw Error('Pass --owner-file, --python and --cwd with absolute paths.');
@@ -10,6 +10,15 @@ const terminal=createInterface({input:process.stdin,output:process.stdout});
10
10
  // Preserve complete text while making terminal controls and bidi controls visible.
11
11
  const plain=text=>text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g,c=>'\\u'+c.charCodeAt(0).toString(16).padStart(4,'0'));
12
12
  let conversation,stopping=false;
13
+ const report=error=>{
14
+ console.error(error instanceof GuardHostError?error.message:'The terminal conversation did not complete.');
15
+ if(error instanceof GuardHostError){
16
+ console.error('Failure code:',error.code);
17
+ if(error.operationCode)console.error('Original operation:',error.operationCode);
18
+ if(error.sessionId)console.error('Session ID:',error.sessionId);
19
+ if(error.credentialDirectory)console.error('Credential directory:',error.credentialDirectory);
20
+ }
21
+ };
13
22
  const stop=()=>{stopping=true;terminal.close();void conversation?.close().catch(()=>{});};
14
23
  terminal.on('SIGINT',stop);terminal.on('close',()=>{stopping=true;void conversation?.close().catch(()=>{});});process.on('SIGTERM',stop);
15
24
  try{
@@ -27,8 +36,8 @@ try{
27
36
  if(message.type==='assistant')for(const part of message.message.content)if(part.type==='text')process.stdout.write(plain(part.text)+'\n');
28
37
  });
29
38
  }
30
- }catch(error){if(!stopping){console.error(error.message);process.exitCode=1;}}
39
+ }catch(error){if(!stopping){report(error);process.exitCode=1;}}
31
40
  finally{
32
41
  terminal.close();process.removeListener('SIGTERM',stop);
33
- try{await conversation?.close();}catch(error){console.error(error.message);if(error.sessionId)console.error('Session ID:',error.sessionId);if(error.credentialDirectory)console.error('Credential directory:',error.credentialDirectory);process.exitCode=1;}
42
+ try{await conversation?.close();}catch(error){report(error);process.exitCode=1;}
34
43
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llms-ink",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Turn any link — site, docs section, page, GitHub repo, PDF or video — into llms.txt and llms-full.txt. Typed client for llms.ink, plus a Vite plugin, an Astro integration and middleware that serve the files from your site, and Vercel AI SDK tools. No dependencies.",
5
5
  "type": "module",
6
6
  "exports": {