@love-moon/app-sdk 0.3.2

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,264 @@
1
+ /* Conductor App SDK — chat widget styles.
2
+ *
3
+ * v0.1: hand-written CSS, functional but visually minimal. The eventual
4
+ * extraction from web/src/features/chat will swap this for the pre-compiled
5
+ * Tailwind output. Until then, hosts who want polished visuals are free to
6
+ * override any class — every component renders semantic conductor-* classes.
7
+ *
8
+ * Theming: all colors / radii / spacing pull from CSS variables on the
9
+ * `.conductor-chat-view` root, which `<ChatView theme={...} />` overrides
10
+ * per instance.
11
+ */
12
+
13
+ .conductor-chat-view {
14
+ --accent: #e4572e;
15
+ --conductor-paper: #f5f1ea;
16
+ --conductor-text: #1a1a1a;
17
+ --conductor-text-muted: rgba(0, 0, 0, 0.55);
18
+ --conductor-bubble-user: #f1ece2;
19
+ --conductor-bubble-assistant: #ffffff;
20
+ --conductor-border: rgba(0, 0, 0, 0.08);
21
+ --conductor-radius: 12px;
22
+ --conductor-spacing: 12px;
23
+ --conductor-font:
24
+ -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
25
+ "Helvetica Neue", Arial, sans-serif;
26
+
27
+ display: grid;
28
+ grid-template-rows: auto 1fr auto;
29
+ height: 100%;
30
+ background: var(--conductor-paper);
31
+ color: var(--conductor-text);
32
+ font-family: var(--conductor-font);
33
+ font-size: 14px;
34
+ line-height: 1.5;
35
+ box-sizing: border-box;
36
+ }
37
+
38
+ .conductor-chat-view *,
39
+ .conductor-chat-view *::before,
40
+ .conductor-chat-view *::after {
41
+ box-sizing: border-box;
42
+ }
43
+
44
+ /* ----- Runtime status bar ------------------------------------------------- */
45
+
46
+ .conductor-runtime-status {
47
+ display: flex;
48
+ align-items: center;
49
+ gap: 8px;
50
+ padding: 8px var(--conductor-spacing);
51
+ font-size: 12px;
52
+ color: var(--conductor-text-muted);
53
+ border-bottom: 1px solid var(--conductor-border);
54
+ min-height: 32px;
55
+ }
56
+
57
+ .conductor-runtime-indicator {
58
+ width: 8px;
59
+ height: 8px;
60
+ border-radius: 50%;
61
+ background: rgba(0, 0, 0, 0.2);
62
+ flex-shrink: 0;
63
+ }
64
+
65
+ .conductor-runtime-indicator--active {
66
+ background: var(--accent);
67
+ animation: conductor-pulse 1.2s ease-in-out infinite;
68
+ }
69
+
70
+ @keyframes conductor-pulse {
71
+ 0%,
72
+ 100% {
73
+ transform: scale(1);
74
+ opacity: 1;
75
+ }
76
+ 50% {
77
+ transform: scale(1.4);
78
+ opacity: 0.6;
79
+ }
80
+ }
81
+
82
+ .conductor-runtime-text {
83
+ flex: 1;
84
+ white-space: nowrap;
85
+ overflow: hidden;
86
+ text-overflow: ellipsis;
87
+ }
88
+
89
+ .conductor-runtime-error {
90
+ color: #b04020;
91
+ font-weight: 500;
92
+ }
93
+
94
+ .conductor-runtime-connection {
95
+ margin-left: auto;
96
+ font-size: 11px;
97
+ color: var(--conductor-text-muted);
98
+ }
99
+
100
+ /* ----- Message list ------------------------------------------------------- */
101
+
102
+ .conductor-message-list {
103
+ overflow-y: auto;
104
+ padding: var(--conductor-spacing);
105
+ display: flex;
106
+ flex-direction: column;
107
+ gap: 8px;
108
+ min-height: 0; /* lets it shrink inside the grid row */
109
+ }
110
+
111
+ .conductor-load-earlier {
112
+ display: flex;
113
+ justify-content: center;
114
+ margin-bottom: 4px;
115
+ }
116
+
117
+ .conductor-load-earlier button {
118
+ background: transparent;
119
+ border: 1px solid var(--conductor-border);
120
+ border-radius: 999px;
121
+ padding: 4px 12px;
122
+ font-size: 12px;
123
+ color: var(--conductor-text-muted);
124
+ cursor: pointer;
125
+ }
126
+
127
+ .conductor-load-earlier button:disabled {
128
+ opacity: 0.5;
129
+ cursor: progress;
130
+ }
131
+
132
+ .conductor-message {
133
+ display: flex;
134
+ }
135
+
136
+ .conductor-message--user {
137
+ justify-content: flex-end;
138
+ }
139
+
140
+ .conductor-message--assistant {
141
+ justify-content: flex-start;
142
+ }
143
+
144
+ .conductor-bubble {
145
+ max-width: 75%;
146
+ padding: 10px 14px;
147
+ border-radius: var(--conductor-radius);
148
+ white-space: pre-wrap;
149
+ word-break: break-word;
150
+ border: 1px solid var(--conductor-border);
151
+ }
152
+
153
+ .conductor-message--user .conductor-bubble {
154
+ background: var(--conductor-bubble-user);
155
+ border-bottom-right-radius: 4px;
156
+ }
157
+
158
+ .conductor-message--assistant .conductor-bubble {
159
+ background: var(--conductor-bubble-assistant);
160
+ border-bottom-left-radius: 4px;
161
+ }
162
+
163
+ .conductor-message--pending .conductor-bubble {
164
+ opacity: 0.6;
165
+ }
166
+
167
+ /* ----- Message input ------------------------------------------------------ */
168
+
169
+ .conductor-message-input {
170
+ display: flex;
171
+ align-items: flex-end;
172
+ gap: 8px;
173
+ padding: var(--conductor-spacing);
174
+ border-top: 1px solid var(--conductor-border);
175
+ background: var(--conductor-paper);
176
+ }
177
+
178
+ .conductor-message-input__textarea {
179
+ flex: 1;
180
+ min-height: 36px;
181
+ max-height: 136px;
182
+ resize: none;
183
+ padding: 8px 12px;
184
+ border: 1px solid var(--conductor-border);
185
+ border-radius: var(--conductor-radius);
186
+ background: #fff;
187
+ color: var(--conductor-text);
188
+ font-family: inherit;
189
+ font-size: 14px;
190
+ line-height: 1.5;
191
+ outline: none;
192
+ overflow-y: auto;
193
+ }
194
+
195
+ .conductor-message-input__textarea:focus {
196
+ border-color: var(--accent);
197
+ box-shadow: 0 0 0 3px rgba(228, 87, 46, 0.15);
198
+ }
199
+
200
+ .conductor-message-input__actions {
201
+ display: flex;
202
+ align-items: center;
203
+ gap: 6px;
204
+ }
205
+
206
+ .conductor-button {
207
+ padding: 8px 14px;
208
+ border-radius: var(--conductor-radius);
209
+ border: 1px solid var(--conductor-border);
210
+ background: #fff;
211
+ color: var(--conductor-text);
212
+ font: inherit;
213
+ cursor: pointer;
214
+ transition: background-color 120ms ease;
215
+ }
216
+
217
+ .conductor-button:hover {
218
+ background: rgba(0, 0, 0, 0.04);
219
+ }
220
+
221
+ .conductor-button:disabled {
222
+ opacity: 0.4;
223
+ cursor: not-allowed;
224
+ }
225
+
226
+ .conductor-button--send {
227
+ background: var(--accent);
228
+ color: #fff;
229
+ border-color: transparent;
230
+ }
231
+
232
+ .conductor-button--send:hover {
233
+ background: var(--accent);
234
+ filter: brightness(0.92);
235
+ }
236
+
237
+ .conductor-button--interrupt {
238
+ background: #b04020;
239
+ color: #fff;
240
+ border-color: transparent;
241
+ }
242
+
243
+ .conductor-button--interrupt:hover {
244
+ filter: brightness(0.92);
245
+ }
246
+
247
+ /* ----- Mobile layout ------------------------------------------------------ */
248
+
249
+ @media (max-width: 600px) {
250
+ .conductor-chat-view {
251
+ font-size: 15px;
252
+ }
253
+ .conductor-bubble {
254
+ max-width: 85%;
255
+ }
256
+ }
257
+
258
+ .conductor-chat-view[data-layout='mobile'] .conductor-bubble {
259
+ max-width: 85%;
260
+ }
261
+
262
+ .conductor-chat-view[data-layout='desktop'] .conductor-bubble {
263
+ max-width: 60%;
264
+ }
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Task: a single AI conversation within a project.
3
+ *
4
+ * Mirrors the shape returned by Conductor's `/api/tasks/[taskId]` REST endpoint
5
+ * (camelCase normalized). All fields are present after a successful read; the
6
+ * `null`-able fields reflect cases where the backend hasn't populated them yet
7
+ * (e.g. a task created via SDK before its daemon picks it up).
8
+ */
9
+ interface Task {
10
+ id: string;
11
+ projectId: string;
12
+ title: string;
13
+ status: TaskStatus;
14
+ /** Backend execution engine ("claude_code" / "codex" / etc.) or null when not selected yet. */
15
+ backendType: string | null;
16
+ /** AI session id assigned by the daemon; null until the daemon attaches. */
17
+ sessionId: string | null;
18
+ sessionFilePath: string | null;
19
+ /** ISO 8601 timestamps. Always present after the task is persisted. */
20
+ createdAt: string;
21
+ updatedAt: string;
22
+ }
23
+ type TaskStatus = 'pending' | 'running' | 'finished' | 'failed' | 'cancelled' | string;
24
+ interface CreateTaskInput {
25
+ projectId: string;
26
+ title: string;
27
+ /**
28
+ * Optional first user message. When provided, the backend persists it as
29
+ * the task's initial message right after creation.
30
+ * Maps to the backend's `initialContent` field; the SDK renames it for
31
+ * symmetry with `sendMessage(taskId, content)`.
32
+ */
33
+ initialMessage?: string;
34
+ /** Backend type override (claude_code / codex / kimi-cli / etc.). */
35
+ backendType?: string;
36
+ /** Free-form metadata; merged with SDK audit fields server-side. */
37
+ metadata?: Record<string, unknown>;
38
+ }
39
+
40
+ /**
41
+ * Message: one entry in a task's chat transcript.
42
+ *
43
+ * Roles:
44
+ * - 'user' — message sent by the user (or the SDK on their behalf).
45
+ * - 'sdk' — message sent by an SDK / CLI / app integration (still
46
+ * appears in the chat as a user-side bubble).
47
+ * - 'assistant' — AI reply.
48
+ * - 'system' — system-emitted notice (rare; renders muted).
49
+ *
50
+ * `role` is intentionally open-vocabulary; new backend roles render as
51
+ * a generic bubble.
52
+ */
53
+ interface Message {
54
+ id: string;
55
+ taskId: string;
56
+ role: MessageRole | string;
57
+ content: string;
58
+ metadata: Record<string, unknown> | null;
59
+ attachments: Attachment[];
60
+ createdAt: string;
61
+ }
62
+ type MessageRole = 'user' | 'sdk' | 'assistant' | 'system';
63
+ interface Attachment {
64
+ id: string;
65
+ filename: string;
66
+ mimeType: string;
67
+ sizeBytes: number;
68
+ /** Resolvable URL or relative path the host must turn into a fetchable URL. */
69
+ url: string;
70
+ }
71
+ interface SendMessageInput {
72
+ content: string;
73
+ /** Idempotency key. When omitted, the SDK auto-generates a UUID. */
74
+ clientRequestId?: string;
75
+ metadata?: Record<string, unknown>;
76
+ /** Optional attachments to include with this message; must be pre-uploaded. */
77
+ attachmentIds?: string[];
78
+ /** Role override; defaults to 'sdk'. */
79
+ role?: MessageRole;
80
+ }
81
+
82
+ /**
83
+ * Runtime status pushed by the daemon while a task is in progress.
84
+ * Mirrors `task_runtime_status` envelope on /ws/app.
85
+ */
86
+ interface RuntimeStatus {
87
+ taskId: string;
88
+ /** High-level phase: 'idle' | 'thinking' | 'tool_call' | 'awaiting_user' | 'done'. */
89
+ state: RuntimeState;
90
+ phase?: string | null;
91
+ source?: string | null;
92
+ /** Short status line shown next to the AI avatar ("Reading file X..."). */
93
+ statusLine?: string | null;
94
+ /** Final status line shown when the reply finishes. */
95
+ statusDoneLine?: string | null;
96
+ replyPreview?: string | null;
97
+ replyTo?: string | null;
98
+ replyInProgress?: boolean;
99
+ backend?: string | null;
100
+ threadId?: string | null;
101
+ daemon?: string | null;
102
+ pid?: number | null;
103
+ sessionId?: string | null;
104
+ sessionFilePath?: string | null;
105
+ tokenUsagePercent?: number | null;
106
+ contextUsagePercent?: number | null;
107
+ createdAt?: string | null;
108
+ }
109
+ type RuntimeState = 'idle' | 'thinking' | 'tool_call' | 'awaiting_user' | 'done' | string;
110
+
111
+ /**
112
+ * ChatEvent: the minimal set of events the chat widget needs to render.
113
+ *
114
+ * This is intentionally a small union — the widget should not have to
115
+ * pattern-match dozens of envelope types. The default REST/WS adapter
116
+ * funnels raw `/ws/app` envelopes into ChatEvents; custom adapters can do the
117
+ * same translation against any wire format.
118
+ */
119
+ /**
120
+ * Error shape carried inside `task_failed` ChatEvents and `error`
121
+ * StreamReplyDeltas.
122
+ *
123
+ * `details` and `cause` are optional, free-form passthroughs of the original
124
+ * SDK error's structured fields — useful for surfacing request IDs / server
125
+ * payloads in host UIs without depending on the SDK error class directly.
126
+ */
127
+ interface ChatEventError {
128
+ code: string;
129
+ message: string;
130
+ details?: unknown;
131
+ cause?: unknown;
132
+ }
133
+ type ChatEvent = {
134
+ type: 'message_appended';
135
+ message: Message;
136
+ } | {
137
+ type: 'message_updated';
138
+ message: Message;
139
+ } | {
140
+ type: 'runtime_status';
141
+ status: RuntimeStatus;
142
+ } | {
143
+ type: 'task_finished';
144
+ taskId: string;
145
+ } | {
146
+ type: 'task_failed';
147
+ taskId: string;
148
+ error: ChatEventError;
149
+ } | {
150
+ type: 'connection_state';
151
+ state: 'connected' | 'reconnecting' | 'offline';
152
+ };
153
+ /**
154
+ * StreamReplyDelta: a streaming AI reply chunk yielded by
155
+ * `client.tasks.streamReply(id)`.
156
+ *
157
+ * v1 semantics: each `text` delta is a *cumulative* preview-style chunk
158
+ * (built from `task_runtime_status.reply_preview` rolling state). The final
159
+ * `done` delta carries the full reply Message. A future RFC may add real
160
+ * token-level streaming; the shape is forward-compatible.
161
+ */
162
+ type StreamReplyDelta = {
163
+ type: 'text';
164
+ text: string;
165
+ replyTo: string;
166
+ } | {
167
+ type: 'status';
168
+ status: RuntimeStatus;
169
+ } | {
170
+ type: 'done';
171
+ message: Message;
172
+ } | {
173
+ type: 'error';
174
+ error: ChatEventError;
175
+ };
176
+
177
+ /**
178
+ * Project: a workspace binding (daemon + filesystem path) inside which tasks
179
+ * are scoped. Maps to Conductor's Project model.
180
+ *
181
+ * `daemonHost` and `workspacePath` are the binding identity. App-SDK's
182
+ * `projects.bind()` is idempotent on this pair.
183
+ */
184
+ interface Project {
185
+ id: string;
186
+ name: string;
187
+ daemonHost: string | null;
188
+ workspacePath: string | null;
189
+ repoRoot: string | null;
190
+ worktreeBranch: string | null;
191
+ lastCommit: string | null;
192
+ lastCommitAt: string | null;
193
+ fileCount: number | null;
194
+ isDefault: boolean;
195
+ /**
196
+ * True when this project was created via the App SDK (audit hint, derived
197
+ * from `metadata.audit.createdByApp`). Read-only flag for UI affordances.
198
+ */
199
+ createdByApp: boolean;
200
+ createdAt: string;
201
+ updatedAt: string;
202
+ }
203
+ interface BindProjectInput {
204
+ name: string;
205
+ daemonHost: string;
206
+ workspacePath: string;
207
+ /**
208
+ * Optional override for the audit `createdByApp.name` field. Defaults to the
209
+ * `name` argument. Only used when the SDK has to create a new project (no
210
+ * existing binding found).
211
+ */
212
+ appLabel?: string;
213
+ }
214
+
215
+ declare class ProjectsApi {
216
+ private readonly fetcher;
217
+ /**
218
+ * Idempotent find-or-create on (daemonHost, workspacePath).
219
+ *
220
+ * Flow:
221
+ * 1. POST `/api/projects/match-path` with `{ daemonHost, path: workspacePath }`.
222
+ * If a project matches (or is a parent of) the path, return it.
223
+ * 2. Otherwise POST `/api/projects` with binding fields. The server
224
+ * validates the binding with the user's daemon. Success returns the
225
+ * new project; daemon-offline returns 409 → mapped to `daemon_offline`.
226
+ *
227
+ * The created project carries `metadata.audit.createdByApp` so it can be
228
+ * distinguished in the main Conductor UI later. No new schema is required.
229
+ */
230
+ bind(input: BindProjectInput, opts?: {
231
+ signal?: AbortSignal;
232
+ }): Promise<Project>;
233
+ list(opts?: {
234
+ signal?: AbortSignal;
235
+ }): Promise<Project[]>;
236
+ get(projectId: string, opts?: {
237
+ signal?: AbortSignal;
238
+ }): Promise<Project>;
239
+ }
240
+
241
+ interface ConnectOptions {
242
+ /** Conductor backend URL, e.g. `https://conductor.example.com`. */
243
+ baseUrl: string;
244
+ /**
245
+ * Bearer token issued from Conductor Settings → API Tokens.
246
+ * Accepts a string or an async provider (for token rotation / vault lookup).
247
+ */
248
+ bearerToken: string | (() => Promise<string>);
249
+ /** Custom fetch (SSR, testing). Defaults to `globalThis.fetch`. */
250
+ fetch?: typeof globalThis.fetch;
251
+ /** Per-request timeout in ms. Default 30_000. */
252
+ timeoutMs?: number;
253
+ /** Called once when any request returns 401. */
254
+ onUnauthorized?: () => void;
255
+ /**
256
+ * Lazy: do not open the /ws/app connection until the first subscribe/stream
257
+ * call. Default `true`. Set to `false` to eagerly connect at `connect()`.
258
+ */
259
+ lazyWebSocket?: boolean;
260
+ /**
261
+ * Inject a custom WebSocket constructor (used by tests).
262
+ * Type widened to `unknown` to avoid forcing consumers to depend on the
263
+ * specific `ws` types.
264
+ */
265
+ webSocketImpl?: unknown;
266
+ }
267
+ type AppClientOptions = ConnectOptions;
268
+ declare class AppClient {
269
+ readonly projects: ProjectsApi;
270
+ readonly tasks: TasksApi;
271
+ private readonly _fetcher;
272
+ private _socket;
273
+ private _closed;
274
+ private readonly options;
275
+ constructor(options: AppClientOptions);
276
+ /**
277
+ * Release the WS connection and mark the client closed. Idempotent —
278
+ * calling twice is a no-op rather than a NPE. After close, any new
279
+ * `tasks.subscribe()` / `tasks.streamReply()` / `tasks.*` REST call
280
+ * throws synchronously instead of returning a hanging iterator.
281
+ */
282
+ close(): Promise<void>;
283
+ private getOrCreateSocket;
284
+ }
285
+ /**
286
+ * `connect()`: thin async wrapper around the constructor. In v0.1 it's
287
+ * synchronous-ish (no remote probe); kept async for forward compatibility
288
+ * (v0.2 may probe `/api/auth/me` for early-failure semantics).
289
+ */
290
+ declare function connect(options: ConnectOptions): Promise<AppClient>;
291
+ declare class TasksApi {
292
+ private readonly rest;
293
+ private readonly getSocket;
294
+ private readonly isClosed;
295
+ private assertOpen;
296
+ create(input: CreateTaskInput, opts?: {
297
+ signal?: AbortSignal;
298
+ }): Promise<Task>;
299
+ get(taskId: string, opts?: {
300
+ signal?: AbortSignal;
301
+ }): Promise<Task>;
302
+ list(filter?: {
303
+ projectId?: string;
304
+ status?: string;
305
+ }, opts?: {
306
+ signal?: AbortSignal;
307
+ }): Promise<Task[]>;
308
+ sendMessage(taskId: string, input: string | SendMessageInput, opts?: {
309
+ signal?: AbortSignal;
310
+ }): Promise<Message>;
311
+ history(taskId: string, paging?: {
312
+ beforeId?: string;
313
+ limit?: number;
314
+ }, opts?: {
315
+ signal?: AbortSignal;
316
+ }): Promise<{
317
+ messages: Message[];
318
+ hasMoreBefore: boolean;
319
+ oldestMessageId: string | null;
320
+ }>;
321
+ interrupt(taskId: string, opts: {
322
+ targetReplyTo: string;
323
+ signal?: AbortSignal;
324
+ }): Promise<void>;
325
+ /**
326
+ * Subscribe to a task's event stream. Yields ChatEvents until the caller
327
+ * breaks out of the `for await` loop, calls `signal.abort()`, or the
328
+ * client is closed.
329
+ *
330
+ * The first call lazily opens a /ws/app connection; subsequent calls share
331
+ * the same connection.
332
+ */
333
+ subscribe(taskId: string, opts?: {
334
+ signal?: AbortSignal;
335
+ bufferCap?: number;
336
+ }): AsyncIterable<ChatEvent>;
337
+ /**
338
+ * Higher-level convenience: yield only AI reply deltas. Internally consumes
339
+ * `subscribe()` and selects runtime_status preview chunks + the final
340
+ * assistant message.
341
+ */
342
+ streamReply(taskId: string, opts?: {
343
+ signal?: AbortSignal;
344
+ emitInitialPreview?: boolean;
345
+ idleTimeoutMs?: number;
346
+ }): AsyncIterable<StreamReplyDelta>;
347
+ }
348
+
349
+ /**
350
+ * ConductorAppError: every error thrown out of the SDK is one of these.
351
+ * Callers can `if (err instanceof ConductorAppError) switch (err.code)`.
352
+ *
353
+ * `code` is open-vocabulary for forward compat; the SDK promises to never
354
+ * remove existing codes, only add new ones, and to bump the minor version
355
+ * when a new code is introduced.
356
+ */
357
+ declare class ConductorAppError extends Error {
358
+ readonly name = "ConductorAppError";
359
+ readonly code: ConductorErrorCode | string;
360
+ readonly status?: number;
361
+ readonly details?: unknown;
362
+ readonly requestId?: string;
363
+ constructor(args: {
364
+ code: ConductorErrorCode | string;
365
+ message: string;
366
+ status?: number;
367
+ details?: unknown;
368
+ requestId?: string;
369
+ cause?: unknown;
370
+ });
371
+ }
372
+ type ConductorErrorCode = 'unauthorized' | 'forbidden' | 'token_revoked' | 'invalid_input' | 'project_not_found' | 'task_not_found' | 'message_not_found' | 'daemon_offline' | 'workspace_path_conflict' | 'binding_validation_failed' | 'task_not_running' | 'task_type_not_messageable' | 'task_type_not_interruptible' | 'task_fire_owner_offline' | 'network_error' | 'timeout' | 'rate_limited' | 'server_error' | 'subscribe_failed' | 'stream_aborted';
373
+ /** Helper: type-guard for callers that don't want `instanceof`. */
374
+ declare function isConductorAppError(error: unknown): error is ConductorAppError;
375
+
376
+ export { AppClient, type AppClientOptions, type Attachment, type BindProjectInput, type ChatEvent, ConductorAppError, type ConnectOptions, type CreateTaskInput, type Message, type MessageRole, type Project, type RuntimeState, type RuntimeStatus, type SendMessageInput, type StreamReplyDelta, type Task, type TaskStatus, connect, isConductorAppError };