llms-ink 0.7.2 → 0.8.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,10 +307,10 @@ 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.2 @anthropic-ai/claude-agent-sdk@0.3.319
310
+ npm install llms-ink@0.8.1 @anthropic-ai/claude-agent-sdk@0.3.319
311
311
  ```
312
312
 
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:
313
+ First [start the Python 0.14.0 local service](https://llms.ink/guard/service/) with a reviewed gateway configuration and existing Claude authentication. Then try the bundled terminal host:
314
314
 
315
315
  ```sh
316
316
  node node_modules/llms-ink/examples/guard-claude.mjs \
@@ -325,11 +325,11 @@ For application hosts, import `createGuardedClaude` and pass absolute `ownerFile
325
325
 
326
326
  The default fixed session TTL is 900 seconds (`ttl`, maximum 86400), with no renewal. Each turn has a 120000 ms deadline (`turnTimeoutMs`, maximum 600000). Reviews stop after 55 seconds. The adapter configures no native tools, other MCP connections, plugins or local hooks. SDK `maxTurns` defaults to 12 and `maxBudgetUsd` to 1; SDK cost estimates are not a hard billing limit. Optional `model` and `claudeExecutable` select the host model/binary.
327
327
 
328
- `GuardHostError` reports incomplete cleanup with `sessionId` and `credentialDirectory`. If revocation cannot be confirmed, the private credential file remains for recovery; retry `close()` or revoke through the owner CLI. A host process killed without cleanup can leave authority until the service's TTL. The owner process, SDK and approval UI are trusted; private files do not isolate malicious same-user code. This is not a process sandbox, exposure ledger or automatic desktop plugin. Compaction does not establish clean context.
328
+ `GuardHostError` reports incomplete cleanup with `sessionId` and `credentialDirectory`. If revocation cannot be confirmed, the private credential file remains for recovery; retry `close()` or revoke through the owner CLI. The adapter creates a host-bound session and keeps a separate owner watcher connected through an idle pipe. The service refuses attachment before that binding and revokes on observed watcher disconnection; killing the host closes its pipe and ends the watcher. A stopped watcher or inherited open pipe can delay observation until fixed expiry. No heartbeat, renewal, resume or crash-proof guarantee is added. The owner process, SDK and approval UI are trusted; private files do not isolate malicious same-user code. This is not a process sandbox, exposure ledger or automatic desktop plugin. Compaction does not establish clean context.
329
329
 
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
- 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.
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.14.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
333
 
334
334
  ### Guard host diagnostics
335
335
 
@@ -337,9 +337,10 @@ Lifecycle tests use real service sockets and subprocess effects with a simulated
337
337
 
338
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
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_watch_failed`: the owner lifetime channel failed or ended. The conversation closes; start a new one after checking the local service. This code alone does not distinguish watcher failure from external revocation.
340
341
  - `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
342
  - `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
343
  - `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
+ - `revocation_unconfirmed`, `host_cleanup_failed`, `host_watch_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
 
345
346
  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.
@@ -8,7 +8,7 @@ export interface GuardReview {
8
8
  export interface GuardedClaudeOptions {
9
9
  /** Private owner credential. This path is used by the control process, never sent to Claude. */
10
10
  ownerFile: string;
11
- /** Absolute Python interpreter with llms-ink[guard] 0.10.0 installed. */
11
+ /** Absolute Python interpreter with llms-ink[guard] 0.14.0+ installed. */
12
12
  python: string;
13
13
  /** Called for each fresh approval. Only literal true accepts. Errors, cancellation and timeout decline. */
14
14
  review: (request: GuardReview) => Promise<boolean>;
@@ -32,7 +32,7 @@ export interface GuardedClaudeConversation {
32
32
  close(): Promise<void>;
33
33
  }
34
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';
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' | 'host_watch_failed' | 'host_watch_cleanup_failed';
36
36
  export declare class GuardHostError extends Error {
37
37
  readonly sessionId: string | undefined;
38
38
  readonly credentialDirectory: string | undefined;
@@ -44,6 +44,7 @@ export declare class GuardHostError extends Error {
44
44
  /**
45
45
  * Create one non-resumable, tool-scoped conversation. New conversations require new instances.
46
46
  * The owner process, SDK and review callback are trusted. This is not an OS sandbox.
47
- * A process crash leaves authority bounded by the fixed TTL; there is no crash-proof lease or exposure ledger.
47
+ * A private owner watcher revokes authority on observed host disconnect, with a fixed TTL backstop.
48
+ * A stopped host/watcher can retain its socket until TTL; this is not an exposure ledger or a sandbox.
48
49
  */
49
50
  export declare function createGuardedClaude(options: GuardedClaudeOptions): Promise<GuardedClaudeConversation>;
@@ -1,5 +1,5 @@
1
1
  /** Node/POSIX host integration for explicitly scoped Claude Agent SDK conversations. */
2
- import { execFile } from 'node:child_process';
2
+ import { execFile, spawn } from 'node:child_process';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { mkdtemp, chmod, lstat, readFile, rm } from 'node:fs/promises';
5
5
  import { tmpdir } from 'node:os';
@@ -47,6 +47,69 @@ function control(python, args) {
47
47
  }
48
48
  }));
49
49
  }
50
+ async function watchHost(python, ownerFile, sessionId, lost) {
51
+ const child = spawn(python, ['-m', 'llms_ink.guard', 'session-watch', '--owner-file', ownerFile, '--session-id', sessionId], { stdio: ['pipe', 'pipe', 'ignore'] });
52
+ let expected = false, finished = false, acknowledged = false, data = '';
53
+ let accept = () => { }, reject = () => { };
54
+ const ready = new Promise((resolve, fail) => { accept = resolve; reject = () => fail(new GuardHostError('Host lifetime binding failed.', undefined, undefined, 'host_watch_failed')); });
55
+ const failure = () => { reject(); if (!expected)
56
+ lost(); };
57
+ const ended = new Promise(resolve => child.once('close', () => { finished = true; failure(); resolve(); }));
58
+ child.once('error', failure);
59
+ child.stdin.on('error', failure);
60
+ child.stdout.once('end', () => { if (!expected)
61
+ failure(); });
62
+ child.stdout.on('data', (chunk) => {
63
+ if (acknowledged || chunk.length + Buffer.byteLength(data) > 16384) {
64
+ failure();
65
+ return;
66
+ }
67
+ data += chunk.toString('utf8');
68
+ if (!data.includes('\n'))
69
+ return;
70
+ try {
71
+ const value = JSON.parse(data);
72
+ if (!sameShape(value, { ok: true, sessionId }))
73
+ throw new Error();
74
+ acknowledged = true;
75
+ data = '';
76
+ accept();
77
+ }
78
+ catch {
79
+ failure();
80
+ }
81
+ });
82
+ async function waitForExit(ms) {
83
+ let timer;
84
+ try {
85
+ return await Promise.race([ended.then(() => true), new Promise(resolve => { timer = setTimeout(() => resolve(false), ms); })]);
86
+ }
87
+ finally {
88
+ clearTimeout(timer);
89
+ }
90
+ }
91
+ const watcher = { async stop() {
92
+ expected = true;
93
+ child.stdin.destroy();
94
+ if (finished || await waitForExit(5000))
95
+ return;
96
+ child.kill('SIGKILL');
97
+ if (!await waitForExit(2000))
98
+ throw new GuardHostError('Host watcher cleanup did not complete.', undefined, undefined, 'host_watch_cleanup_failed');
99
+ } };
100
+ const timer = setTimeout(failure, 30000);
101
+ try {
102
+ await ready;
103
+ return watcher;
104
+ }
105
+ catch (error) {
106
+ await watcher.stop();
107
+ throw error;
108
+ }
109
+ finally {
110
+ clearTimeout(timer);
111
+ }
112
+ }
50
113
  class Input {
51
114
  pending;
52
115
  wake;
@@ -75,7 +138,8 @@ class Input {
75
138
  /**
76
139
  * Create one non-resumable, tool-scoped conversation. New conversations require new instances.
77
140
  * The owner process, SDK and review callback are trusted. This is not an OS sandbox.
78
- * A process crash leaves authority bounded by the fixed TTL; there is no crash-proof lease or exposure ledger.
141
+ * A private owner watcher revokes authority on observed host disconnect, with a fixed TTL backstop.
142
+ * A stopped host/watcher can retain its socket until TTL; this is not an exposure ledger or a sandbox.
79
143
  */
80
144
  export async function createGuardedClaude(options) {
81
145
  options = { ...options };
@@ -91,6 +155,7 @@ export async function createGuardedClaude(options) {
91
155
  const directory = await mkdtemp(join(tmpdir(), 'ink-host-'));
92
156
  await chmod(directory, 0o700);
93
157
  const file = join(directory, 'session.json');
158
+ const issueStarted = performance.now();
94
159
  let sessionId;
95
160
  async function revoke() {
96
161
  if (!sessionId)
@@ -112,7 +177,7 @@ export async function createGuardedClaude(options) {
112
177
  }
113
178
  }
114
179
  try {
115
- const result = await control(python, ['session-create', '--owner-file', ownerFile, '--output', file, '--ttl', String(ttl)]);
180
+ const result = await control(python, ['session-create', '--owner-file', ownerFile, '--output', file, '--ttl', String(ttl), '--host-bound']);
116
181
  if (typeof result.sessionId !== 'string' || !idPattern.test(result.sessionId))
117
182
  throw new Error();
118
183
  sessionId = result.sessionId;
@@ -143,6 +208,7 @@ export async function createGuardedClaude(options) {
143
208
  const hostId = randomUUID(), input = new Input(), abort = new AbortController();
144
209
  let closing = false, closed = false, busy = false, initialized = false, closeWork;
145
210
  let host;
211
+ let watcher;
146
212
  let expiresTimer;
147
213
  let stopReason;
148
214
  function cleanupError(error, operationCode) {
@@ -189,6 +255,12 @@ export async function createGuardedClaude(options) {
189
255
  catch {
190
256
  failure ??= new GuardHostError('Claude host cleanup did not complete. Retry close.', guardId, directory, 'host_cleanup_failed');
191
257
  }
258
+ try {
259
+ await watcher?.stop();
260
+ }
261
+ catch {
262
+ failure ??= new GuardHostError('Host watcher cleanup did not complete. Retry close.', guardId, directory, 'host_watch_cleanup_failed');
263
+ }
192
264
  if (failure)
193
265
  throw failure;
194
266
  try {
@@ -233,6 +305,15 @@ export async function createGuardedClaude(options) {
233
305
  }
234
306
  }
235
307
  try {
308
+ watcher = await watchHost(python, ownerFile, guardId, () => {
309
+ if (!closing) {
310
+ stopReason = performance.now() - issueStarted >= ttl * 1000 ? 'session_expired' : 'host_watch_failed';
311
+ if (watcher)
312
+ void close().catch(() => { });
313
+ }
314
+ });
315
+ if (closing || stopReason)
316
+ throw new Error();
236
317
  const sdkOptions = {
237
318
  abortController: abort, sessionId: hostId, persistSession: false, settingSources: [], plugins: [], tools: [],
238
319
  strictMcpConfig: true, mcpServers: { guarded: { command: python, args: ['-m', 'llms_ink.guard', 'connect', '--session-file', file] } },
@@ -246,22 +327,33 @@ export async function createGuardedClaude(options) {
246
327
  host = query({ prompt: input, options: sdkOptions });
247
328
  // Revocation on idle expiry does not wait for another prompt. Failures remain
248
329
  // observable through close(); the service independently enforces its TTL.
249
- expiresTimer = setTimeout(() => { stopReason ??= 'session_expired'; void close().catch(() => { }); }, ttl * 1000);
330
+ expiresTimer = setTimeout(() => { stopReason ??= 'session_expired'; void close().catch(() => { }); }, Math.max(0, ttl * 1000 - (performance.now() - issueStarted)));
250
331
  expiresTimer.unref();
251
332
  }
252
333
  catch {
334
+ const code = stopReason ?? (watcher ? 'host_startup_failed' : 'host_watch_failed');
253
335
  try {
254
336
  await close();
255
337
  }
256
338
  catch (error) {
257
- throw cleanupError(error, 'host_startup_failed');
339
+ throw cleanupError(error, code);
258
340
  }
259
- throw new GuardHostError('Claude host startup failed.', undefined, undefined, 'host_startup_failed');
341
+ throw new GuardHostError('Claude host startup failed.', undefined, undefined, code);
260
342
  }
261
343
  return Object.freeze({ sessionId: guardId,
262
344
  async run(prompt, onMessage) {
263
- if (closing || busy)
264
- throw new GuardHostError('The conversation is closed or another turn is running.', undefined, undefined, closing ? (stopReason ?? 'conversation_closed') : 'host_error');
345
+ if (closing) {
346
+ const code = stopReason ?? 'conversation_closed';
347
+ try {
348
+ await close();
349
+ }
350
+ catch (error) {
351
+ throw cleanupError(error, code);
352
+ }
353
+ throw new GuardHostError('The conversation is closed.', undefined, undefined, code);
354
+ }
355
+ if (busy)
356
+ throw new GuardHostError('Another turn is running.');
265
357
  if (typeof prompt !== 'string' || !prompt.trim() || prompt.trimStart().startsWith('/') || Buffer.byteLength(prompt) > 131072)
266
358
  throw new GuardHostError('Provide a text prompt up to 128 KiB; host commands require a new conversation.');
267
359
  busy = true;
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.2";
18
+ export declare const VERSION = "0.8.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.2';
18
+ export const VERSION = '0.8.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).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "llms-ink",
3
- "version": "0.7.2",
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.",
3
+ "version": "0.8.1",
4
+ "description": "Turn any link \u2014 site, docs section, page, GitHub repo, PDF or video \u2014 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": {
7
7
  ".": {