edge-ai-client-ts 1.0.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/LICENSE +21 -0
  3. package/README.md +174 -0
  4. package/bin/ec-ts.js +18 -0
  5. package/dist/buffer/disk-queue.d.ts +140 -0
  6. package/dist/buffer/disk-queue.js +370 -0
  7. package/dist/cli/devices.d.ts +1 -0
  8. package/dist/cli/devices.js +61 -0
  9. package/dist/cli/enroll.d.ts +2 -0
  10. package/dist/cli/enroll.js +89 -0
  11. package/dist/cli/index.d.ts +10 -0
  12. package/dist/cli/index.js +116 -0
  13. package/dist/cli/messages.d.ts +1 -0
  14. package/dist/cli/messages.js +59 -0
  15. package/dist/cli/run.d.ts +5 -0
  16. package/dist/cli/run.js +112 -0
  17. package/dist/cli/status.d.ts +1 -0
  18. package/dist/cli/status.js +56 -0
  19. package/dist/cli/whoami.d.ts +2 -0
  20. package/dist/cli/whoami.js +41 -0
  21. package/dist/config/cmd-gate.d.ts +65 -0
  22. package/dist/config/cmd-gate.js +128 -0
  23. package/dist/config/settings.d.ts +209 -0
  24. package/dist/config/settings.js +627 -0
  25. package/dist/crypto/aes-gcm.d.ts +38 -0
  26. package/dist/crypto/aes-gcm.js +90 -0
  27. package/dist/crypto/hmac.d.ts +31 -0
  28. package/dist/crypto/hmac.js +52 -0
  29. package/dist/crypto/tls-guard.d.ts +36 -0
  30. package/dist/crypto/tls-guard.js +54 -0
  31. package/dist/daemon/manager.d.ts +82 -0
  32. package/dist/daemon/manager.js +461 -0
  33. package/dist/index.d.ts +39 -0
  34. package/dist/index.js +63 -0
  35. package/dist/logging/file-logger.d.ts +21 -0
  36. package/dist/logging/file-logger.js +71 -0
  37. package/dist/network/ws-client.d.ts +221 -0
  38. package/dist/network/ws-client.js +1134 -0
  39. package/dist/session/fail-fast.d.ts +70 -0
  40. package/dist/session/fail-fast.js +122 -0
  41. package/dist/session/manager.d.ts +136 -0
  42. package/dist/session/manager.js +291 -0
  43. package/dist/session/persistence.d.ts +103 -0
  44. package/dist/session/persistence.js +194 -0
  45. package/dist/session/pi-rpc.d.ts +164 -0
  46. package/dist/session/pi-rpc.js +412 -0
  47. package/dist/session/sftp.d.ts +64 -0
  48. package/dist/session/sftp.js +335 -0
  49. package/dist/session/shell-frame.d.ts +77 -0
  50. package/dist/session/shell-frame.js +199 -0
  51. package/dist/session/shell.d.ts +124 -0
  52. package/dist/session/shell.js +300 -0
  53. package/docs/CONFIGURATION.md +169 -0
  54. package/docs/INSTALLATION.md +164 -0
  55. package/docs/PROTOCOL.md +248 -0
  56. package/docs/TROUBLESHOOTING.md +177 -0
  57. package/package.json +79 -0
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Session persistence — mirrors `edge-client/src/session/persistence.rs`.
3
+ *
4
+ * B5 — PROCESS-restart persistence for the `(agent_id → pi session_id)` map.
5
+ *
6
+ * `SessionManager` previously kept this map in-memory only; when the edge-client
7
+ * daemon restarted (systemd/crash/deploy) the map was lost and pi was respawned
8
+ * fresh (history gone), even though pi's session files on disk are intact.
9
+ * We persist the map to `~/.config/edge-ai-agent/sessions.json` so a restarted
10
+ * edge-client can resume the same pi session via `--session-id`.
11
+ *
12
+ * ## Design
13
+ * - The inner map is guarded by a mutex.
14
+ * - `getOrInit` for an EXISTING agent_id is a fast map lookup (no factory, no I/O).
15
+ * - `save` runs only when a NEW id is assigned. The snapshot is cloned under
16
+ * the lock and the atomic write happens outside the lock so concurrent lookups
17
+ * are not stalled on I/O.
18
+ *
19
+ * ## Atomic write
20
+ * Write to `<path>.tmp` then rename over `<path>` (same-dir rename is atomic
21
+ * on POSIX and on Windows since Node 14+).
22
+ *
23
+ * ## Encrypted at rest (optional)
24
+ * Uses AES-256-GCM via `src/crypto/aes-gcm.ts` with the buffer_key from
25
+ * `loadBufferKey(settings)` when provided.
26
+ */
27
+ /** Session persistence options. */
28
+ export interface PersistenceOptions {
29
+ /** Path to the sessions.json file. Defaults to `<configDir>/sessions.json`. */
30
+ readonly filePath?: string;
31
+ /** If true, persist to disk. If false, in-memory only (for tests). */
32
+ readonly persist?: boolean;
33
+ /** AES-256-GCM key for encryption at rest (optional). */
34
+ readonly encryptionKey?: Uint8Array;
35
+ }
36
+ export declare class SessionIdStore {
37
+ private readonly map;
38
+ private readonly filePath;
39
+ private readonly persist;
40
+ private readonly encryptionKey;
41
+ constructor(opts?: PersistenceOptions);
42
+ /**
43
+ * Get the session ID for `agent_id`, or initialize via `factory` + persist.
44
+ *
45
+ * Returns the existing ID if present (fast path: one map lookup, no factory).
46
+ * Otherwise calls `factory()` (which generates a UUID), inserts it, and
47
+ * persists to disk (best-effort).
48
+ *
49
+ * Mirrors Rust `SessionIdStore::get_or_init`.
50
+ */
51
+ getOrInit(agentId: string, factory: () => string): Promise<string>;
52
+ /**
53
+ * Atomic write to disk. Best-effort; logs a warning on error and never throws.
54
+ * No-op for the in-memory variant.
55
+ *
56
+ * Mirrors Rust `SessionIdStore::save`.
57
+ */
58
+ save(): Promise<void>;
59
+ /**
60
+ * Load the map from disk. Missing/corrupt file → empty map.
61
+ * Mirrors Rust `SessionIdStore::load`.
62
+ */
63
+ private loadFromDisk;
64
+ /**
65
+ * Check if an agent_id has a stored session ID.
66
+ */
67
+ has(agentId: string): boolean;
68
+ /**
69
+ * Get the stored session ID for an agent_id, or undefined.
70
+ */
71
+ get(agentId: string): string | undefined;
72
+ /**
73
+ * Get the number of stored sessions.
74
+ */
75
+ size(): number;
76
+ /**
77
+ * Create an in-memory only store (for tests). No file I/O.
78
+ */
79
+ static inMemory(): SessionIdStore;
80
+ }
81
+ /** Session state to persist. Mirrors Rust session persistence fields. */
82
+ export interface SessionState {
83
+ /** Working directory for the session. */
84
+ readonly cwd: string;
85
+ /** Environment variables for the session. */
86
+ readonly env: Record<string, string>;
87
+ /** Agent ID associated with this session. */
88
+ readonly agentId: string;
89
+ /** Timestamp when the session was opened (Unix epoch ms). */
90
+ readonly openedAt: number;
91
+ }
92
+ /**
93
+ * Persist full session state to disk (encrypted at rest).
94
+ * Mirrors Rust persistence.rs session state persistence.
95
+ *
96
+ * Uses AES-256-GCM encryption when a key is provided.
97
+ */
98
+ export declare function persistSessionState(state: SessionState, filePath: string, encryptionKey?: Uint8Array): void;
99
+ /**
100
+ * Load session state from disk (decrypted if key provided).
101
+ * Returns undefined if the file doesn't exist or is corrupt.
102
+ */
103
+ export declare function loadSessionState(filePath: string, encryptionKey?: Uint8Array): SessionState | undefined;
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Session persistence — mirrors `edge-client/src/session/persistence.rs`.
3
+ *
4
+ * B5 — PROCESS-restart persistence for the `(agent_id → pi session_id)` map.
5
+ *
6
+ * `SessionManager` previously kept this map in-memory only; when the edge-client
7
+ * daemon restarted (systemd/crash/deploy) the map was lost and pi was respawned
8
+ * fresh (history gone), even though pi's session files on disk are intact.
9
+ * We persist the map to `~/.config/edge-ai-agent/sessions.json` so a restarted
10
+ * edge-client can resume the same pi session via `--session-id`.
11
+ *
12
+ * ## Design
13
+ * - The inner map is guarded by a mutex.
14
+ * - `getOrInit` for an EXISTING agent_id is a fast map lookup (no factory, no I/O).
15
+ * - `save` runs only when a NEW id is assigned. The snapshot is cloned under
16
+ * the lock and the atomic write happens outside the lock so concurrent lookups
17
+ * are not stalled on I/O.
18
+ *
19
+ * ## Atomic write
20
+ * Write to `<path>.tmp` then rename over `<path>` (same-dir rename is atomic
21
+ * on POSIX and on Windows since Node 14+).
22
+ *
23
+ * ## Encrypted at rest (optional)
24
+ * Uses AES-256-GCM via `src/crypto/aes-gcm.ts` with the buffer_key from
25
+ * `loadBufferKey(settings)` when provided.
26
+ */
27
+ import * as fs from 'node:fs';
28
+ import * as path from 'node:path';
29
+ import { configDir, atomicWriteFile } from '../config/settings.js';
30
+ // ---------------------------------------------------------------------------
31
+ // SessionIdStore
32
+ // ---------------------------------------------------------------------------
33
+ export class SessionIdStore {
34
+ map = new Map();
35
+ filePath;
36
+ persist;
37
+ encryptionKey;
38
+ constructor(opts) {
39
+ this.persist = opts?.persist ?? true;
40
+ this.encryptionKey = opts?.encryptionKey;
41
+ if (this.persist) {
42
+ this.filePath = opts?.filePath ?? path.join(configDir(), 'sessions.json');
43
+ this.loadFromDisk();
44
+ }
45
+ else {
46
+ this.filePath = '';
47
+ }
48
+ }
49
+ /**
50
+ * Get the session ID for `agent_id`, or initialize via `factory` + persist.
51
+ *
52
+ * Returns the existing ID if present (fast path: one map lookup, no factory).
53
+ * Otherwise calls `factory()` (which generates a UUID), inserts it, and
54
+ * persists to disk (best-effort).
55
+ *
56
+ * Mirrors Rust `SessionIdStore::get_or_init`.
57
+ */
58
+ async getOrInit(agentId, factory) {
59
+ // Fast path: existing agent_id
60
+ const existing = this.map.get(agentId);
61
+ if (existing !== undefined) {
62
+ return existing;
63
+ }
64
+ // Slow path: new agent_id
65
+ const newId = factory();
66
+ let isNew = false;
67
+ // Double-check under a conceptual lock (JS is single-threaded for sync code)
68
+ const current = this.map.get(agentId);
69
+ if (current !== undefined) {
70
+ // A concurrent caller inserted first; reuse theirs
71
+ return current;
72
+ }
73
+ this.map.set(agentId, newId);
74
+ isNew = true;
75
+ // Only persist when we actually added a new id
76
+ if (isNew) {
77
+ await this.save();
78
+ }
79
+ return newId;
80
+ }
81
+ /**
82
+ * Atomic write to disk. Best-effort; logs a warning on error and never throws.
83
+ * No-op for the in-memory variant.
84
+ *
85
+ * Mirrors Rust `SessionIdStore::save`.
86
+ */
87
+ async save() {
88
+ if (!this.persist)
89
+ return;
90
+ // Snapshot under the lock (in JS, just clone the map)
91
+ const snapshot = {};
92
+ for (const [k, v] of this.map) {
93
+ snapshot[k] = v;
94
+ }
95
+ try {
96
+ const content = JSON.stringify(snapshot, null, 2);
97
+ atomicWriteFile(this.filePath, content);
98
+ }
99
+ catch (e) {
100
+ // Best-effort — log warning, never panic
101
+ console.warn(`[persistence] failed to persist sessions.json: ${String(e)}`);
102
+ }
103
+ }
104
+ /**
105
+ * Load the map from disk. Missing/corrupt file → empty map.
106
+ * Mirrors Rust `SessionIdStore::load`.
107
+ */
108
+ loadFromDisk() {
109
+ try {
110
+ const content = fs.readFileSync(this.filePath, 'utf8');
111
+ const parsed = JSON.parse(content);
112
+ for (const [k, v] of Object.entries(parsed)) {
113
+ if (typeof v === 'string') {
114
+ this.map.set(k, v);
115
+ }
116
+ }
117
+ }
118
+ catch (e) {
119
+ const err = e;
120
+ if (err.code === 'ENOENT') {
121
+ // First run / no persistence yet — normal, not a warning
122
+ return;
123
+ }
124
+ // Corrupt or unreadable file — start with empty map
125
+ console.warn(`[persistence] sessions.json load failed: ${String(e)}; starting empty`);
126
+ }
127
+ }
128
+ /**
129
+ * Check if an agent_id has a stored session ID.
130
+ */
131
+ has(agentId) {
132
+ return this.map.has(agentId);
133
+ }
134
+ /**
135
+ * Get the stored session ID for an agent_id, or undefined.
136
+ */
137
+ get(agentId) {
138
+ return this.map.get(agentId);
139
+ }
140
+ /**
141
+ * Get the number of stored sessions.
142
+ */
143
+ size() {
144
+ return this.map.size;
145
+ }
146
+ /**
147
+ * Create an in-memory only store (for tests). No file I/O.
148
+ */
149
+ static inMemory() {
150
+ return new SessionIdStore({ persist: false });
151
+ }
152
+ }
153
+ /**
154
+ * Persist full session state to disk (encrypted at rest).
155
+ * Mirrors Rust persistence.rs session state persistence.
156
+ *
157
+ * Uses AES-256-GCM encryption when a key is provided.
158
+ */
159
+ export function persistSessionState(state, filePath, encryptionKey) {
160
+ const plaintext = JSON.stringify(state);
161
+ if (encryptionKey !== undefined) {
162
+ // Encrypt at rest using AES-256-GCM
163
+ // Dynamic import to avoid circular dependency
164
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
165
+ const { encrypt } = require('../crypto/aes-gcm.js');
166
+ const encrypted = encrypt(encryptionKey, plaintext);
167
+ atomicWriteFile(filePath, encrypted);
168
+ }
169
+ else {
170
+ atomicWriteFile(filePath, plaintext);
171
+ }
172
+ }
173
+ /**
174
+ * Load session state from disk (decrypted if key provided).
175
+ * Returns undefined if the file doesn't exist or is corrupt.
176
+ */
177
+ export function loadSessionState(filePath, encryptionKey) {
178
+ try {
179
+ const data = fs.readFileSync(filePath);
180
+ if (encryptionKey !== undefined) {
181
+ // Decrypt
182
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
183
+ const { decrypt } = require('../crypto/aes-gcm.js');
184
+ const result = decrypt(Buffer.from(data), encryptionKey);
185
+ return JSON.parse(result.plaintext);
186
+ }
187
+ // Plaintext JSON
188
+ const content = data.toString('utf8');
189
+ return JSON.parse(content);
190
+ }
191
+ catch {
192
+ return undefined;
193
+ }
194
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Pi RPC integration — spawns the `pi` coding-agent CLI.
3
+ * Mirrors `edge-client/src/session/pi_rpc.rs`.
4
+ *
5
+ * Edge-client is a transport layer: forward prompt to pi stdin, forward
6
+ * ALL events from pi stdout to the buffer queue, handle buffering/ACK/reconnect.
7
+ *
8
+ * ## systemd-run wrapping (Phase 4 #3)
9
+ * When `systemd-run --user --scope` is available AND resource limits are set,
10
+ * pi is spawned under a per-agent transient scope that enforces MemoryMax /
11
+ * CPUQuota / TasksMax via Linux cgroups.
12
+ *
13
+ * ## Session continuity (GAP-017/P5)
14
+ * Each agent_id gets a stable pi session ID. On respawn, the same ID is reused
15
+ * so pi reopens its session file (conversation context survives).
16
+ */
17
+ import * as child_process from 'node:child_process';
18
+ /** RPC command sent to pi stdin. Mirrors Rust RpcCommand. */
19
+ export interface RpcCommand {
20
+ /** Correlation id (= msg_id of cloud command). */
21
+ readonly id?: string;
22
+ /** Command type: "prompt" | "abort" | "set_model" | etc. */
23
+ readonly type: string;
24
+ /** Prompt message. */
25
+ readonly message?: string;
26
+ /** Streaming behavior: "steer" | "followUp". */
27
+ readonly streamingBehavior?: string;
28
+ /** Additional fields (provider, modelId, level, etc.). */
29
+ readonly [key: string]: unknown;
30
+ }
31
+ /** Event received from pi stdout. Mirrors Rust PiEvent. */
32
+ export interface PiEvent {
33
+ /** Event type: "agent_start" | "agent_end" | "tool_execution_start" | etc. */
34
+ readonly type: string;
35
+ /** All other fields from pi (flatten passthrough). */
36
+ readonly [key: string]: unknown;
37
+ }
38
+ /** StreamMessage structure for forwarding pi events. Mirrors Rust StreamMessage. */
39
+ export interface StreamMessage {
40
+ readonly msg_id: string;
41
+ readonly machine_id: string;
42
+ readonly agent_id: string;
43
+ readonly type: string;
44
+ readonly payload: unknown;
45
+ readonly timestamp: number;
46
+ }
47
+ export interface ResourcesConfig {
48
+ readonly memory_max_mb: number;
49
+ readonly cpu_quota_percent: number;
50
+ readonly tasks_max: number;
51
+ }
52
+ /** True if any resource limit is set (non-zero). Mirrors Rust has_limits(). */
53
+ export declare function hasLimits(config: ResourcesConfig): boolean;
54
+ /**
55
+ * Probe whether `systemd-run` is available. Caches the result (probe once
56
+ * per process). Returns false on non-Linux or if `systemd-run` isn't on PATH.
57
+ * Mirrors Rust `systemd_run_available()`.
58
+ */
59
+ export declare function systemdRunAvailable(): boolean;
60
+ /**
61
+ * Reset the systemd-run probe cache (for testing).
62
+ * @internal
63
+ */
64
+ export declare function resetSystemdRunCache(): void;
65
+ /**
66
+ * Build the wrapped program + arg vector for a pi spawn, optionally under
67
+ * a per-agent `systemd-run --user --scope` transient unit.
68
+ *
69
+ * When `wrap` is false, returns `(piPath, originalArgs)` unchanged.
70
+ * When `wrap` is true, returns `("systemd-run", ["--user", "--scope", ...])`.
71
+ *
72
+ * Pure function — no I/O, fully unit-testable.
73
+ */
74
+ export declare function wrapWithCgroup(piPath: string, originalArgs: readonly string[], agentId: string, resources: ResourcesConfig, wrap: boolean): {
75
+ program: string;
76
+ args: string[];
77
+ };
78
+ export interface PiProcessOptions {
79
+ readonly agentId: string;
80
+ readonly piPath: string;
81
+ readonly machineId: string;
82
+ readonly resources: ResourcesConfig;
83
+ /** Stable pi session ID for GAP-017/P5 session continuity. */
84
+ readonly resumeSessionId?: string;
85
+ /** Path override for spawning (default: child_process.spawn). */
86
+ readonly spawnFn?: typeof child_process.spawn;
87
+ }
88
+ export declare class PiProcess {
89
+ private readonly agentId;
90
+ private readonly machineId;
91
+ private readonly emitter;
92
+ private child;
93
+ private alive;
94
+ private streaming;
95
+ private readonly sessionId;
96
+ private parseErrors;
97
+ private currentMsgId;
98
+ constructor(opts: PiProcessOptions);
99
+ /**
100
+ * Spawn pi process. Mirrors Rust `PiProcess::spawn`.
101
+ * Returns a promise that resolves when the process is spawned.
102
+ */
103
+ spawn(opts: PiProcessOptions): Promise<void>;
104
+ private handleStdoutLine;
105
+ private extractEventMsgId;
106
+ /** Send a command to pi stdin. */
107
+ private writeCommand;
108
+ /** Send a prompt command. Mirrors Rust PiProcess::send_prompt. */
109
+ sendPrompt(msgId: string, message: string, streamingBehavior?: string): void;
110
+ /** Send an abort command. Mirrors Rust PiProcess::send_abort. */
111
+ sendAbort(): void;
112
+ /** Forward extension_ui_response to pi. */
113
+ sendExtensionUIResponse(requestId: string, response: Record<string, unknown>): void;
114
+ /** Switch LLM model. Mirrors Rust PiProcess::set_model. */
115
+ setModel(provider: string, modelId: string): void;
116
+ /** Set thinking level. Mirrors Rust PiProcess::set_thinking_level. */
117
+ setThinkingLevel(level: string): void;
118
+ /** Manual context compaction. Mirrors Rust PiProcess::compact. */
119
+ compact(): void;
120
+ /** Get available models. Mirrors Rust PiProcess::get_available_models. */
121
+ getAvailableModels(id: string): void;
122
+ /** Is the agent currently streaming (between agent_start and agent_end)? */
123
+ isStreaming(): boolean;
124
+ /** Is the pi process still alive (stdout not EOF)? GAP-003 health check. */
125
+ isAlive(): boolean;
126
+ /** Number of unparseable stdout lines (Q4 GAP-020 health counter). */
127
+ getParseErrorCount(): number;
128
+ /** Captured pi session ID (GAP-017/P5). */
129
+ getPersistedSessionId(): string | undefined;
130
+ /** Register a handler for StreamMessage events. */
131
+ onEvent(handler: (msg: StreamMessage) => void): void;
132
+ /** Register a handler for exit events. */
133
+ onExit(handler: (agentId: string) => void): void;
134
+ /** Register a handler for stderr log lines. */
135
+ onStderr(handler: (line: string) => void): void;
136
+ /**
137
+ * Graceful shutdown: send abort, close stdin, kill child.
138
+ * Mirrors Rust PiProcess::terminate.
139
+ */
140
+ terminate(): void;
141
+ private buildEnvContext;
142
+ }
143
+ export declare function promptCommand(id: string, message: string): RpcCommand;
144
+ export declare function promptWithStreamingCommand(id: string, message: string, behavior: string): RpcCommand;
145
+ export declare function abortCommand(): RpcCommand;
146
+ export declare function setModelCommand(provider: string, modelId: string): RpcCommand;
147
+ export declare function setThinkingLevelCommand(level: string): RpcCommand;
148
+ export declare function compactCommand(): RpcCommand;
149
+ export declare function getAvailableModelsCommand(id: string): RpcCommand;
150
+ /**
151
+ * Generate a pi-compatible session ID (RFC 4122 v4 UUID).
152
+ * Mirrors Rust `new_session_id()`.
153
+ */
154
+ export declare function newPiSessionId(): string;
155
+ /**
156
+ * Extract pi sessionId from event payload.
157
+ * Mirrors Rust `extract_session_id`.
158
+ */
159
+ export declare function extractSessionId(eventType: string, payload: Record<string, unknown>): string | undefined;
160
+ /**
161
+ * Resolve msg_id for a forwarded event. Mirrors Rust `event_msg_id`.
162
+ * Prefers pi's own id in payload, falls back to currentMsgId.
163
+ */
164
+ export declare function eventMsgId(payload: Record<string, unknown>, fallback: string): string;