@love-moon/conductor-sdk 0.2.16 → 0.2.18

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/dist/index.d.ts CHANGED
@@ -6,3 +6,4 @@ export * from './message/index.js';
6
6
  export * from './client.js';
7
7
  export * from './context/index.js';
8
8
  export * from './limits/index.js';
9
+ export * from './outbox/index.js';
package/dist/index.js CHANGED
@@ -6,3 +6,4 @@ export * from './message/index.js';
6
6
  export * from './client.js';
7
7
  export * from './context/index.js';
8
8
  export * from './limits/index.js';
9
+ export * from './outbox/index.js';
@@ -16,6 +16,8 @@ export declare class MessageRouter {
16
16
  private resolveMessageId;
17
17
  private coerceRole;
18
18
  private coerceContent;
19
+ private coerceMetadata;
20
+ private coerceAttachments;
19
21
  private formatActionContent;
20
22
  private ensureSession;
21
23
  }
@@ -26,6 +26,8 @@ export class MessageRouter {
26
26
  role: this.coerceRole(data.role, 'user'),
27
27
  content: this.coerceContent(data.content),
28
28
  ackToken: data.ack_token ? String(data.ack_token) : null,
29
+ metadata: this.coerceMetadata(data.metadata),
30
+ attachments: this.coerceAttachments(data.attachments),
29
31
  });
30
32
  await this.notify(taskId);
31
33
  }
@@ -36,6 +38,8 @@ export class MessageRouter {
36
38
  role: this.coerceRole(data.role, 'action'),
37
39
  content: this.formatActionContent(data),
38
40
  ackToken: data.ack_token ? String(data.ack_token) : null,
41
+ metadata: this.coerceMetadata(data.metadata),
42
+ attachments: this.coerceAttachments(data.attachments),
39
43
  });
40
44
  await this.notify(taskId);
41
45
  }
@@ -92,6 +96,20 @@ export class MessageRouter {
92
96
  return String(content);
93
97
  }
94
98
  }
99
+ coerceMetadata(metadata) {
100
+ if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
101
+ return null;
102
+ }
103
+ return { ...metadata };
104
+ }
105
+ coerceAttachments(attachments) {
106
+ if (!Array.isArray(attachments)) {
107
+ return [];
108
+ }
109
+ return attachments
110
+ .filter((entry) => Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry))
111
+ .map((entry) => ({ ...entry }));
112
+ }
95
113
  formatActionContent(data) {
96
114
  if (typeof data.content === 'string' && data.content.trim()) {
97
115
  return data.content;
@@ -0,0 +1,16 @@
1
+ export interface DownstreamCommandCursor {
2
+ createdAt: string;
3
+ requestId: string;
4
+ }
5
+ export declare function normalizeDownstreamCommandCursor(value: unknown): DownstreamCommandCursor | null;
6
+ export declare class DownstreamCursorStore {
7
+ private readonly filePath;
8
+ constructor(filePath: string);
9
+ static filePathForProjectPath(projectPath: string, scopeId: string): string;
10
+ static forProjectPath(projectPath: string, scopeId: string): DownstreamCursorStore;
11
+ get(agentHost: string): DownstreamCommandCursor | null;
12
+ hasApplied(agentHost: string, cursor: DownstreamCommandCursor): boolean;
13
+ advance(agentHost: string, cursor: DownstreamCommandCursor): DownstreamCommandCursor;
14
+ private loadState;
15
+ private saveState;
16
+ }
@@ -0,0 +1,96 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const STATE_DIR = path.join('.conductor', 'state');
4
+ const CURSOR_BASENAME = 'agent-downstream-cursor';
5
+ function normalizeCursorValue(value) {
6
+ if (typeof value !== 'string') {
7
+ return null;
8
+ }
9
+ const normalized = value.trim();
10
+ return normalized || null;
11
+ }
12
+ function compareCursor(left, right) {
13
+ const leftTime = Date.parse(left.createdAt);
14
+ const rightTime = Date.parse(right.createdAt);
15
+ const normalizedLeftTime = Number.isFinite(leftTime) ? leftTime : 0;
16
+ const normalizedRightTime = Number.isFinite(rightTime) ? rightTime : 0;
17
+ if (normalizedLeftTime !== normalizedRightTime) {
18
+ return normalizedLeftTime < normalizedRightTime ? -1 : 1;
19
+ }
20
+ return left.requestId.localeCompare(right.requestId);
21
+ }
22
+ const sanitizeScopeId = (scopeId) => scopeId
23
+ .trim()
24
+ .replace(/[^a-zA-Z0-9._-]+/g, '_')
25
+ .replace(/^_+|_+$/g, '')
26
+ || 'default';
27
+ export function normalizeDownstreamCommandCursor(value) {
28
+ if (!value || typeof value !== 'object') {
29
+ return null;
30
+ }
31
+ const source = value;
32
+ const createdAt = normalizeCursorValue(source.created_at) ?? normalizeCursorValue(source.createdAt);
33
+ const requestId = normalizeCursorValue(source.request_id) ?? normalizeCursorValue(source.requestId);
34
+ if (!createdAt || !requestId) {
35
+ return null;
36
+ }
37
+ return {
38
+ createdAt,
39
+ requestId,
40
+ };
41
+ }
42
+ export class DownstreamCursorStore {
43
+ filePath;
44
+ constructor(filePath) {
45
+ this.filePath = path.resolve(filePath);
46
+ }
47
+ static filePathForProjectPath(projectPath, scopeId) {
48
+ return path.join(projectPath, STATE_DIR, `${CURSOR_BASENAME}.${sanitizeScopeId(scopeId)}.json`);
49
+ }
50
+ static forProjectPath(projectPath, scopeId) {
51
+ return new DownstreamCursorStore(DownstreamCursorStore.filePathForProjectPath(projectPath, scopeId));
52
+ }
53
+ get(agentHost) {
54
+ const state = this.loadState();
55
+ return state.agents[agentHost] ?? null;
56
+ }
57
+ hasApplied(agentHost, cursor) {
58
+ const current = this.get(agentHost);
59
+ if (!current) {
60
+ return false;
61
+ }
62
+ return compareCursor(current, cursor) >= 0;
63
+ }
64
+ advance(agentHost, cursor) {
65
+ const state = this.loadState();
66
+ const current = state.agents[agentHost] ?? null;
67
+ if (current && compareCursor(current, cursor) >= 0) {
68
+ return current;
69
+ }
70
+ state.agents[agentHost] = cursor;
71
+ this.saveState(state);
72
+ return cursor;
73
+ }
74
+ loadState() {
75
+ if (!fs.existsSync(this.filePath)) {
76
+ return { agents: {} };
77
+ }
78
+ try {
79
+ const contents = fs.readFileSync(this.filePath, 'utf-8');
80
+ const parsed = JSON.parse(contents);
81
+ if (!parsed || typeof parsed !== 'object' || !parsed.agents || typeof parsed.agents !== 'object') {
82
+ return { agents: {} };
83
+ }
84
+ return { agents: parsed.agents };
85
+ }
86
+ catch {
87
+ return { agents: {} };
88
+ }
89
+ }
90
+ saveState(state) {
91
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
92
+ const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
93
+ fs.writeFileSync(tempPath, JSON.stringify(state, null, 2), 'utf-8');
94
+ fs.renameSync(tempPath, this.filePath);
95
+ }
96
+ }
@@ -0,0 +1,2 @@
1
+ export * from './store.js';
2
+ export * from './downstream-cursor-store.js';
@@ -0,0 +1,2 @@
1
+ export * from './store.js';
2
+ export * from './downstream-cursor-store.js';
@@ -0,0 +1,31 @@
1
+ export type DurableUpstreamEventType = 'sdk_message' | 'task_status_update' | 'agent_command_ack' | 'task_stop_ack';
2
+ export interface DurableUpstreamEvent {
3
+ stableId: string;
4
+ eventType: DurableUpstreamEventType;
5
+ payload: Record<string, unknown>;
6
+ createdAt: string;
7
+ attemptCount: number;
8
+ lastAttemptAt?: string | null;
9
+ nextAttemptAt?: string | null;
10
+ }
11
+ export declare class DurableUpstreamOutboxStore {
12
+ private readonly filePath;
13
+ private readonly lockPath;
14
+ constructor(filePath: string);
15
+ static filePathForProjectPath(projectPath: string, scopeId: string): string;
16
+ static forProjectPath(projectPath: string, scopeId: string): DurableUpstreamOutboxStore;
17
+ load(): DurableUpstreamEvent[];
18
+ upsert(entry: {
19
+ stableId: string;
20
+ eventType: DurableUpstreamEventType;
21
+ payload: Record<string, unknown>;
22
+ }): DurableUpstreamEvent;
23
+ remove(stableId: string): void;
24
+ markRetry(stableId: string, delayMs: number): DurableUpstreamEvent | null;
25
+ listReady(nowMs?: number): DurableUpstreamEvent[];
26
+ nextRetryDelay(nowMs?: number): number | null;
27
+ private loadUnlocked;
28
+ private saveUnlocked;
29
+ private withLock;
30
+ private acquireLock;
31
+ }
@@ -0,0 +1,174 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const OUTBOX_DIR = path.join('.conductor', 'state');
4
+ const OUTBOX_BASENAME = 'agent-upstream-outbox';
5
+ const LOCK_TIMEOUT_MS = 10_000;
6
+ const LOCK_RETRY_MS = 50;
7
+ const sleepSync = (ms) => {
8
+ if (ms <= 0)
9
+ return;
10
+ try {
11
+ const buffer = new SharedArrayBuffer(4);
12
+ const arr = new Int32Array(buffer);
13
+ Atomics.wait(arr, 0, 0, ms);
14
+ }
15
+ catch {
16
+ const startedAt = Date.now();
17
+ while (Date.now() - startedAt < ms) {
18
+ // busy wait fallback
19
+ }
20
+ }
21
+ };
22
+ const sanitizeScopeId = (scopeId) => scopeId
23
+ .trim()
24
+ .replace(/[^a-zA-Z0-9._-]+/g, '_')
25
+ .replace(/^_+|_+$/g, '')
26
+ || 'default';
27
+ export class DurableUpstreamOutboxStore {
28
+ filePath;
29
+ lockPath;
30
+ constructor(filePath) {
31
+ this.filePath = path.resolve(filePath);
32
+ this.lockPath = `${this.filePath}.lock`;
33
+ }
34
+ static filePathForProjectPath(projectPath, scopeId) {
35
+ return path.join(projectPath, OUTBOX_DIR, `${OUTBOX_BASENAME}.${sanitizeScopeId(scopeId)}.json`);
36
+ }
37
+ static forProjectPath(projectPath, scopeId) {
38
+ return new DurableUpstreamOutboxStore(DurableUpstreamOutboxStore.filePathForProjectPath(projectPath, scopeId));
39
+ }
40
+ load() {
41
+ return this.withLock(() => this.loadUnlocked());
42
+ }
43
+ upsert(entry) {
44
+ return this.withLock(() => {
45
+ const entries = this.loadUnlocked();
46
+ const existing = entries.find((candidate) => candidate.stableId === entry.stableId);
47
+ const record = {
48
+ stableId: entry.stableId,
49
+ eventType: entry.eventType,
50
+ payload: entry.payload,
51
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
52
+ attemptCount: existing?.attemptCount ?? 0,
53
+ lastAttemptAt: existing?.lastAttemptAt ?? null,
54
+ nextAttemptAt: existing?.nextAttemptAt ?? new Date().toISOString(),
55
+ };
56
+ const nextEntries = existing
57
+ ? entries.map((candidate) => (candidate.stableId === entry.stableId ? record : candidate))
58
+ : [...entries, record];
59
+ this.saveUnlocked(nextEntries);
60
+ return record;
61
+ });
62
+ }
63
+ remove(stableId) {
64
+ this.withLock(() => {
65
+ const entries = this.loadUnlocked().filter((candidate) => candidate.stableId !== stableId);
66
+ this.saveUnlocked(entries);
67
+ });
68
+ }
69
+ markRetry(stableId, delayMs) {
70
+ return this.withLock(() => {
71
+ const entries = this.loadUnlocked();
72
+ const index = entries.findIndex((candidate) => candidate.stableId === stableId);
73
+ if (index < 0) {
74
+ return null;
75
+ }
76
+ const now = Date.now();
77
+ const updated = {
78
+ ...entries[index],
79
+ attemptCount: entries[index].attemptCount + 1,
80
+ lastAttemptAt: new Date(now).toISOString(),
81
+ nextAttemptAt: new Date(now + Math.max(delayMs, 0)).toISOString(),
82
+ };
83
+ entries[index] = updated;
84
+ this.saveUnlocked(entries);
85
+ return updated;
86
+ });
87
+ }
88
+ listReady(nowMs = Date.now()) {
89
+ return this.load()
90
+ .filter((entry) => {
91
+ if (!entry.nextAttemptAt)
92
+ return true;
93
+ const nextAttemptMs = Date.parse(entry.nextAttemptAt);
94
+ return !Number.isFinite(nextAttemptMs) || nextAttemptMs <= nowMs;
95
+ });
96
+ }
97
+ nextRetryDelay(nowMs = Date.now()) {
98
+ const entries = this.load();
99
+ let minDelay = null;
100
+ for (const entry of entries) {
101
+ if (!entry.nextAttemptAt) {
102
+ return 0;
103
+ }
104
+ const nextAttemptMs = Date.parse(entry.nextAttemptAt);
105
+ if (!Number.isFinite(nextAttemptMs)) {
106
+ return 0;
107
+ }
108
+ const delay = Math.max(nextAttemptMs - nowMs, 0);
109
+ minDelay = minDelay === null ? delay : Math.min(minDelay, delay);
110
+ }
111
+ return minDelay;
112
+ }
113
+ loadUnlocked() {
114
+ if (!fs.existsSync(this.filePath)) {
115
+ return [];
116
+ }
117
+ try {
118
+ const contents = fs.readFileSync(this.filePath, 'utf-8');
119
+ const parsed = JSON.parse(contents);
120
+ return Array.isArray(parsed.entries) ? parsed.entries : [];
121
+ }
122
+ catch {
123
+ return [];
124
+ }
125
+ }
126
+ saveUnlocked(entries) {
127
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
128
+ const payload = { entries };
129
+ const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
130
+ fs.writeFileSync(tempPath, JSON.stringify(payload, null, 2), 'utf-8');
131
+ fs.renameSync(tempPath, this.filePath);
132
+ }
133
+ withLock(fn) {
134
+ const release = this.acquireLock();
135
+ try {
136
+ return fn();
137
+ }
138
+ finally {
139
+ release();
140
+ }
141
+ }
142
+ acquireLock() {
143
+ const startedAt = Date.now();
144
+ fs.mkdirSync(path.dirname(this.lockPath), { recursive: true });
145
+ while (true) {
146
+ try {
147
+ const fd = fs.openSync(this.lockPath, 'wx');
148
+ return () => {
149
+ try {
150
+ fs.closeSync(fd);
151
+ }
152
+ catch {
153
+ // ignore
154
+ }
155
+ try {
156
+ fs.unlinkSync(this.lockPath);
157
+ }
158
+ catch {
159
+ // ignore
160
+ }
161
+ };
162
+ }
163
+ catch (error) {
164
+ if (error?.code !== 'EEXIST') {
165
+ throw error;
166
+ }
167
+ if (Date.now() - startedAt > LOCK_TIMEOUT_MS) {
168
+ throw new Error(`Timed out waiting for outbox lock: ${this.lockPath}`);
169
+ }
170
+ sleepSync(LOCK_RETRY_MS);
171
+ }
172
+ }
173
+ }
174
+ }
@@ -1,8 +1,11 @@
1
+ type JsonRecord = Record<string, unknown>;
1
2
  export interface MessageInput {
2
3
  messageId: string;
3
4
  role: string;
4
5
  content: string;
5
6
  ackToken?: string | null;
7
+ metadata?: JsonRecord | null;
8
+ attachments?: JsonRecord[];
6
9
  }
7
10
  export declare class MessageRecord {
8
11
  readonly messageId: string;
@@ -10,6 +13,8 @@ export declare class MessageRecord {
10
13
  readonly content: string;
11
14
  readonly createdAt: Date;
12
15
  readonly ackToken?: string | null;
16
+ readonly metadata?: JsonRecord | null;
17
+ readonly attachments: JsonRecord[];
13
18
  constructor(init: MessageInput);
14
19
  }
15
20
  export declare class SessionState {
@@ -37,3 +42,4 @@ export declare class SessionManager {
37
42
  endSession(taskId: string): Promise<void>;
38
43
  private ensureEvent;
39
44
  }
45
+ export {};
@@ -1,15 +1,33 @@
1
1
  import { EventEmitter } from 'node:events';
2
+ function normalizeJsonRecord(value) {
3
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
4
+ return null;
5
+ }
6
+ return { ...value };
7
+ }
8
+ function normalizeJsonRecordArray(value) {
9
+ if (!Array.isArray(value)) {
10
+ return [];
11
+ }
12
+ return value
13
+ .filter((entry) => Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry))
14
+ .map((entry) => ({ ...entry }));
15
+ }
2
16
  export class MessageRecord {
3
17
  messageId;
4
18
  role;
5
19
  content;
6
20
  createdAt;
7
21
  ackToken;
22
+ metadata;
23
+ attachments;
8
24
  constructor(init) {
9
25
  this.messageId = init.messageId;
10
26
  this.role = init.role;
11
27
  this.content = init.content;
12
28
  this.ackToken = init.ackToken;
29
+ this.metadata = normalizeJsonRecord(init.metadata);
30
+ this.attachments = normalizeJsonRecordArray(init.attachments);
13
31
  this.createdAt = new Date();
14
32
  }
15
33
  }
@@ -1,9 +1,17 @@
1
1
  import { ConductorConfig } from '../config/index.js';
2
2
  export type WebSocketHandler = (payload: Record<string, any>) => Promise<void> | void;
3
+ export interface WebSocketCloseInfo {
4
+ code?: number | null;
5
+ reason?: string | null;
6
+ }
3
7
  export interface WebSocketLike {
4
8
  send(data: string): Promise<void> | void;
5
9
  ping(): Promise<void> | void;
6
10
  close(): Promise<void> | void;
11
+ terminate?(): Promise<void> | void;
12
+ onPong?(handler: () => void): void;
13
+ onCloseInfo?(handler: (info: WebSocketCloseInfo) => void): void;
14
+ onErrorInfo?(handler: (error: unknown) => void): void;
7
15
  closed?: boolean;
8
16
  [Symbol.asyncIterator](): AsyncIterableIterator<string>;
9
17
  }
@@ -11,16 +19,35 @@ export interface ConnectOptions {
11
19
  headers: Record<string, string>;
12
20
  }
13
21
  export type ConnectImpl = (url: string, options: ConnectOptions) => Promise<WebSocketLike>;
22
+ export interface WebSocketConnectedEvent {
23
+ isReconnect: boolean;
24
+ connectedAt: number;
25
+ }
26
+ export interface WebSocketPongEvent {
27
+ at: number;
28
+ latencyMs: number | null;
29
+ }
30
+ export interface WebSocketDisconnectEvent {
31
+ reason: string;
32
+ disconnectedAt: number;
33
+ connectedAt: number | null;
34
+ closeCode: number | null;
35
+ closeReason: string | null;
36
+ socketError: string | null;
37
+ missedPongs: number;
38
+ lastPingAt: number | null;
39
+ lastPongAt: number | null;
40
+ lastMessageAt: number | null;
41
+ }
14
42
  export interface WebSocketClientOptions {
15
43
  reconnectDelay?: number;
16
44
  heartbeatInterval?: number;
17
45
  extraHeaders?: Record<string, string>;
18
46
  connectImpl?: ConnectImpl;
19
47
  hostName?: string;
20
- onConnected?: (event: {
21
- isReconnect: boolean;
22
- }) => void;
23
- onDisconnected?: () => void;
48
+ onConnected?: (event: WebSocketConnectedEvent) => void;
49
+ onDisconnected?: (event: WebSocketDisconnectEvent) => void;
50
+ onPong?: (event: WebSocketPongEvent) => void;
24
51
  onReconnected?: () => void;
25
52
  }
26
53
  export declare class ConductorWebSocketClient {
@@ -31,10 +58,12 @@ export declare class ConductorWebSocketClient {
31
58
  private readonly connectImpl;
32
59
  private readonly onConnected?;
33
60
  private readonly onDisconnected?;
61
+ private readonly onPong?;
34
62
  private readonly onReconnected?;
35
63
  private readonly handlers;
36
64
  private readonly extraHeaders;
37
65
  private conn;
66
+ private runtime;
38
67
  private stop;
39
68
  private listenTask;
40
69
  private heartbeatTask;
@@ -45,6 +74,7 @@ export declare class ConductorWebSocketClient {
45
74
  registerHandler(handler: WebSocketHandler): void;
46
75
  connect(): Promise<void>;
47
76
  disconnect(): Promise<void>;
77
+ forceReconnect(reason?: string): Promise<void>;
48
78
  sendJson(payload: Record<string, any>): Promise<void>;
49
79
  private ensureConnection;
50
80
  private openConnection;
@@ -59,4 +89,11 @@ export declare class ConductorWebSocketClient {
59
89
  private isConnectionClosed;
60
90
  private sendWithReconnect;
61
91
  private isNotOpenError;
92
+ private createRuntime;
93
+ private attachConnectionObservers;
94
+ private getRuntime;
95
+ private markDisconnectReason;
96
+ private buildDisconnectEvent;
97
+ private closeConnection;
98
+ private terminateConnection;
62
99
  }