@schlenkr/ragents 0.1.17 → 0.1.19

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
@@ -41,7 +41,11 @@ npm install -g @schlenkr/ragents
41
41
  ```
42
42
 
43
43
  - `workspace-client <server-url> [folder ...]` - register this machine and its folders as a
44
- workspace for a server, so a run on that server works in these folders.
44
+ workspace for a server, so a run on that server works in these folders. Includes the same
45
+ ripgrep and Windows Bash as the VS Code extension. `RAGENTS_USER` and `RAGENTS_PASSWORD` in
46
+ the environment enable session renewal; `RAGENTS_TOKEN` supplies an existing or personal token.
47
+ Terminal or parent closure stops it and its children. Use `--detached` explicitly for nohup
48
+ or a service manager; SIGINT/SIGTERM still stop it.
45
49
 
46
50
  ```sh
47
51
  ragents workspace-client https://ragents.example.com ~/projects/demo
@@ -130,7 +130,7 @@
130
130
  <a class="nav-github" href="https://github.com/SchlenkR/RAgents" aria-label="GitHub"><svg class="gh-icon" viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 2a10 10 0 0 0-3.2 19.5c.5.1.7-.2.7-.5v-1.7c-2.8.6-3.4-1.3-3.4-1.3-.5-1.2-1.1-1.5-1.1-1.5-.9-.6.1-.6.1-.6 1 .1 1.5 1 1.5 1 .9 1.6 2.4 1.1 3 .9.1-.7.4-1.1.6-1.4-2.2-.3-4.6-1.1-4.6-5 0-1.1.4-2 1-2.7-.1-.3-.4-1.3.1-2.7 0 0 .8-.3 2.8 1a9.6 9.6 0 0 1 5 0c1.9-1.3 2.8-1 2.8-1 .5 1.4.2 2.4.1 2.7.6.7 1 1.6 1 2.7 0 3.9-2.4 4.7-4.6 5 .4.3.7.9.7 1.9V21c0 .3.2.6.7.5A10 10 0 0 0 12 2Z"/></svg><span class="gh-label">GitHub</span></a>
131
131
  </nav>
132
132
  <button class="nav-menu" type="button" aria-expanded="false" aria-controls="main-navigation"><span data-menu-label>Menu</span><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></button>
133
- </header><main class="shell" id="main"><div class="guide-layout"><aside class="guide-sidebar"><nav class="guide-chapters" aria-label="Guide navigation"><a href="guide.html">Overview</a><div class="guide-nav-group"><p>Understand</p><a href="guide-ideas.html">Core ideas</a><a href="guide-runtime.html">Actors and messages</a></div><div class="guide-nav-group"><p>Use</p><a href="guide-getting-started.html">Get started</a><a href="guide-clients.html">Web and VS Code</a><a href="guide-distributed.html" aria-current="page">Distributed work</a></div><div class="guide-nav-group"><p>Build</p><a href="guide-functions.html">Work with TypeScript</a><a href="guide-programs.html">Build mini-apps</a><a href="guide-plugins.html">Plugins and skills</a></div><div class="guide-nav-group"><p>Access</p><a href="guide-access.html">Assign permissions</a></div></nav></aside><details class="guide-mobile-nav"><summary>Guide: Distributed work</summary><nav class="guide-chapters" aria-label="Guide navigation"><a href="guide.html">Overview</a><div class="guide-nav-group"><p>Understand</p><a href="guide-ideas.html">Core ideas</a><a href="guide-runtime.html">Actors and messages</a></div><div class="guide-nav-group"><p>Use</p><a href="guide-getting-started.html">Get started</a><a href="guide-clients.html">Web and VS Code</a><a href="guide-distributed.html" aria-current="page">Distributed work</a></div><div class="guide-nav-group"><p>Build</p><a href="guide-functions.html">Work with TypeScript</a><a href="guide-programs.html">Build mini-apps</a><a href="guide-plugins.html">Plugins and skills</a></div><div class="guide-nav-group"><p>Access</p><a href="guide-access.html">Assign permissions</a></div></nav></details><article class="guide-body"><header class="guide-title"><p class="eyebrow"><a href="guide.html">Guide</a></p><h1>Distributed work</h1><p>Run the host locally or remotely, keep tools beside the project, and move runs between servers.</p></header><details class="guide-page-nav"><summary>On this page</summary><nav aria-label="On this page"><a href="#work-without-a-checkout">Work without a checkout</a><a href="#connect-to-a-server">Connect to a server</a><a href="#transfer-a-run">Transfer a run</a><a href="#work-on-windows">Work on Windows</a></nav></details><h2 id="work-without-a-checkout">Work without a checkout</h2>
133
+ </header><main class="shell" id="main"><div class="guide-layout"><aside class="guide-sidebar"><nav class="guide-chapters" aria-label="Guide navigation"><a href="guide.html">Overview</a><div class="guide-nav-group"><p>Understand</p><a href="guide-ideas.html">Core ideas</a><a href="guide-runtime.html">Actors and messages</a></div><div class="guide-nav-group"><p>Use</p><a href="guide-getting-started.html">Get started</a><a href="guide-clients.html">Web and VS Code</a><a href="guide-distributed.html" aria-current="page">Distributed work</a></div><div class="guide-nav-group"><p>Build</p><a href="guide-functions.html">Work with TypeScript</a><a href="guide-programs.html">Build mini-apps</a><a href="guide-plugins.html">Plugins and skills</a></div><div class="guide-nav-group"><p>Access</p><a href="guide-access.html">Assign permissions</a></div></nav></aside><details class="guide-mobile-nav"><summary>Guide: Distributed work</summary><nav class="guide-chapters" aria-label="Guide navigation"><a href="guide.html">Overview</a><div class="guide-nav-group"><p>Understand</p><a href="guide-ideas.html">Core ideas</a><a href="guide-runtime.html">Actors and messages</a></div><div class="guide-nav-group"><p>Use</p><a href="guide-getting-started.html">Get started</a><a href="guide-clients.html">Web and VS Code</a><a href="guide-distributed.html" aria-current="page">Distributed work</a></div><div class="guide-nav-group"><p>Build</p><a href="guide-functions.html">Work with TypeScript</a><a href="guide-programs.html">Build mini-apps</a><a href="guide-plugins.html">Plugins and skills</a></div><div class="guide-nav-group"><p>Access</p><a href="guide-access.html">Assign permissions</a></div></nav></details><article class="guide-body"><header class="guide-title"><p class="eyebrow"><a href="guide.html">Guide</a></p><h1>Distributed work</h1><p>Run the host locally or remotely, keep tools beside the project, and move runs between servers.</p></header><details class="guide-page-nav"><summary>On this page</summary><nav aria-label="On this page"><a href="#work-without-a-checkout">Work without a checkout</a><a href="#connect-to-a-server">Connect to a server</a><a href="#run-a-cli-workstation">Run a CLI workstation</a><a href="#transfer-a-run">Transfer a run</a><a href="#work-on-windows">Work on Windows</a></nav></details><h2 id="work-without-a-checkout">Work without a checkout</h2>
134
134
  <p>Using RAgents does not require a repository. <code>pnpm build:package</code> creates the npm package
135
135
  <code>@schlenkr/ragents</code> from this checkout in <code>dist/ragents</code>; with <code>--pack</code>, it also creates
136
136
  <code>dist/schlenkr-ragents-&lt;version&gt;.tgz</code>. The package contains the server, engine, executor, all
@@ -246,6 +246,22 @@ records only forwarded calls with user, alias, target, status, and token count.<
246
246
  starts with the new web interface as long as the host API stays the same. Otherwise the start
247
247
  refuses the fetched bundles, and <code>connect</code> names the version to install.</li>
248
248
  </ul>
249
+ <h2 id="run-a-cli-workstation">Run a CLI workstation</h2>
250
+ <p><code>ragents workspace-client &lt;server-url&gt; [folders ...]</code> registers this machine with the same tools
251
+ and executor as the VS Code extension. No folder means the current directory; <code>--id</code> and <code>--label</code>
252
+ set its stable identity and display name. The npm installation includes ripgrep on every supported
253
+ platform and the curated Bash on Windows. Plugin tools are provisioned before registration.</p>
254
+ <p>Set <code>RAGENTS_TOKEN</code> for a personal or existing session token. For automatic sign-in and renewal,
255
+ configure <code>RAGENTS_USER</code> and <code>RAGENTS_PASSWORD</code> in the process environment. Neither is a command-line
256
+ option. Invalid or absent credentials when sign-in is required produce a visible failure and a
257
+ nonzero exit. A temporary connection loss reconnects and registers the workstation again.</p>
258
+ <p>Closing the terminal or SSH session, closing stdin, or ending the owning process unregisters the
259
+ workstation and stops its children. SIGINT, SIGTERM, and SIGHUP also stop it. For an intentional
260
+ background service, pass <code>--detached</code> and let a service manager own it, or run:</p>
261
+ <pre><code class="language-sh">nohup ragents workspace-client https://ragents.example.com /home/user/project --detached &gt;workspace.log 2&gt;&amp;1 &lt;/dev/null &amp;
262
+ </code></pre>
263
+ <p>The flag ignores terminal closure, parent loss, and SIGHUP; it does not daemonize. SIGINT and
264
+ SIGTERM still unregister and shut down. Use the service manager&#39;s stop command to end a service.</p>
249
265
  <h2 id="transfer-a-run">Transfer a run</h2>
250
266
  <p>A run can move from one server to another and continue there, for example from a notebook to an
251
267
  always-on machine or from a central server to a developer for local inspection.</p>
@@ -299,13 +315,11 @@ otherwise it tells the model to exclude dependency and build folders from <code>
299
315
  contains no git, so your login works as usual: Git Credential Manager, <code>~/.gitconfig</code> and
300
316
  <code>~/.ssh</code>. On your own machine the bash inherits your whole environment, except the variables of
301
317
  VS Code itself and <code>BASH_ENV</code>/<code>ENV</code>.</li>
302
- <li><strong>Without the extension</strong> (<code>ragents start</code> or <code>ragents workspace-client</code> from the npm package)
303
- set <code>RAGENTS_BASH</code> to the <code>bash.exe</code> of such a bundle, for example
304
- <code>&lt;extension folder&gt;\dist\bash\win32-x64\usr\bin\bash.exe</code>. Without it every <code>bash</code> call fails
305
- and names what is missing. <code>RAGENTS_RG</code> names an <code>rg</code> in the same way, for example
306
- <code>&lt;extension folder&gt;\dist\rg\win32-x64\rg.exe</code>, on every platform; without it, an <code>rg</code> on
307
- <code>PATH</code> is used if there is one. A <code>RAGENTS_RG</code> that points to a missing file stops the server
308
- start or the workstation&#39;s registration.</li>
318
+ <li><strong>CLI workstation</strong> (<code>ragents workspace-client</code>) carries the same Bash and ripgrep as the
319
+ extension. npm installs only the optional tools package for this machine; keep optional
320
+ dependencies enabled. A checkout uses the extension build from <code>pnpm bundle:rg</code> and
321
+ <code>pnpm bundle:bash</code>. A standalone server (<code>ragents start</code>) still takes <code>RAGENTS_BASH</code> and
322
+ <code>RAGENTS_RG</code> from its environment; these variables do not override workstation bundles.</li>
309
323
  <li><strong>Node.js</strong> is enough with <code>@schlenkr/ragents</code>. A checkout additionally needs <strong>pnpm</strong>, and
310
324
  <code>pnpm install</code>, <code>pnpm build:agent</code>, and <code>scripts/start.sh</code> need a bash of your own, such as
311
325
  Git Bash.</li>
@@ -143,6 +143,29 @@ expose only the alias.
143
143
  starts with the new web interface as long as the host API stays the same. Otherwise the start
144
144
  refuses the fetched bundles, and `connect` names the version to install.
145
145
 
146
+ ## Run a CLI workstation
147
+
148
+ `ragents workspace-client <server-url> [folders ...]` registers this machine with the same tools
149
+ and executor as the VS Code extension. No folder means the current directory; `--id` and `--label`
150
+ set its stable identity and display name. The npm installation includes ripgrep on every supported
151
+ platform and the curated Bash on Windows. Plugin tools are provisioned before registration.
152
+
153
+ Set `RAGENTS_TOKEN` for a personal or existing session token. For automatic sign-in and renewal,
154
+ configure `RAGENTS_USER` and `RAGENTS_PASSWORD` in the process environment. Neither is a command-line
155
+ option. Invalid or absent credentials when sign-in is required produce a visible failure and a
156
+ nonzero exit. A temporary connection loss reconnects and registers the workstation again.
157
+
158
+ Closing the terminal or SSH session, closing stdin, or ending the owning process unregisters the
159
+ workstation and stops its children. SIGINT, SIGTERM, and SIGHUP also stop it. For an intentional
160
+ background service, pass `--detached` and let a service manager own it, or run:
161
+
162
+ ```sh
163
+ nohup ragents workspace-client https://ragents.example.com /home/user/project --detached >workspace.log 2>&1 </dev/null &
164
+ ```
165
+
166
+ The flag ignores terminal closure, parent loss, and SIGHUP; it does not daemonize. SIGINT and
167
+ SIGTERM still unregister and shut down. Use the service manager's stop command to end a service.
168
+
146
169
  ## Transfer a run
147
170
 
148
171
  A run can move from one server to another and continue there, for example from a notebook to an
@@ -203,13 +226,11 @@ The local host and workspace also run on Windows. Requirements are:
203
226
  contains no git, so your login works as usual: Git Credential Manager, `~/.gitconfig` and
204
227
  `~/.ssh`. On your own machine the bash inherits your whole environment, except the variables of
205
228
  VS Code itself and `BASH_ENV`/`ENV`.
206
- - **Without the extension** (`ragents start` or `ragents workspace-client` from the npm package)
207
- set `RAGENTS_BASH` to the `bash.exe` of such a bundle, for example
208
- `<extension folder>\dist\bash\win32-x64\usr\bin\bash.exe`. Without it every `bash` call fails
209
- and names what is missing. `RAGENTS_RG` names an `rg` in the same way, for example
210
- `<extension folder>\dist\rg\win32-x64\rg.exe`, on every platform; without it, an `rg` on
211
- `PATH` is used if there is one. A `RAGENTS_RG` that points to a missing file stops the server
212
- start or the workstation's registration.
229
+ - **CLI workstation** (`ragents workspace-client`) carries the same Bash and ripgrep as the
230
+ extension. npm installs only the optional tools package for this machine; keep optional
231
+ dependencies enabled. A checkout uses the extension build from `pnpm bundle:rg` and
232
+ `pnpm bundle:bash`. A standalone server (`ragents start`) still takes `RAGENTS_BASH` and
233
+ `RAGENTS_RG` from its environment; these variables do not override workstation bundles.
213
234
  - **Node.js** is enough with `@schlenkr/ragents`. A checkout additionally needs **pnpm**, and
214
235
  `pnpm install`, `pnpm build:agent`, and `scripts/start.sh` need a bash of your own, such as
215
236
  Git Bash.
@@ -0,0 +1,165 @@
1
+ import type { AccessSnapshot } from "../../../packages/ragents/src/access";
2
+ import { accessSnapshotFrom } from "./access-session";
3
+ import { RpcClient } from "./rpc/client";
4
+
5
+ export class ServerError extends Error {
6
+ constructor(message: string, readonly status: number, readonly code?: string) {
7
+ super(message);
8
+ this.name = "ServerError";
9
+ }
10
+ }
11
+
12
+ export class UnreachableError extends Error {
13
+ constructor(message: string) {
14
+ super(message);
15
+ this.name = "UnreachableError";
16
+ }
17
+ }
18
+
19
+ const sessionTokenFrom = (setCookie: readonly string[]): string => {
20
+ for (const cookie of setCookie) {
21
+ const pair = cookie.split(";")[0] ?? "";
22
+ const token = pair.slice(pair.indexOf("=") + 1).trim();
23
+ if (/^[A-Za-z0-9_-]{43}$/.test(token)) return token;
24
+ }
25
+ throw new ServerError("The server did not deliver a session token.", 500);
26
+ };
27
+
28
+ const errorFrom = async (response: Response): Promise<ServerError> => {
29
+ let message = `The server responded with ${response.status}.`;
30
+ let code: string | undefined;
31
+ try {
32
+ const body = await response.json() as { error?: unknown; message?: unknown; code?: unknown };
33
+ if (typeof body.error === "string" && body.error) message = body.error;
34
+ else if (typeof body.message === "string" && body.message) message = body.message;
35
+ if (typeof body.code === "string") code = body.code;
36
+ } catch {}
37
+ return new ServerError(message, response.status, code);
38
+ };
39
+
40
+ /** A workstation's access: sign-in and delivery over HTTP, everything else as JSON-RPC; the session token goes as bearer. */
41
+ export interface SignInOptions {
42
+ readonly credentials: () => Promise<{ id: string; password: string } | undefined>;
43
+ readonly onToken?: (token: string) => Promise<void>;
44
+ readonly log?: (line: string) => void;
45
+ }
46
+
47
+ export class ServerClient {
48
+ #authenticating: Promise<boolean> | undefined;
49
+ #signInFailure: string | undefined;
50
+ readonly origin: string;
51
+ readonly rpc: RpcClient;
52
+
53
+ constructor(readonly baseUrl: string, private token: string | undefined, private readonly request: typeof fetch = fetch, private readonly signIn?: SignInOptions) {
54
+ this.origin = new URL(baseUrl).origin;
55
+ this.rpc = new RpcClient({
56
+ baseUrl: this.origin,
57
+ fetch: (input, init) => this.#authorized(input, init),
58
+ });
59
+ }
60
+
61
+ signInFailure(): string | undefined {
62
+ return this.#signInFailure;
63
+ }
64
+
65
+ async #authorized(input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> {
66
+ const token = this.token;
67
+ const send = () => this.request(input, { ...init, headers: this.headers(init?.headers as Record<string, string> | undefined ?? {}) });
68
+ const response = await send();
69
+ if (response.status !== 401 || !this.signIn || init?.signal?.aborted) return response;
70
+ const rejection = await response.clone().json().catch(() => undefined) as { code?: unknown } | undefined;
71
+ if (rejection?.code !== "login-required") {
72
+ this.#signInFailure = "Access token required: this server does not accept automatic user/password sign-in. Supply a valid access token.";
73
+ return response;
74
+ }
75
+ if (token === this.token) {
76
+ this.#authenticating ??= this.#authenticate().finally(() => { this.#authenticating = undefined; });
77
+ if (!await this.#authenticating) return response;
78
+ }
79
+ if (init?.signal?.aborted) return response;
80
+ await response.body?.cancel();
81
+ const retried = await send();
82
+ if (retried.status === 401) this.#signInFailure = "Sign-in required: the server rejected the renewed session.";
83
+ return retried;
84
+ }
85
+
86
+ async #authenticate(): Promise<boolean> {
87
+ try {
88
+ const credentials = await this.signIn!.credentials();
89
+ if (!credentials) {
90
+ this.#signInFailure = "Sign-in required: no credentials are configured for session renewal.";
91
+ return false;
92
+ }
93
+ this.signIn!.log?.(`== Signing in to ${this.origin} with configured credentials`);
94
+ const { token } = await this.login(credentials.id, credentials.password);
95
+ await this.signIn!.onToken?.(token);
96
+ this.useToken(token);
97
+ this.#signInFailure = undefined;
98
+ return true;
99
+ } catch {
100
+ this.#signInFailure = "Sign-in required: automatic sign-in failed. Check the configured credentials and server availability.";
101
+ return false;
102
+ }
103
+ }
104
+
105
+ get hasToken(): boolean {
106
+ return this.token !== undefined;
107
+ }
108
+
109
+ get accessToken(): string | undefined {
110
+ return this.token;
111
+ }
112
+
113
+ /** The client reads the token on every request, so the messaging layer stays in place. */
114
+ useToken(token: string | undefined): void {
115
+ this.token = token;
116
+ }
117
+
118
+ url(path: string): string {
119
+ return new URL(path, `${this.origin}/`).toString();
120
+ }
121
+
122
+ headers(extra: Record<string, string> = {}): Record<string, string> {
123
+ return this.token === undefined ? extra : { ...extra, authorization: `Bearer ${this.token}` };
124
+ }
125
+
126
+ async fetch(path: string, init: RequestInit = {}): Promise<Response> {
127
+ try {
128
+ return await this.#authorized(this.url(path), init);
129
+ } catch (cause) {
130
+ throw new UnreachableError(`${this.origin} is unreachable: ${cause instanceof Error ? cause.message : String(cause)}`);
131
+ }
132
+ }
133
+
134
+ async json<T>(path: string, init: RequestInit = {}, validate: (value: unknown) => T = (value) => value as T): Promise<T> {
135
+ const response = await this.fetch(path, init);
136
+ if (!response.ok) throw await errorFrom(response);
137
+ return validate(await response.json());
138
+ }
139
+
140
+ async access(): Promise<AccessSnapshot> {
141
+ const snapshot = await this.json("/api/access", { cache: "no-store" }, accessSnapshotFrom);
142
+ if (!snapshot.enabled || snapshot.user || !this.signIn) return snapshot;
143
+ this.#authenticating ??= this.#authenticate().finally(() => { this.#authenticating = undefined; });
144
+ return await this.#authenticating ? this.json("/api/access", { cache: "no-store" }, accessSnapshotFrom) : snapshot;
145
+ }
146
+
147
+ async login(id: string, password: string): Promise<{ snapshot: AccessSnapshot; token: string }> {
148
+ const response = await this.request(this.url("/api/access/login"), {
149
+ method: "POST",
150
+ headers: { "content-type": "application/json" },
151
+ body: JSON.stringify({ id, password }),
152
+ signal: AbortSignal.timeout(10_000),
153
+ redirect: "error",
154
+ });
155
+ if (!response.ok) throw await errorFrom(response);
156
+ const snapshot = accessSnapshotFrom(await response.json());
157
+ if (!snapshot.user) throw new ServerError("The server did not confirm the sign-in.", 500);
158
+ return { snapshot, token: sessionTokenFrom(response.headers.getSetCookie()) };
159
+ }
160
+
161
+ async logout(): Promise<void> {
162
+ const response = await this.fetch("/api/access/logout", { method: "POST" });
163
+ if (!response.ok && response.status !== 401) throw await errorFrom(response);
164
+ }
165
+ }
@@ -137,7 +137,7 @@
137
137
  }
138
138
  },
139
139
  "revision": "db9370b7b3449c44be49986b074806351b857c430963aa878e209d2d5c9ba4f8",
140
- "sourceRevision": "012d6527173d20efeeaa5102b884d7cd9bbf781cf4fa6446164529ec252fcc2e",
140
+ "sourceRevision": "25a05a48b591f8f4b575b06d495f7f191037d7419b79a98ad321328c455f4e0d",
141
141
  "server": "server/index.js",
142
142
  "web": {
143
143
  "entry": "web/index.js",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@schlenkr/ragents",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "RAgents host: server, engine, plugins and tools without a source checkout",
5
5
  "keywords": [
6
6
  "agents",
@@ -70,7 +70,15 @@
70
70
  "vscode-jsonrpc": "9.0.2",
71
71
  "yaml": "2.9.0"
72
72
  },
73
+ "optionalDependencies": {
74
+ "@schlenkr/ragents-tools-win32-x64": "0.1.19",
75
+ "@schlenkr/ragents-tools-win32-arm64": "0.1.19",
76
+ "@schlenkr/ragents-tools-darwin-x64": "0.1.19",
77
+ "@schlenkr/ragents-tools-darwin-arm64": "0.1.19",
78
+ "@schlenkr/ragents-tools-linux-x64": "0.1.19",
79
+ "@schlenkr/ragents-tools-linux-arm64": "0.1.19"
80
+ },
73
81
  "ragents": {
74
- "hostVersion": "29cbfb6c019aa6a9ceb540363ca37ef2f9d03cb1"
82
+ "hostVersion": "9fd1390d0bf180bd36be46f0409d318a223e6ef2"
75
83
  }
76
84
  }
@@ -2,7 +2,7 @@
2
2
  export interface AgentSettings {
3
3
  /** Retries after a retryable model error, with exponential backoff. */
4
4
  retry: { enabled: boolean; maxRetries: number; baseDelayMs: number };
5
- /** Timeout and retries of a single provider request; unset values leave the SDK defaults. */
5
+ /** Idle timeout (time without any received chunk) and retries of a single provider request; unset values leave the SDK defaults. */
6
6
  providerRequest: { timeoutMs: number; maxRetries?: number; maxRetryDelayMs: number };
7
7
  }
8
8
 
@@ -13,5 +13,5 @@ export interface AgentSettingsInput {
13
13
 
14
14
  export const agentSettings = (input: AgentSettingsInput = {}): AgentSettings => ({
15
15
  retry: { enabled: true, maxRetries: 3, baseDelayMs: 2000, ...input.retry },
16
- providerRequest: { timeoutMs: 300_000, maxRetryDelayMs: 60_000, ...input.providerRequest },
16
+ providerRequest: { timeoutMs: 600_000, maxRetryDelayMs: 60_000, ...input.providerRequest },
17
17
  });
@@ -33,6 +33,16 @@ async function runStream(model: Model<"openai-completions">, context: Context, o
33
33
  };
34
34
  const controller = new AbortController();
35
35
  const signal = options?.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
36
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
37
+ let idleExpired = false;
38
+ const restartIdleTimer = () => {
39
+ clearTimeout(idleTimer);
40
+ if (options?.timeoutMs === undefined) return;
41
+ idleTimer = setTimeout(() => {
42
+ idleExpired = true;
43
+ controller.abort();
44
+ }, options.timeoutMs);
45
+ };
36
46
  try {
37
47
  const retention = options?.cacheRetention ?? (getProviderEnvValue("AGENT_CACHE_RETENTION", options?.env) === "long" ? "long" : "short");
38
48
  const cacheControl = retention !== "none" && (model.compat?.cacheControlFormat === "anthropic" || model.id.startsWith("anthropic/"))
@@ -67,7 +77,6 @@ async function runStream(model: Model<"openai-completions">, context: Context, o
67
77
  maxRetries: options?.maxRetries ?? 0,
68
78
  streamRetries: 0,
69
79
  abortSignal: signal,
70
- timeout: options?.timeoutMs,
71
80
  stopWhen: stepCountIs(1),
72
81
  includeRawChunks: true,
73
82
  onError: () => {},
@@ -83,7 +92,9 @@ async function runStream(model: Model<"openai-completions">, context: Context, o
83
92
  let rawThinkingIndex: number | undefined;
84
93
  let hasFinishReason = false;
85
94
  let finished = false;
95
+ restartIdleTimer();
86
96
  for await (const part of result.fullStream) {
97
+ restartIdleTimer();
87
98
  switch (part.type) {
88
99
  case "raw": {
89
100
  const chunk = part.rawValue as { id?: string; model?: string; choices?: Array<{ finish_reason?: string | null; delta?: {
@@ -216,9 +227,11 @@ async function runStream(model: Model<"openai-completions">, context: Context, o
216
227
  } catch (error) {
217
228
  controller.abort();
218
229
  output.stopReason = options?.signal?.aborted ? "aborted" : "error";
219
- output.errorMessage = formatProviderError(normalizeProviderError(error), model.provider === "relay" ? `Relay ${model.baseUrl}` : undefined);
230
+ const failure = idleExpired ? new Error(`Timeout: no data from the provider for ${options?.timeoutMs} ms`) : error;
231
+ output.errorMessage = formatProviderError(normalizeProviderError(failure), model.provider === "relay" ? `Relay ${model.baseUrl}` : undefined);
220
232
  events.push({ type: "error", reason: output.stopReason, error: output });
221
233
  } finally {
234
+ clearTimeout(idleTimer);
222
235
  events.end();
223
236
  }
224
237
  }
@@ -89,7 +89,8 @@ export interface StreamOptions {
89
89
  */
90
90
  headers?: ProviderHeaders;
91
91
  /**
92
- * HTTP request timeout in milliseconds for providers/SDKs that support it.
92
+ * Timeout in milliseconds for providers/SDKs that support it. The openai-completions
93
+ * stream counts it as idle time: any received chunk restarts it, so an active stream never expires.
93
94
  * For example, OpenAI and Anthropic SDK clients default to 10 minutes.
94
95
  */
95
96
  timeoutMs?: number;
@@ -0,0 +1,49 @@
1
+ import { existsSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+
5
+ export const WORKSPACE_TOOL_TARGETS = ["win32-x64", "win32-arm64", "darwin-x64", "darwin-arm64", "linux-x64", "linux-arm64"] as const;
6
+ export type WorkspaceToolTarget = typeof WORKSPACE_TOOL_TARGETS[number];
7
+
8
+ export const workspaceToolsPackageName = (target: string): string => `@schlenkr/ragents-tools-${target}`;
9
+
10
+ export interface BundledToolsOptions {
11
+ readonly root: string;
12
+ readonly distribution: "extension" | "package";
13
+ readonly platform?: NodeJS.Platform;
14
+ readonly arch?: string;
15
+ readonly paths?: typeof path;
16
+ readonly exists?: (file: string) => boolean;
17
+ readonly resolvePackage?: (specifier: string) => string;
18
+ }
19
+
20
+ export interface BundledTools {
21
+ readonly bash: string | undefined;
22
+ readonly rg: string | undefined;
23
+ }
24
+
25
+ export const resolveBundledTools = (options: BundledToolsOptions): BundledTools => {
26
+ const platform = options.platform ?? process.platform;
27
+ const target = `${platform}-${options.arch ?? process.arch}`;
28
+ const paths = options.paths ?? path;
29
+ const exists = options.exists ?? existsSync;
30
+ const packageRoot = (): string => {
31
+ if (!WORKSPACE_TOOL_TARGETS.includes(target as WorkspaceToolTarget)) throw new Error(`Unsupported workstation platform: ${target}`);
32
+ const name = workspaceToolsPackageName(target);
33
+ try {
34
+ const resolve = options.resolvePackage ?? createRequire(paths.join(options.root, "package.json")).resolve;
35
+ return paths.dirname(resolve(`${name}/package.json`));
36
+ } catch {
37
+ throw new Error(`The bundled workstation tools ${name} are missing. Reinstall @schlenkr/ragents with optional dependencies enabled.`);
38
+ }
39
+ };
40
+ const root = options.distribution === "package" ? packageRoot() : options.root;
41
+ const bash = platform === "win32" ? paths.join(root, "dist", "bash", target, "usr", "bin", "bash.exe") : undefined;
42
+ const rg = paths.join(root, "dist", "rg", target, platform === "win32" ? "rg.exe" : "rg");
43
+ if (options.distribution === "package") {
44
+ for (const file of [rg, bash]) {
45
+ if (file !== undefined && !exists(file)) throw new Error(`Bundled workstation tool is missing: ${file}. Reinstall @schlenkr/ragents with optional dependencies enabled.`);
46
+ }
47
+ }
48
+ return { bash, rg: exists(rg) ? rg : undefined };
49
+ };
@@ -0,0 +1,18 @@
1
+ import type { EventEmitter } from "node:events";
2
+ import type { Readable } from "node:stream";
3
+
4
+ export interface OwnerLifetimeOptions {
5
+ readonly detached?: boolean;
6
+ readonly parents?: readonly number[];
7
+ readonly onStop: (reason: string) => void;
8
+ readonly stdin?: Pick<Readable, "on" | "off" | "resume" | "pause" | "destroyed" | "readableEnded"> | null;
9
+ readonly signals?: EventEmitter;
10
+ readonly alive?: (pid: number) => boolean;
11
+ readonly intervalMs?: number;
12
+ }
13
+
14
+ export function processAlive(pid: number): boolean;
15
+ export function watchOwnerLifetime(options: OwnerLifetimeOptions): () => void;
16
+ export function processChildren(pid?: number): number[];
17
+ export function terminateProcessTree(pid: number): void;
18
+ export function exitWorkspaceProcess(code: number): never;
@@ -0,0 +1,102 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync, readdirSync } from "node:fs";
3
+
4
+ export const processAlive = (pid) => {
5
+ try {
6
+ process.kill(pid, 0);
7
+ return true;
8
+ } catch (error) {
9
+ return error.code === "EPERM";
10
+ }
11
+ };
12
+
13
+ export const watchOwnerLifetime = ({ detached = false, parents = [process.ppid], onStop, stdin = process.stdin, signals = process, alive = processAlive, intervalMs = 1000 }) => {
14
+ const releases = [];
15
+ const signalReleases = [];
16
+ let stopped = false;
17
+ const dispose = () => {
18
+ for (const release of releases.splice(0)) release();
19
+ };
20
+ const stop = (reason) => {
21
+ if (stopped) return;
22
+ stopped = true;
23
+ dispose();
24
+ onStop(reason);
25
+ };
26
+ const listen = (emitter, event, handler, keepDuringStop = false) => {
27
+ emitter.on(event, handler);
28
+ (keepDuringStop ? signalReleases : releases).push(() => emitter.off(event, handler));
29
+ };
30
+ listen(signals, "SIGINT", () => stop("SIGINT"), true);
31
+ listen(signals, "SIGTERM", () => stop("SIGTERM"), true);
32
+ listen(signals, "SIGHUP", () => { if (!detached) stop("SIGHUP"); }, true);
33
+ if (!detached) {
34
+ if (parents.some((pid) => !Number.isSafeInteger(pid) || pid <= 0)) throw new Error("The workspace owner PID must be a positive integer.");
35
+ const check = () => {
36
+ if (parents.some((pid) => !alive(pid))) stop("parent exited");
37
+ };
38
+ const timer = setInterval(check, intervalMs);
39
+ timer.unref();
40
+ releases.push(() => clearInterval(timer));
41
+ listen(signals, "disconnect", () => stop("owner disconnected"));
42
+ if (stdin) {
43
+ listen(stdin, "end", () => stop("stdin closed"));
44
+ listen(stdin, "close", () => stop("stdin closed"));
45
+ listen(stdin, "error", () => stop("stdin failed"));
46
+ stdin.resume();
47
+ releases.push(() => stdin.pause());
48
+ if (stdin.destroyed || stdin.readableEnded) queueMicrotask(() => stop("stdin closed"));
49
+ }
50
+ queueMicrotask(check);
51
+ }
52
+ return () => {
53
+ stopped = true;
54
+ dispose();
55
+ for (const release of signalReleases.splice(0)) release();
56
+ };
57
+ };
58
+
59
+ export const processChildren = (pid = process.pid) => {
60
+ if (process.platform === "win32") {
61
+ const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command",
62
+ `Get-CimInstance Win32_Process -Filter 'ParentProcessId = ${pid}' | ForEach-Object { $_.ProcessId }`], { encoding: "utf8", windowsHide: true, timeout: 5000 });
63
+ if (result.status !== 0) throw new Error("Cannot inspect workspace child processes for shutdown.");
64
+ return result.stdout.trim().split(/\s+/).map(Number).filter((child) => child > 0);
65
+ }
66
+ const rows = process.platform === "linux" ? readdirSync("/proc").filter((entry) => /^\d+$/.test(entry)).flatMap((entry) => {
67
+ try {
68
+ const stat = readFileSync(`/proc/${entry}/stat`, "utf8");
69
+ return [[Number(entry), Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1])]];
70
+ } catch (error) {
71
+ if (["ENOENT", "ESRCH", "EACCES"].includes(error.code)) return [];
72
+ throw error;
73
+ }
74
+ }) : (() => {
75
+ const table = spawnSync("ps", ["-axo", "pid=,ppid="], { encoding: "utf8" });
76
+ if (table.status !== 0) throw new Error("Cannot inspect workspace child processes for shutdown.");
77
+ return table.stdout.trim().split("\n").map((line) => line.trim().split(/\s+/).map(Number));
78
+ })();
79
+ const descendants = (parent) => rows.filter((row) => row[1] === parent).flatMap((row) => [...descendants(row[0]), row[0]]);
80
+ return descendants(pid);
81
+ };
82
+
83
+ export const terminateProcessTree = (pid) => {
84
+ if (process.platform === "win32") {
85
+ const killed = spawnSync("taskkill", ["/T", "/F", "/PID", String(pid)], { stdio: "ignore", windowsHide: true, timeout: 5000 });
86
+ if (killed.status !== 0 && processAlive(pid)) throw new Error(`Could not stop workspace child process ${pid}.`);
87
+ return;
88
+ }
89
+ for (const child of [...processChildren(pid), pid]) {
90
+ try { process.kill(child, "SIGKILL"); } catch (error) { if (error.code !== "ESRCH") throw error; }
91
+ }
92
+ };
93
+
94
+ export const exitWorkspaceProcess = (code) => {
95
+ for (const pid of processChildren()) {
96
+ if (process.platform === "win32") terminateProcessTree(pid);
97
+ else {
98
+ try { process.kill(pid, "SIGKILL"); } catch (error) { if (error.code !== "ESRCH") throw error; }
99
+ }
100
+ }
101
+ process.exit(code);
102
+ };
@@ -1,13 +1,11 @@
1
- import { RpcClient } from "../../../apps/web/src/rpc/client";
2
- import type { WorkspaceClientTransport } from "./workspace-client";
1
+ import { ServerClient } from "../../../apps/web/src/server-client";
3
2
 
4
- /** A workstation access without a user interface: the message layer with a bearer token. */
5
- export const workspaceClientTransport = (baseUrl: string, token: string | undefined): WorkspaceClientTransport => ({
6
- rpc: new RpcClient({
7
- baseUrl: new URL(baseUrl).origin,
8
- fetch: (input, init) => {
9
- const headers = init?.headers as Record<string, string> | undefined ?? {};
10
- return fetch(input, { ...init, headers: token === undefined ? headers : { ...headers, authorization: `Bearer ${token}` } });
3
+ export const workspaceClientTransport = (baseUrl: string, token: string | undefined, environment: NodeJS.ProcessEnv = process.env): ServerClient =>
4
+ new ServerClient(baseUrl, token, fetch, {
5
+ log: (line) => console.error(line),
6
+ credentials: async () => {
7
+ const id = environment.RAGENTS_USER;
8
+ const password = environment.RAGENTS_PASSWORD;
9
+ return id && password ? { id, password } : undefined;
11
10
  },
12
- }),
13
- });
11
+ });
@@ -65,6 +65,7 @@ export interface WorkspaceClientOptions {
65
65
  /** What the workstation needs for the server: the message layer. */
66
66
  export interface WorkspaceClientTransport {
67
67
  readonly rpc: RpcClient;
68
+ readonly signInFailure?: () => string | undefined;
68
69
  }
69
70
 
70
71
  type ExecuteInput = OperationInput<typeof workspaceClientContracts.execute>;
@@ -255,6 +256,11 @@ export class WorkspaceClient {
255
256
  releases.push(
256
257
  rpc.handle(workspaceClientContracts.execute, (input, context) => this.#execute(attachment, input, context)),
257
258
  rpc.onConnected(() => void this.#announce()),
259
+ rpc.onStatus((status) => {
260
+ if (status.kind === "unauthorized" && attachment === this.#attachment) {
261
+ this.#set({ kind: "failed", message: this.transport.signInFailure?.() ?? "The server requires a sign-in.", mismatch: false });
262
+ }
263
+ }),
258
264
  );
259
265
  this.#attachment = attachment;
260
266
  return attachment;
@@ -288,13 +294,14 @@ export class WorkspaceClient {
288
294
  #connected(): Promise<void> {
289
295
  const rpc = this.transport.rpc;
290
296
  if (rpc.status.kind === "connected") return Promise.resolve();
297
+ if (rpc.status.kind === "unauthorized") return Promise.reject(new Error(this.transport.signInFailure?.() ?? "The server requires a sign-in."));
291
298
  return new Promise((settle, fail) => {
292
299
  const release = rpc.onStatus((status) => {
293
300
  if (status.kind === "connecting") return;
294
301
  release();
295
302
  if (status.kind === "connected") settle();
296
303
  else if (status.kind === "retrying") fail(new Error(status.message));
297
- else if (status.kind === "unauthorized") fail(new Error("The server requires a sign-in."));
304
+ else if (status.kind === "unauthorized") fail(new Error(this.transport.signInFailure?.() ?? "The server requires a sign-in."));
298
305
  else fail(new Error("The server's event stream is closed."));
299
306
  });
300
307
  });