@almyty/client 1.2.0 → 1.5.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 CHANGED
@@ -29,8 +29,8 @@ almyty is the full-stack platform for AI agents, agnostic by design: any LLM, an
29
29
  API turned into tools, served over MCP, A2A, UTCP, and Agent Skills. Open source,
30
30
  no lock-in.
31
31
 
32
- - Website — https://almyty.com
33
- - Docs — https://docs.almyty.com
34
- - Source — https://github.com/almyty-inc/almyty
32
+ - Website: https://almyty.com
33
+ - Docs: https://docs.almyty.com
34
+ - Source: https://github.com/almyty-inc/almyty
35
35
 
36
36
  Apache-2.0 © Almyty Inc.
package/dist/client.d.ts CHANGED
@@ -80,6 +80,21 @@ export interface CodingSession {
80
80
  }
81
81
  /** Callback for stream events. */
82
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;
83
98
  export declare class AlmytyClient {
84
99
  private readonly baseUrl;
85
100
  private readonly token;
@@ -89,8 +104,11 @@ export declare class AlmytyClient {
89
104
  /**
90
105
  * Connect to an SSE endpoint and call handler for each event.
91
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.
92
110
  */
93
- streamSSE(path: string, handler: StreamEventHandler, signal?: AbortSignal): Promise<void>;
111
+ streamSSE(path: string, handler: StreamEventHandler, signal?: AbortSignal, init?: RequestInit): Promise<void>;
94
112
  private unwrap;
95
113
  listAgents(): Promise<AgentInfo[]>;
96
114
  getAgent(id: string): Promise<AgentInfo>;
@@ -159,6 +177,16 @@ export declare class GatewayClient {
159
177
  conversationId?: string;
160
178
  }): Promise<AgentRun>;
161
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>;
162
190
  /**
163
191
  * Stream run events via SSE. Calls handler for each event
164
192
  * (llm.started, llm.chunk, llm.response, tool.started, tool.result,
@@ -175,9 +203,18 @@ export declare class GatewayClient {
175
203
  }>>;
176
204
  sendRunInput(runId: string, input: string): Promise<void>;
177
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>;
178
214
  pollRun(runId: string, options?: {
179
215
  intervalMs?: number;
180
216
  timeoutMs?: number;
181
217
  onStep?: (run: AgentRun) => void;
218
+ signal?: AbortSignal;
182
219
  }): Promise<AgentRun>;
183
220
  }
package/dist/client.js CHANGED
@@ -6,7 +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(['run.completed', 'run.failed', 'run.cancelled', 'coding.exit']);
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
+ }
10
73
  export class AlmytyClient {
11
74
  baseUrl;
12
75
  token;
@@ -27,13 +90,27 @@ export class AlmytyClient {
27
90
  ...this.headers(),
28
91
  ...(init.headers || {}),
29
92
  };
30
- 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
+ }
31
106
  if (!res.ok) {
32
- if (res.status === 401) {
33
- throw new Error('Authentication failed. Run: npx @almyty/auth login');
34
- }
35
107
  const text = await res.text().catch(() => '');
36
- 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 });
37
114
  }
38
115
  if (res.status === 204)
39
116
  return null;
@@ -42,16 +119,24 @@ export class AlmytyClient {
42
119
  /**
43
120
  * Connect to an SSE endpoint and call handler for each event.
44
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.
45
125
  */
46
- async streamSSE(path, handler, signal) {
126
+ async streamSSE(path, handler, signal, init = {}) {
47
127
  const url = `${this.baseUrl}${path}`;
48
128
  const res = await fetch(url, {
49
- headers: { ...this.headers(), Accept: 'text/event-stream' },
129
+ ...init,
130
+ headers: { ...this.headers(), Accept: 'text/event-stream', ...(init.headers || {}) },
50
131
  signal,
51
132
  });
52
133
  if (!res.ok) {
53
134
  const text = await res.text().catch(() => '');
54
- throw new Error(`SSE ${res.status}: ${text}`);
135
+ throw Object.assign(new Error(`SSE ${res.status}: ${text}`), {
136
+ status: res.status,
137
+ body: text,
138
+ url,
139
+ });
55
140
  }
56
141
  const body = res.body;
57
142
  if (!body)
@@ -59,44 +144,55 @@ export class AlmytyClient {
59
144
  const reader = body.getReader();
60
145
  const decoder = new TextDecoder();
61
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 = [];
62
152
  try {
63
- while (true) {
153
+ for (;;) {
64
154
  const { value, done } = await reader.read();
65
155
  if (done)
66
156
  break;
67
157
  buffer += decoder.decode(value, { stream: true });
68
- // Parse SSE frames
69
- const lines = buffer.split('\n');
70
- buffer = lines.pop(); // keep incomplete line
71
- let eventType = 'message';
72
- let dataLines = [];
73
- for (const line of lines) {
74
- if (line.startsWith('event: ')) {
75
- eventType = line.slice(7).trim();
76
- }
77
- else if (line.startsWith('data: ')) {
78
- dataLines.push(line.slice(6));
79
- }
80
- else if (line === '') {
81
- // End of frame
82
- if (dataLines.length) {
83
- const raw = dataLines.join('\n');
84
- try {
85
- const data = JSON.parse(raw);
86
- const event = { type: data.type || eventType, data };
87
- handler(event);
88
- if (TERMINAL_EVENT_TYPES.has(event.type))
89
- return;
90
- }
91
- catch { /* skip malformed */ }
92
- dataLines = [];
93
- eventType = 'message';
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;
94
172
  }
95
173
  }
174
+ else {
175
+ frame.push(line);
176
+ }
177
+ nl = buffer.indexOf('\n');
96
178
  }
97
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);
98
187
  }
99
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);
100
196
  reader.releaseLock();
101
197
  }
102
198
  }
@@ -320,12 +416,29 @@ export class GatewayClient {
320
416
  output: run.output,
321
417
  error: run.error,
322
418
  steps: run.steps,
419
+ totalCost: run.totalCost,
420
+ totalTokens: run.totalTokens,
323
421
  };
324
422
  }
325
423
  async getRun(runId) {
326
424
  const data = await this.client.request(`${this.prefix}/runs/${encodeURIComponent(runId)}`);
327
425
  return (data?.data ?? data);
328
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
+ }
329
442
  /**
330
443
  * Stream run events via SSE. Calls handler for each event
331
444
  * (llm.started, llm.chunk, llm.response, tool.started, tool.result,
@@ -339,9 +452,14 @@ export class GatewayClient {
339
452
  // Stream ended — get final state
340
453
  return this.getRun(runId);
341
454
  }
342
- catch {
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;
343
461
  // SSE failed — fall back to polling until completion
344
- return this.pollRun(runId);
462
+ return this.pollRun(runId, { signal });
345
463
  }
346
464
  }
347
465
  async getConversationMessages(conversationId) {
@@ -354,12 +472,25 @@ export class GatewayClient {
354
472
  async cancelRun(runId) {
355
473
  await this.client.request(`${this.prefix}/runs/${encodeURIComponent(runId)}/cancel`, { method: 'POST' });
356
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
+ }
357
485
  async pollRun(runId, options = {}) {
358
486
  const intervalMs = options.intervalMs ?? 1500;
359
487
  const timeoutMs = options.timeoutMs ?? 5 * 60_000;
360
488
  const deadline = Date.now() + timeoutMs;
361
489
  let lastStepCount = -1;
362
490
  while (Date.now() < deadline) {
491
+ if (options.signal?.aborted) {
492
+ throw Object.assign(new Error('Aborted'), { name: 'AbortError' });
493
+ }
363
494
  const run = await this.getRun(runId);
364
495
  if (Array.isArray(run.steps) && run.steps.length !== lastStepCount) {
365
496
  lastStepCount = run.steps.length;
@@ -370,6 +501,6 @@ export class GatewayClient {
370
501
  }
371
502
  await new Promise((r) => setTimeout(r, intervalMs));
372
503
  }
373
- throw new Error(`Run ${runId} did not finish within ${Math.round(timeoutMs / 1000)}s`);
504
+ throw Object.assign(new Error(`Run ${runId} did not finish within ${Math.round(timeoutMs / 1000)}s`), { runId, pollTimeout: true });
374
505
  }
375
506
  }
@@ -10,14 +10,36 @@ 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;
23
45
  /**
@@ -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,21 +40,32 @@ 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);
45
69
  }
46
70
  /**
47
71
  * Extract the default org slug from a JWT token.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { AlmytyClient, GatewayClient } from './client.js';
1
+ export { AlmytyClient, GatewayClient, parseSseFrame, isAbortError } from './client.js';
2
2
  export type { AgentInfo, AgentTool, AgentRun, PipelineNode, RunLimits, StreamEvent, StreamEventHandler, RunnerSummary, RunnerCodingAgent, CodingSession, } from './client.js';
3
- export { loadCredentials, resolveCredentials, resolveCredentialsOrExit, getOrgSlugFromToken, CREDENTIALS_FILE, } from './credentials.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, GatewayClient } from './client.js';
2
- export { loadCredentials, resolveCredentials, resolveCredentialsOrExit, getOrgSlugFromToken, 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@almyty/client",
3
- "version": "1.2.0",
3
+ "version": "1.5.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -37,5 +37,8 @@
37
37
  },
38
38
  "bugs": {
39
39
  "url": "https://github.com/almyty-inc/almyty/issues"
40
+ },
41
+ "overrides": {
42
+ "postcss": "^8.5.23"
40
43
  }
41
44
  }