@standardagents/code 0.0.2-dev.517db40 → 0.0.2-dev.b3cdaaf

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.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Registry of long-running background processes the agent spawns.
3
+ *
4
+ * The metadata lives in the THREAD's durable key-value store (server-side), not
5
+ * on this client — so the process list resumes from any machine, at any time,
6
+ * and nothing about it is kept on the client. (The process *output* logs are the
7
+ * one host-local thing: a detached process writes its stdout to a file on the
8
+ * machine it runs on; that file can only live there.)
9
+ *
10
+ * Liveness is re-derived from the OS for this machine's processes — a recorded
11
+ * "running" entry is verified with a signal-0 probe — so the registry self-heals
12
+ * if a process died while we weren't watching. Processes on other machines show
13
+ * their last-known status (we can't probe a PID we don't own).
14
+ */
15
+ import path from "node:path";
16
+ import os from "node:os";
17
+ import type { ApiClient } from "./api.ts";
18
+
19
+ /** Process *output* logs are host-local (the process writes its stdout here). */
20
+ export const LOG_DIR = path.join(os.homedir(), ".standardagents", "process-logs");
21
+
22
+ /** The thread KV key under which the process list is stored. */
23
+ const KEY = "bg_processes";
24
+
25
+ export type ProcessStatus = "running" | "exited" | "stopped";
26
+
27
+ export interface ProcessEntry {
28
+ id: string;
29
+ pid: number;
30
+ command: string;
31
+ description?: string;
32
+ cwd: string;
33
+ machine: string;
34
+ logPath: string;
35
+ startedAt: number;
36
+ status: ProcessStatus;
37
+ exitCode?: number | null;
38
+ endedAt?: number;
39
+ }
40
+
41
+ function isAlive(pid: number): boolean {
42
+ try {
43
+ process.kill(pid, 0);
44
+ return true;
45
+ } catch (err) {
46
+ // EPERM means the process exists but isn't ours; ESRCH means it's gone.
47
+ return (err as NodeJS.ErrnoException).code === "EPERM";
48
+ }
49
+ }
50
+
51
+ export class ProcessRegistry {
52
+ constructor(
53
+ private api: ApiClient,
54
+ private threadId: string,
55
+ private machine: string
56
+ ) {}
57
+
58
+ private async read(): Promise<ProcessEntry[]> {
59
+ const value = await this.api.kvGet(this.threadId, KEY);
60
+ return Array.isArray(value) ? (value as ProcessEntry[]) : [];
61
+ }
62
+
63
+ private async write(entries: ProcessEntry[]): Promise<void> {
64
+ await this.api.kvSet(this.threadId, KEY, entries);
65
+ }
66
+
67
+ /** Mark this machine's dead "running" entries as exited. Returns true if any changed. */
68
+ private reconcileLiveness(entries: ProcessEntry[]): boolean {
69
+ let changed = false;
70
+ for (const e of entries) {
71
+ if (e.machine === this.machine && e.status === "running" && !isAlive(e.pid)) {
72
+ e.status = "exited";
73
+ e.endedAt = Date.now();
74
+ changed = true;
75
+ }
76
+ }
77
+ return changed;
78
+ }
79
+
80
+ /** All tracked processes, newest first, with liveness re-checked + persisted. */
81
+ async list(): Promise<ProcessEntry[]> {
82
+ const entries = await this.read();
83
+ if (this.reconcileLiveness(entries)) await this.write(entries);
84
+ return entries.sort((a, b) => b.startedAt - a.startedAt);
85
+ }
86
+
87
+ async runningCount(): Promise<number> {
88
+ const entries = await this.list();
89
+ return entries.filter((e) => e.status === "running").length;
90
+ }
91
+
92
+ async get(id: string): Promise<ProcessEntry | null> {
93
+ return (await this.read()).find((e) => e.id === id) ?? null;
94
+ }
95
+
96
+ async add(entry: ProcessEntry): Promise<void> {
97
+ const entries = await this.read();
98
+ entries.push(entry);
99
+ await this.write(entries);
100
+ }
101
+
102
+ async markExited(id: string, exitCode: number | null): Promise<void> {
103
+ const entries = await this.read();
104
+ const e = entries.find((x) => x.id === id);
105
+ if (e && e.status === "running") {
106
+ e.status = "exited";
107
+ e.exitCode = exitCode;
108
+ e.endedAt = Date.now();
109
+ await this.write(entries);
110
+ }
111
+ }
112
+
113
+ async markStopped(id: string): Promise<void> {
114
+ const entries = await this.read();
115
+ const e = entries.find((x) => x.id === id);
116
+ if (e) {
117
+ e.status = "stopped";
118
+ e.endedAt = Date.now();
119
+ await this.write(entries);
120
+ }
121
+ }
122
+ }
package/src/stream.ts ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Subscribes to a thread's message WebSocket to render streamed assistant text
3
+ * and detect end-of-turn (an assistant message with non-empty text content,
4
+ * which under stopOnResponse means the agent has finished its turn).
5
+ */
6
+ import type { ApiClient } from "./api.ts";
7
+
8
+ export interface StreamHooks {
9
+ onChunk(text: string): void;
10
+ /**
11
+ * A complete assistant message. `hasToolCalls` is true for narration that
12
+ * precedes tool calls (the turn continues) and false for the final answer
13
+ * (the turn is over).
14
+ */
15
+ onAssistantText(content: string, hasToolCalls: boolean): void;
16
+ /**
17
+ * A custom thread event (the `{ type: "event", eventType, data }` channel) —
18
+ * e.g. `tool_call_started`, `tool_call_done`, `generation`.
19
+ */
20
+ onEvent?(eventType: string, data: any): void;
21
+ onError(err: string): void;
22
+ }
23
+
24
+ export class MessageStream {
25
+ private ws: WebSocket | null = null;
26
+ private closed = false;
27
+ private reconnectAttempt = 0;
28
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
29
+ private resolveConnected: (() => void) | null = null;
30
+
31
+ constructor(
32
+ private api: ApiClient,
33
+ private threadId: string,
34
+ private hooks: StreamHooks
35
+ ) {}
36
+
37
+ /**
38
+ * Connect and stay connected. Resolves on first open; reconnects on any drop
39
+ * with exponential backoff. Silent — the bridge surfaces the user-facing
40
+ * connection status; completion is detected via HTTP polling regardless, so a
41
+ * dropped stream only affects live display, not correctness.
42
+ */
43
+ connect(): Promise<void> {
44
+ return new Promise((resolve) => {
45
+ let settled = false;
46
+ this.resolveConnected = () => {
47
+ if (!settled) {
48
+ settled = true;
49
+ resolve();
50
+ }
51
+ };
52
+ setTimeout(() => this.resolveConnected?.(), 8000);
53
+ this.openSocket();
54
+ });
55
+ }
56
+
57
+ private openSocket(): void {
58
+ if (this.closed) return;
59
+ const url = `${this.api.wsEndpoint}/api/threads/${this.threadId}/stream?token=${encodeURIComponent(this.api.bearer)}`;
60
+ let ws: WebSocket;
61
+ try {
62
+ ws = new WebSocket(url);
63
+ } catch {
64
+ this.scheduleReconnect();
65
+ return;
66
+ }
67
+ this.ws = ws;
68
+ ws.addEventListener("open", () => {
69
+ this.reconnectAttempt = 0;
70
+ this.resolveConnected?.();
71
+ });
72
+ ws.addEventListener("message", (ev) => this.onMessage(String((ev as MessageEvent).data)));
73
+ // Both 'error' (failed open) and 'close' must drive reconnection.
74
+ ws.addEventListener("error", () => this.handleDrop(ws));
75
+ ws.addEventListener("close", () => this.handleDrop(ws));
76
+ }
77
+
78
+ private handleDrop(ws: WebSocket): void {
79
+ if (this.ws !== ws) return;
80
+ this.ws = null;
81
+ this.scheduleReconnect();
82
+ }
83
+
84
+ private scheduleReconnect(): void {
85
+ if (this.closed || this.reconnectTimer) return;
86
+ this.reconnectAttempt++;
87
+ const base = Math.min(500 * 2 ** (this.reconnectAttempt - 1), 15000);
88
+ const delay = base + Math.floor(Math.random() * 400);
89
+ this.reconnectTimer = setTimeout(() => {
90
+ this.reconnectTimer = null;
91
+ this.openSocket();
92
+ }, delay);
93
+ }
94
+
95
+ close(): void {
96
+ this.closed = true;
97
+ if (this.reconnectTimer) {
98
+ clearTimeout(this.reconnectTimer);
99
+ this.reconnectTimer = null;
100
+ }
101
+ this.ws?.close();
102
+ }
103
+
104
+ private onMessage(raw: string): void {
105
+ let msg: any;
106
+ try {
107
+ msg = JSON.parse(raw);
108
+ } catch {
109
+ return;
110
+ }
111
+ if (msg.type === "event" && typeof msg.eventType === "string") {
112
+ this.hooks.onEvent?.(msg.eventType, msg.data);
113
+ return;
114
+ }
115
+ if (msg.type === "message_chunk" && (msg.depth ?? 0) === 0) {
116
+ if (typeof msg.chunk === "string") this.hooks.onChunk(msg.chunk);
117
+ return;
118
+ }
119
+ if (msg.type === "message_data" && (msg.depth ?? 0) === 0) {
120
+ const data = msg.data || {};
121
+ if (data.role === "assistant" && typeof data.content === "string" && data.content.trim()) {
122
+ // tool_calls is serialized as a JSON string, not an array.
123
+ const tc = data.tool_calls;
124
+ const hasToolCalls = Array.isArray(tc)
125
+ ? tc.length > 0
126
+ : typeof tc === "string" && tc.trim() !== "" && tc.trim() !== "null" && tc.trim() !== "[]";
127
+ this.hooks.onAssistantText(data.content, hasToolCalls);
128
+ }
129
+ if (data.role === "assistant" && data.status === "failed" && data.error) {
130
+ this.hooks.onError(String(data.error));
131
+ }
132
+ }
133
+ }
134
+ }