@almyty/client 0.1.0 → 1.3.0

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 ADDED
@@ -0,0 +1,36 @@
1
+ # @almyty/client
2
+
3
+ Shared HTTP client and credential resolver used by all almyty CLI packages.
4
+
5
+ ## Usage
6
+
7
+ ```typescript
8
+ import { AlmytyClient, resolveCredentialsOrExit } from '@almyty/client';
9
+
10
+ const creds = resolveCredentialsOrExit();
11
+ const client = new AlmytyClient(creds.url, creds.token);
12
+
13
+ const agents = await client.listAgents();
14
+ ```
15
+
16
+ ## Exports
17
+
18
+ - `AlmytyClient` -- API client (agents, runs, gateways)
19
+ - `GatewayClient` -- gateway-scoped client (invoke, stream, conversations)
20
+ - `resolveCredentials()` -- read `~/.almyty/credentials.json` (returns null if missing)
21
+ - `resolveCredentialsOrExit()` -- same, but exits with an error message if missing
22
+ - `getOrgSlugFromToken(token)` -- extract org slug from JWT
23
+ - `loadCredentials()` -- raw file read
24
+ - `CREDENTIALS_FILE` -- path to `~/.almyty/credentials.json`
25
+
26
+ ## About almyty
27
+
28
+ almyty is the full-stack platform for AI agents, agnostic by design: any LLM, any
29
+ API turned into tools, served over MCP, A2A, UTCP, and Agent Skills. Open source,
30
+ no lock-in.
31
+
32
+ - Website: https://almyty.com
33
+ - Docs: https://docs.almyty.com
34
+ - Source: https://github.com/almyty-inc/almyty
35
+
36
+ Apache-2.0 © Almyty Inc.
package/dist/client.d.ts CHANGED
@@ -5,6 +5,11 @@
5
5
  * and @almyty/mcp-server. Covers agent discovery, invocation,
6
6
  * autonomous run management, and polling.
7
7
  */
8
+ export interface AgentTool {
9
+ id: string;
10
+ name: string;
11
+ description?: string;
12
+ }
8
13
  export interface AgentInfo {
9
14
  id: string;
10
15
  name: string;
@@ -16,6 +21,7 @@ export interface AgentInfo {
16
21
  nodes?: PipelineNode[];
17
22
  };
18
23
  modelConfig?: Record<string, unknown>;
24
+ tools?: AgentTool[];
19
25
  }
20
26
  export interface PipelineNode {
21
27
  id: string;
@@ -39,16 +45,79 @@ export interface RunLimits {
39
45
  maxCostCents?: number;
40
46
  maxDurationMs?: number;
41
47
  }
48
+ /** SSE event from the agent run stream. */
49
+ export interface StreamEvent {
50
+ type: string;
51
+ data: Record<string, unknown>;
52
+ }
53
+ /** A coding CLI detected on a runner machine. */
54
+ export interface RunnerCodingAgent {
55
+ id: string;
56
+ displayName: string;
57
+ binary: string;
58
+ version?: string;
59
+ providerFamily?: string;
60
+ }
61
+ /** A registered runner (one of the user's machines). */
62
+ export interface RunnerSummary {
63
+ id: string;
64
+ name: string;
65
+ state?: string;
66
+ labels?: Record<string, string>;
67
+ /** Coding CLIs the runner reported at registration. */
68
+ codingAgents: RunnerCodingAgent[];
69
+ }
70
+ /** A coding session running on a runner. */
71
+ export interface CodingSession {
72
+ sessionId: string;
73
+ agent: string;
74
+ binary?: string;
75
+ processId?: string;
76
+ cwd?: string;
77
+ task?: string;
78
+ status?: string;
79
+ exitCode?: number | null;
80
+ }
81
+ /** Callback for stream events. */
82
+ export type StreamEventHandler = (event: StreamEvent) => void;
83
+ /** Whether a rejection is a caller-requested abort rather than a failure. */
84
+ export declare function isAbortError(err: unknown): boolean;
85
+ /**
86
+ * Turn one SSE frame's lines into a StreamEvent, or null when the frame
87
+ * carries nothing usable (a keep-alive comment, or malformed JSON).
88
+ *
89
+ * Server frames are not uniform. Run events arrive wrapped as
90
+ * `{type, data, timestamp}`; coding and pipeline events put their
91
+ * fields at the top level and may also carry a `data` object. So the
92
+ * envelope's own `data` object is flattened onto the result and the
93
+ * top-level fields are kept. Reading `event.data.content` off an
94
+ * `llm.chunk` returned undefined before this, which is why a streaming
95
+ * reply used to arrive as one block once the run had already finished.
96
+ */
97
+ export declare function parseSseFrame(lines: string[]): StreamEvent | null;
42
98
  export declare class AlmytyClient {
43
99
  private readonly baseUrl;
44
100
  private readonly token;
45
101
  constructor(baseUrl: string, token: string);
46
102
  private headers;
47
103
  request(path: string, init?: RequestInit): Promise<any>;
104
+ /**
105
+ * Connect to an SSE endpoint and call handler for each event.
106
+ * Returns when the stream ends or a terminal event is received.
107
+ *
108
+ * `init` lets a caller POST (the workflow pipeline stream does);
109
+ * omitted, this is a GET.
110
+ */
111
+ streamSSE(path: string, handler: StreamEventHandler, signal?: AbortSignal, init?: RequestInit): Promise<void>;
48
112
  private unwrap;
49
113
  listAgents(): Promise<AgentInfo[]>;
50
114
  getAgent(id: string): Promise<AgentInfo>;
51
115
  findAgentByNameOrId(nameOrId: string): Promise<AgentInfo | null>;
116
+ /**
117
+ * Return a gateway-scoped client that routes all calls through
118
+ * /:orgSlug/:agentSlug instead of /agents/:id.
119
+ */
120
+ gateway(orgSlug: string, agentSlug: string): GatewayClient;
52
121
  invokeAgent(agentId: string, input: Record<string, any>): Promise<any>;
53
122
  startRun(agentId: string, input: any, options?: RunLimits & {
54
123
  conversationId?: string;
@@ -68,4 +137,84 @@ export declare class AlmytyClient {
68
137
  timeoutMs?: number;
69
138
  onStep?: (run: AgentRun) => void;
70
139
  }): Promise<AgentRun>;
140
+ /** The caller's registered runners, with their detected coding CLIs. */
141
+ listRunners(): Promise<RunnerSummary[]>;
142
+ /** Fresh probe of coding CLIs installed on the runner machine. */
143
+ listRunnerCodingAgents(runnerId: string): Promise<RunnerCodingAgent[]>;
144
+ /** Start a coding session (spawns the CLI with the task prompt). */
145
+ startCodingSession(runnerId: string, options: {
146
+ agent: string;
147
+ task: string;
148
+ cwd?: string;
149
+ model?: string;
150
+ }): Promise<CodingSession>;
151
+ getCodingSession(runnerId: string, sessionId: string): Promise<CodingSession>;
152
+ /** Route a line of user input to the session's stdin. */
153
+ sendCodingInput(runnerId: string, sessionId: string, data: string): Promise<void>;
154
+ stopCodingSession(runnerId: string, sessionId: string, force?: boolean): Promise<void>;
155
+ /**
156
+ * Stream a coding session's output via SSE. Calls handler for each
157
+ * coding.output / coding.exit event; returns when the session exits or
158
+ * the stream ends.
159
+ */
160
+ streamCodingEvents(runnerId: string, sessionId: string, handler: StreamEventHandler, signal?: AbortSignal): Promise<void>;
161
+ }
162
+ /**
163
+ * Routes all agent calls through the gateway unified endpoint
164
+ * (/:orgSlug/:agentSlug/...) instead of /agents/:id/...
165
+ *
166
+ * Authenticates via API key (same Bearer token).
167
+ */
168
+ export declare class GatewayClient {
169
+ private readonly client;
170
+ private readonly prefix;
171
+ readonly orgSlug: string;
172
+ readonly agentSlug: string;
173
+ constructor(client: AlmytyClient, orgSlug: string, agentSlug: string);
174
+ getInfo(): Promise<AgentInfo>;
175
+ invoke(input: Record<string, any>): Promise<any>;
176
+ startRun(input: any, options?: RunLimits & {
177
+ conversationId?: string;
178
+ }): Promise<AgentRun>;
179
+ getRun(runId: string): Promise<AgentRun>;
180
+ /**
181
+ * Stream a workflow agent's pipeline as it executes.
182
+ *
183
+ * The unified endpoint answers POST /:org/:agent/stream with SSE:
184
+ * execution.started, node.started, node.output, node.completed,
185
+ * node.skipped, then execution.completed or execution.failed. Without
186
+ * this a multi-node pipeline is a blocking POST with nothing to show
187
+ * while it runs.
188
+ */
189
+ streamInvoke(input: Record<string, any>, handler: StreamEventHandler, signal?: AbortSignal): Promise<void>;
190
+ /**
191
+ * Stream run events via SSE. Calls handler for each event
192
+ * (llm.started, llm.chunk, llm.response, tool.started, tool.result,
193
+ * step.completed, run.completed, run.failed).
194
+ * Returns when the run completes or fails.
195
+ * Falls back to polling if SSE fails.
196
+ */
197
+ streamRun(runId: string, handler: StreamEventHandler, signal?: AbortSignal): Promise<AgentRun>;
198
+ getConversationMessages(conversationId: string): Promise<Array<{
199
+ id: string;
200
+ role: string;
201
+ content: string;
202
+ createdAt: string;
203
+ }>>;
204
+ sendRunInput(runId: string, input: string): Promise<void>;
205
+ cancelRun(runId: string): Promise<void>;
206
+ /**
207
+ * Cancel a workflow execution.
208
+ *
209
+ * The workflow counterpart of cancelRun. A workflow run is an execution,
210
+ * not a run, so cancelRun could never stop one -- a Ctrl-C that did not
211
+ * also drop the SSE connection left the pipeline running and billing.
212
+ */
213
+ cancelExecution(executionId: string): Promise<void>;
214
+ pollRun(runId: string, options?: {
215
+ intervalMs?: number;
216
+ timeoutMs?: number;
217
+ onStep?: (run: AgentRun) => void;
218
+ signal?: AbortSignal;
219
+ }): Promise<AgentRun>;
71
220
  }
package/dist/client.js CHANGED
@@ -6,6 +6,70 @@
6
6
  * autonomous run management, and polling.
7
7
  */
8
8
  const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled', 'timeout']);
9
+ const TERMINAL_EVENT_TYPES = new Set([
10
+ 'run.completed',
11
+ 'run.failed',
12
+ 'run.cancelled',
13
+ 'coding.exit',
14
+ // The workflow pipeline stream's own terminators.
15
+ 'execution.completed',
16
+ 'execution.failed',
17
+ 'done',
18
+ ]);
19
+ /** Whether a rejection is a caller-requested abort rather than a failure. */
20
+ export function isAbortError(err) {
21
+ const e = err;
22
+ return !!e && (e.name === 'AbortError' || e.code === 'ABORT_ERR');
23
+ }
24
+ /**
25
+ * Turn one SSE frame's lines into a StreamEvent, or null when the frame
26
+ * carries nothing usable (a keep-alive comment, or malformed JSON).
27
+ *
28
+ * Server frames are not uniform. Run events arrive wrapped as
29
+ * `{type, data, timestamp}`; coding and pipeline events put their
30
+ * fields at the top level and may also carry a `data` object. So the
31
+ * envelope's own `data` object is flattened onto the result and the
32
+ * top-level fields are kept. Reading `event.data.content` off an
33
+ * `llm.chunk` returned undefined before this, which is why a streaming
34
+ * reply used to arrive as one block once the run had already finished.
35
+ */
36
+ export function parseSseFrame(lines) {
37
+ let eventType = 'message';
38
+ const dataLines = [];
39
+ for (const line of lines) {
40
+ // A line starting with ':' is a comment. The server sends
41
+ // ': keep-alive' every 15s on the coding stream.
42
+ if (line.startsWith(':'))
43
+ continue;
44
+ if (line.startsWith('event:')) {
45
+ eventType = line.slice(6).trim();
46
+ }
47
+ else if (line.startsWith('data:')) {
48
+ const value = line.slice(5);
49
+ dataLines.push(value.startsWith(' ') ? value.slice(1) : value);
50
+ }
51
+ }
52
+ if (!dataLines.length)
53
+ return null;
54
+ let parsed;
55
+ try {
56
+ parsed = JSON.parse(dataLines.join('\n'));
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
62
+ return null;
63
+ const envelope = parsed;
64
+ const inner = envelope.data;
65
+ const flat = inner && typeof inner === 'object' && !Array.isArray(inner)
66
+ ? { ...envelope, ...inner }
67
+ : envelope;
68
+ return {
69
+ type: typeof envelope.type === 'string' ? envelope.type : eventType,
70
+ data: flat,
71
+ };
72
+ }
9
73
  export class AlmytyClient {
10
74
  baseUrl;
11
75
  token;
@@ -26,18 +90,112 @@ export class AlmytyClient {
26
90
  ...this.headers(),
27
91
  ...(init.headers || {}),
28
92
  };
29
- const res = await fetch(url, { ...init, headers });
93
+ let res;
94
+ try {
95
+ res = await fetch(url, { ...init, headers });
96
+ }
97
+ catch (err) {
98
+ // A transport failure carries no status, so callers that want to
99
+ // say something useful about it need the cause and the host.
100
+ throw Object.assign(new Error(err?.message || 'Network request failed'), {
101
+ url,
102
+ cause: err,
103
+ networkError: true,
104
+ });
105
+ }
30
106
  if (!res.ok) {
31
- if (res.status === 401) {
32
- throw new Error('Authentication failed. Run: npx @almyty/auth login');
33
- }
34
107
  const text = await res.text().catch(() => '');
35
- throw new Error(`API error ${res.status}: ${text}`);
108
+ // Status and body ride on the error. A CLI cannot turn
109
+ // "API error 400: {...}" into a sentence a user can act on without
110
+ // them, and parsing the message string back apart is worse.
111
+ throw Object.assign(new Error(res.status === 401
112
+ ? 'Authentication failed. Run: npx @almyty/auth login'
113
+ : `API error ${res.status}: ${text}`), { status: res.status, body: text, url });
36
114
  }
37
115
  if (res.status === 204)
38
116
  return null;
39
117
  return res.json();
40
118
  }
119
+ /**
120
+ * Connect to an SSE endpoint and call handler for each event.
121
+ * Returns when the stream ends or a terminal event is received.
122
+ *
123
+ * `init` lets a caller POST (the workflow pipeline stream does);
124
+ * omitted, this is a GET.
125
+ */
126
+ async streamSSE(path, handler, signal, init = {}) {
127
+ const url = `${this.baseUrl}${path}`;
128
+ const res = await fetch(url, {
129
+ ...init,
130
+ headers: { ...this.headers(), Accept: 'text/event-stream', ...(init.headers || {}) },
131
+ signal,
132
+ });
133
+ if (!res.ok) {
134
+ const text = await res.text().catch(() => '');
135
+ throw Object.assign(new Error(`SSE ${res.status}: ${text}`), {
136
+ status: res.status,
137
+ body: text,
138
+ url,
139
+ });
140
+ }
141
+ const body = res.body;
142
+ if (!body)
143
+ return;
144
+ const reader = body.getReader();
145
+ const decoder = new TextDecoder();
146
+ let buffer = '';
147
+ // Frame state lives outside the read loop. It used to be declared
148
+ // per chunk, so any frame whose terminating blank line arrived in
149
+ // the next chunk was dropped -- which is most of them on a busy
150
+ // stream, and is why streamed tokens never reached the CLI.
151
+ let frame = [];
152
+ try {
153
+ for (;;) {
154
+ const { value, done } = await reader.read();
155
+ if (done)
156
+ break;
157
+ buffer += decoder.decode(value, { stream: true });
158
+ let nl = buffer.indexOf('\n');
159
+ while (nl !== -1) {
160
+ // CRLF is legal in SSE and a proxy may rewrite to it. Without
161
+ // stripping the carriage return, no line ever compares equal
162
+ // to '' and not one frame is ever dispatched.
163
+ const line = buffer.slice(0, nl).replace(/\r$/, '');
164
+ buffer = buffer.slice(nl + 1);
165
+ if (line === '') {
166
+ const event = parseSseFrame(frame);
167
+ frame = [];
168
+ if (event) {
169
+ handler(event);
170
+ if (TERMINAL_EVENT_TYPES.has(event.type))
171
+ return;
172
+ }
173
+ }
174
+ else {
175
+ frame.push(line);
176
+ }
177
+ nl = buffer.indexOf('\n');
178
+ }
179
+ }
180
+ // A server that closes without a trailing blank line still sent a
181
+ // frame worth reading.
182
+ if (buffer)
183
+ frame.push(buffer.replace(/\r$/, ''));
184
+ const tail = parseSseFrame(frame);
185
+ if (tail)
186
+ handler(tail);
187
+ }
188
+ finally {
189
+ // Releasing the lock does not close the connection. A terminal
190
+ // event returns from the loop above with the body unread and the
191
+ // socket still open, and an SSE endpoint holds its end open too,
192
+ // so the handle keeps Node's event loop alive: `almyty chat` would
193
+ // not exit after a streamed turn, and a REPL leaked one connection
194
+ // per answer. Cancelling the body is what actually closes it.
195
+ await reader.cancel().catch(() => undefined);
196
+ reader.releaseLock();
197
+ }
198
+ }
41
199
  unwrap(data) {
42
200
  return data?.data ?? data;
43
201
  }
@@ -81,7 +239,15 @@ export class AlmytyClient {
81
239
  all.find((a) => a.slug?.toLowerCase() === lower) ||
82
240
  null);
83
241
  }
84
- // ── Workflow invocation ───────────────────────���─────────────────
242
+ // ── Gateway-scoped client ───────────────────────────────────────
243
+ /**
244
+ * Return a gateway-scoped client that routes all calls through
245
+ * /:orgSlug/:agentSlug instead of /agents/:id.
246
+ */
247
+ gateway(orgSlug, agentSlug) {
248
+ return new GatewayClient(this, orgSlug, agentSlug);
249
+ }
250
+ // ── Workflow invocation ────────────────────────────────────────
85
251
  async invokeAgent(agentId, input) {
86
252
  const data = await this.request(`/agents/${encodeURIComponent(agentId)}/invoke`, {
87
253
  method: 'POST',
@@ -155,4 +321,186 @@ export class AlmytyClient {
155
321
  }
156
322
  throw new Error(`Run ${runId} did not finish within ${Math.round(timeoutMs / 1000)}s`);
157
323
  }
324
+ // ── Runners & coding sessions ───────────────────────────────────
325
+ /** The caller's registered runners, with their detected coding CLIs. */
326
+ async listRunners() {
327
+ const data = await this.request('/runners');
328
+ const list = data?.data ?? data ?? [];
329
+ return list.map((r) => ({
330
+ id: r.id,
331
+ name: r.name,
332
+ state: r.state,
333
+ labels: r.labels,
334
+ codingAgents: r.runtimeInfo?.codingAgents ?? [],
335
+ }));
336
+ }
337
+ /** Fresh probe of coding CLIs installed on the runner machine. */
338
+ async listRunnerCodingAgents(runnerId) {
339
+ const data = await this.request(`/runners/${encodeURIComponent(runnerId)}/coding/agents`);
340
+ return this.unwrap(data)?.agents ?? [];
341
+ }
342
+ /** Start a coding session (spawns the CLI with the task prompt). */
343
+ async startCodingSession(runnerId, options) {
344
+ const data = await this.request(`/runners/${encodeURIComponent(runnerId)}/coding/sessions`, { method: 'POST', body: JSON.stringify(options) });
345
+ return this.unwrap(data);
346
+ }
347
+ async getCodingSession(runnerId, sessionId) {
348
+ const data = await this.request(`/runners/${encodeURIComponent(runnerId)}/coding/sessions/${encodeURIComponent(sessionId)}`);
349
+ return this.unwrap(data);
350
+ }
351
+ /** Route a line of user input to the session's stdin. */
352
+ async sendCodingInput(runnerId, sessionId, data) {
353
+ await this.request(`/runners/${encodeURIComponent(runnerId)}/coding/sessions/${encodeURIComponent(sessionId)}/input`, { method: 'POST', body: JSON.stringify({ data }) });
354
+ }
355
+ async stopCodingSession(runnerId, sessionId, force = false) {
356
+ await this.request(`/runners/${encodeURIComponent(runnerId)}/coding/sessions/${encodeURIComponent(sessionId)}/stop`, { method: 'POST', body: JSON.stringify(force ? { force } : {}) });
357
+ }
358
+ /**
359
+ * Stream a coding session's output via SSE. Calls handler for each
360
+ * coding.output / coding.exit event; returns when the session exits or
361
+ * the stream ends.
362
+ */
363
+ async streamCodingEvents(runnerId, sessionId, handler, signal) {
364
+ await this.streamSSE(`/runners/${encodeURIComponent(runnerId)}/coding/sessions/${encodeURIComponent(sessionId)}/events`, handler, signal);
365
+ }
366
+ }
367
+ // ── Gateway-scoped client ───────────────────────────────────────
368
+ /**
369
+ * Routes all agent calls through the gateway unified endpoint
370
+ * (/:orgSlug/:agentSlug/...) instead of /agents/:id/...
371
+ *
372
+ * Authenticates via API key (same Bearer token).
373
+ */
374
+ export class GatewayClient {
375
+ client;
376
+ prefix;
377
+ orgSlug;
378
+ agentSlug;
379
+ constructor(client, orgSlug, agentSlug) {
380
+ this.client = client;
381
+ this.orgSlug = orgSlug;
382
+ this.agentSlug = agentSlug;
383
+ this.prefix = `/${encodeURIComponent(orgSlug)}/${encodeURIComponent(agentSlug)}`;
384
+ }
385
+ async getInfo() {
386
+ const data = await this.client.request(this.prefix);
387
+ return data?.data ?? data;
388
+ }
389
+ async invoke(input) {
390
+ const data = await this.client.request(`${this.prefix}/invoke`, {
391
+ method: 'POST',
392
+ body: JSON.stringify({ input }),
393
+ });
394
+ return data?.data ?? data;
395
+ }
396
+ async startRun(input, options) {
397
+ const body = { input };
398
+ if (options?.maxSteps)
399
+ body.maxSteps = options.maxSteps;
400
+ if (options?.maxCostCents)
401
+ body.maxCostCents = options.maxCostCents;
402
+ if (options?.maxDurationMs)
403
+ body.maxDurationMs = options.maxDurationMs;
404
+ if (options?.conversationId)
405
+ body.conversationId = options.conversationId;
406
+ const data = await this.client.request(`${this.prefix}/runs`, {
407
+ method: 'POST',
408
+ body: JSON.stringify(body),
409
+ });
410
+ const run = data?.data ?? data;
411
+ return {
412
+ id: run.id,
413
+ agentId: run.agentId,
414
+ status: run.status,
415
+ conversationId: run.conversationId,
416
+ output: run.output,
417
+ error: run.error,
418
+ steps: run.steps,
419
+ totalCost: run.totalCost,
420
+ totalTokens: run.totalTokens,
421
+ };
422
+ }
423
+ async getRun(runId) {
424
+ const data = await this.client.request(`${this.prefix}/runs/${encodeURIComponent(runId)}`);
425
+ return (data?.data ?? data);
426
+ }
427
+ /**
428
+ * Stream a workflow agent's pipeline as it executes.
429
+ *
430
+ * The unified endpoint answers POST /:org/:agent/stream with SSE:
431
+ * execution.started, node.started, node.output, node.completed,
432
+ * node.skipped, then execution.completed or execution.failed. Without
433
+ * this a multi-node pipeline is a blocking POST with nothing to show
434
+ * while it runs.
435
+ */
436
+ async streamInvoke(input, handler, signal) {
437
+ await this.client.streamSSE(`${this.prefix}/stream`, handler, signal, {
438
+ method: 'POST',
439
+ body: JSON.stringify({ input }),
440
+ });
441
+ }
442
+ /**
443
+ * Stream run events via SSE. Calls handler for each event
444
+ * (llm.started, llm.chunk, llm.response, tool.started, tool.result,
445
+ * step.completed, run.completed, run.failed).
446
+ * Returns when the run completes or fails.
447
+ * Falls back to polling if SSE fails.
448
+ */
449
+ async streamRun(runId, handler, signal) {
450
+ try {
451
+ await this.client.streamSSE(`${this.prefix}/runs/${encodeURIComponent(runId)}/stream`, handler, signal);
452
+ // Stream ended — get final state
453
+ return this.getRun(runId);
454
+ }
455
+ catch (err) {
456
+ // An abort is what the caller asked for, not a transport failure.
457
+ // Falling back to polling here kept a cancelled run under watch
458
+ // for the full five-minute poll window.
459
+ if (signal?.aborted || isAbortError(err))
460
+ throw err;
461
+ // SSE failed — fall back to polling until completion
462
+ return this.pollRun(runId, { signal });
463
+ }
464
+ }
465
+ async getConversationMessages(conversationId) {
466
+ const data = await this.client.request(`${this.prefix}/conversations/${encodeURIComponent(conversationId)}/messages`);
467
+ return data?.data ?? [];
468
+ }
469
+ async sendRunInput(runId, input) {
470
+ await this.client.request(`${this.prefix}/runs/${encodeURIComponent(runId)}/input`, { method: 'POST', body: JSON.stringify({ input }) });
471
+ }
472
+ async cancelRun(runId) {
473
+ await this.client.request(`${this.prefix}/runs/${encodeURIComponent(runId)}/cancel`, { method: 'POST' });
474
+ }
475
+ /**
476
+ * Cancel a workflow execution.
477
+ *
478
+ * The workflow counterpart of cancelRun. A workflow run is an execution,
479
+ * not a run, so cancelRun could never stop one -- a Ctrl-C that did not
480
+ * also drop the SSE connection left the pipeline running and billing.
481
+ */
482
+ async cancelExecution(executionId) {
483
+ await this.client.request(`${this.prefix}/executions/${encodeURIComponent(executionId)}/cancel`, { method: 'POST' });
484
+ }
485
+ async pollRun(runId, options = {}) {
486
+ const intervalMs = options.intervalMs ?? 1500;
487
+ const timeoutMs = options.timeoutMs ?? 5 * 60_000;
488
+ const deadline = Date.now() + timeoutMs;
489
+ let lastStepCount = -1;
490
+ while (Date.now() < deadline) {
491
+ if (options.signal?.aborted) {
492
+ throw Object.assign(new Error('Aborted'), { name: 'AbortError' });
493
+ }
494
+ const run = await this.getRun(runId);
495
+ if (Array.isArray(run.steps) && run.steps.length !== lastStepCount) {
496
+ lastStepCount = run.steps.length;
497
+ options.onStep?.(run);
498
+ }
499
+ if (run.status && (TERMINAL_STATUSES.has(run.status) || run.status === 'waiting_input')) {
500
+ return run;
501
+ }
502
+ await new Promise((r) => setTimeout(r, intervalMs));
503
+ }
504
+ throw Object.assign(new Error(`Run ${runId} did not finish within ${Math.round(timeoutMs / 1000)}s`), { runId, pollTimeout: true });
505
+ }
158
506
  }
@@ -10,13 +10,40 @@ export interface StoredCredentials {
10
10
  token: string;
11
11
  email?: string;
12
12
  frontendUrl?: string;
13
+ /**
14
+ * When the token stops working, from the JWT's own `exp` claim.
15
+ *
16
+ * `@almyty/auth` writes it; this reader did not carry the field, so
17
+ * every CLI other than `auth` could not tell an expired credential
18
+ * from a live one and discovered the difference on its first API call
19
+ * — as a 401 from whatever the user was actually trying to do.
20
+ */
21
+ expiresAt?: string;
13
22
  }
23
+ /** Past its `exp`, treating a malformed or absent value as "no idea, assume live". */
24
+ export declare function credentialsExpired(creds: Pick<StoredCredentials, 'expiresAt'>): boolean;
14
25
  export declare function loadCredentials(): StoredCredentials | null;
15
26
  /**
16
- * Resolve credentials from env or file. Returns null if nothing found.
27
+ * Resolve credentials from env or file. Returns null if nothing usable
28
+ * was found — an expired stored credential counts as nothing, because
29
+ * using it produces a 401 on the user's actual request rather than a
30
+ * sentence telling them to log in again.
31
+ *
32
+ * `ALMYTY_TOKEN` is never expiry-checked: it did not come from `auth
33
+ * login`, so there is no claim to check and no file to correct.
17
34
  */
18
35
  export declare function resolveCredentials(): StoredCredentials | null;
19
36
  /**
20
- * Resolve credentials or exit with an error message.
37
+ * Resolve credentials or exit.
38
+ *
39
+ * Exits 3, which is "not authenticated" in the exit-code table every
40
+ * almyty CLI shares (0 ok, 1 unexpected, 2 usage, 3 not authenticated,
41
+ * 4 not found, 5 the operation ran and failed). It exited 1 before, so
42
+ * a script could not tell a stale login from a crash.
21
43
  */
22
44
  export declare function resolveCredentialsOrExit(): StoredCredentials;
45
+ /**
46
+ * Extract the default org slug from a JWT token.
47
+ * Returns null if the token isn't a JWT or has no orgs.
48
+ */
49
+ export declare function getOrgSlugFromToken(token: string): string | null;
@@ -8,6 +8,13 @@ import { readFileSync, existsSync } from 'node:fs';
8
8
  import { homedir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
  export const CREDENTIALS_FILE = join(homedir(), '.almyty', 'credentials.json');
11
+ /** Past its `exp`, treating a malformed or absent value as "no idea, assume live". */
12
+ export function credentialsExpired(creds) {
13
+ if (!creds.expiresAt)
14
+ return false;
15
+ const at = Date.parse(creds.expiresAt);
16
+ return Number.isFinite(at) && at <= Date.now();
17
+ }
11
18
  export function loadCredentials() {
12
19
  try {
13
20
  if (!existsSync(CREDENTIALS_FILE))
@@ -19,7 +26,13 @@ export function loadCredentials() {
19
26
  }
20
27
  }
21
28
  /**
22
- * Resolve credentials from env or file. Returns null if nothing found.
29
+ * Resolve credentials from env or file. Returns null if nothing usable
30
+ * was found — an expired stored credential counts as nothing, because
31
+ * using it produces a 401 on the user's actual request rather than a
32
+ * sentence telling them to log in again.
33
+ *
34
+ * `ALMYTY_TOKEN` is never expiry-checked: it did not come from `auth
35
+ * login`, so there is no claim to check and no file to correct.
23
36
  */
24
37
  export function resolveCredentials() {
25
38
  const envToken = process.env.ALMYTY_TOKEN;
@@ -27,19 +40,57 @@ export function resolveCredentials() {
27
40
  if (envToken)
28
41
  return { url: envUrl, token: envToken };
29
42
  const stored = loadCredentials();
30
- if (stored?.token)
43
+ if (stored?.token && !credentialsExpired(stored))
31
44
  return stored;
32
45
  return null;
33
46
  }
34
47
  /**
35
- * Resolve credentials or exit with an error message.
48
+ * Resolve credentials or exit.
49
+ *
50
+ * Exits 3, which is "not authenticated" in the exit-code table every
51
+ * almyty CLI shares (0 ok, 1 unexpected, 2 usage, 3 not authenticated,
52
+ * 4 not found, 5 the operation ran and failed). It exited 1 before, so
53
+ * a script could not tell a stale login from a crash.
36
54
  */
37
55
  export function resolveCredentialsOrExit() {
38
56
  const creds = resolveCredentials();
39
57
  if (creds)
40
58
  return creds;
59
+ const stored = loadCredentials();
60
+ if (stored?.token && credentialsExpired(stored)) {
61
+ console.error(`Your login expired on ${stored.expiresAt}. Run:`);
62
+ console.error(' npx @almyty/auth login');
63
+ process.exit(3);
64
+ }
41
65
  console.error('Not authenticated. Run one of:');
42
66
  console.error(' npx @almyty/auth login');
43
67
  console.error(' export ALMYTY_TOKEN=<your-token>');
44
- process.exit(1);
68
+ process.exit(3);
69
+ }
70
+ /**
71
+ * Extract the default org slug from a JWT token.
72
+ * Returns null if the token isn't a JWT or has no orgs.
73
+ */
74
+ export function getOrgSlugFromToken(token) {
75
+ try {
76
+ const parts = token.split('.');
77
+ if (parts.length !== 3)
78
+ return null;
79
+ let payload = parts[1];
80
+ payload += '='.repeat((4 - payload.length % 4) % 4);
81
+ const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString());
82
+ const orgs = decoded.organizations;
83
+ if (!Array.isArray(orgs) || !orgs.length)
84
+ return null;
85
+ // Use slug if available, otherwise derive from name
86
+ const org = orgs[0];
87
+ if (org.slug)
88
+ return org.slug;
89
+ if (org.name)
90
+ return org.name.toLowerCase().replace(/\s+/g, '-');
91
+ return null;
92
+ }
93
+ catch {
94
+ return null;
95
+ }
45
96
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { AlmytyClient } from './client.js';
2
- export type { AgentInfo, AgentRun, PipelineNode, RunLimits } from './client.js';
3
- export { loadCredentials, resolveCredentials, resolveCredentialsOrExit, CREDENTIALS_FILE, } from './credentials.js';
1
+ export { AlmytyClient, GatewayClient, parseSseFrame, isAbortError } from './client.js';
2
+ export type { AgentInfo, AgentTool, AgentRun, PipelineNode, RunLimits, StreamEvent, StreamEventHandler, RunnerSummary, RunnerCodingAgent, CodingSession, } from './client.js';
3
+ export { loadCredentials, resolveCredentials, resolveCredentialsOrExit, getOrgSlugFromToken, credentialsExpired, CREDENTIALS_FILE, } from './credentials.js';
4
4
  export type { StoredCredentials } from './credentials.js';
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export { AlmytyClient } from './client.js';
2
- export { loadCredentials, resolveCredentials, resolveCredentialsOrExit, CREDENTIALS_FILE, } from './credentials.js';
1
+ export { AlmytyClient, GatewayClient, parseSseFrame, isAbortError } from './client.js';
2
+ export { loadCredentials, resolveCredentials, resolveCredentialsOrExit, getOrgSlugFromToken, credentialsExpired, CREDENTIALS_FILE, } from './credentials.js';
package/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@almyty/client",
3
- "version": "0.1.0",
4
- "description": "Shared HTTP client and credential resolver for almyty CLI packages",
3
+ "version": "1.3.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "Shared HTTP client and credential resolver used by the almyty CLIs. Reads ~/.almyty/credentials.json; not usually installed on its own.",
5
8
  "type": "module",
6
9
  "main": "dist/index.js",
7
10
  "types": "dist/index.d.ts",
@@ -20,10 +23,22 @@
20
23
  "sdk"
21
24
  ],
22
25
  "author": "almyty",
23
- "license": "BSL-1.1",
26
+ "license": "Apache-2.0",
24
27
  "devDependencies": {
25
28
  "@types/node": "^25.4.0",
26
29
  "typescript": "^5.3.0",
27
30
  "vitest": "^4.1.0"
31
+ },
32
+ "homepage": "https://almyty.com",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/almyty-inc/almyty.git",
36
+ "directory": "packages/client"
37
+ },
38
+ "bugs": {
39
+ "url": "https://github.com/almyty-inc/almyty/issues"
40
+ },
41
+ "overrides": {
42
+ "postcss": "^8.5.23"
28
43
  }
29
44
  }