@olares/lares-core 0.15.4 → 0.15.6

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.
@@ -1,20 +1,38 @@
1
1
  import { isAuthFailure } from "./host.js";
2
- import { listRootSessions, summarizeSession } from "./chat.js";
2
+ import { listNavigableSessions, summarizeSession } from "./chat.js";
3
3
  import { consumeMuxInbox } from "./mux.js";
4
+ import { permissionLine, permissionSelect } from "./permissions.js";
4
5
  import { wrapClientResponse } from "./rpc.js";
6
+ import { sessionReferenceCandidates } from "./references.js";
5
7
  import { createSessionCache } from "./session-cache.js";
6
8
  import { foldTranscript } from "./transcript.js";
9
+ import { normalizeWorkspaceList, workspaceForSession } from "./workspace.js";
7
10
 
8
11
  const DRAFT_SESSION = "@lares/draft";
12
+ const ACTIVITY_COALESCE_MS = 5000;
9
13
 
10
14
  function isDraft(sessionId) {
11
15
  return sessionId === DRAFT_SESSION;
12
16
  }
13
17
 
14
- function viewOf(state, cache, pendingQuestions) {
18
+ function composerProjections(source) {
19
+ const values = source?.projections?.values ?? {};
20
+ const permissions = permissionSelect(source);
21
+ return {
22
+ permissions: permissions ?? undefined,
23
+ plan: values.plan,
24
+ contextPressure: values.contextPressure,
25
+ contextBreakdown: values.contextBreakdown,
26
+ goal: values.goal,
27
+ todos: values.todos,
28
+ };
29
+ }
30
+
31
+ function viewOf(state, cache, pendingQuestions, pendingApprovals, queues) {
15
32
  const page = cache.peek(state.sessionId);
16
33
  const events = page?.events ?? [];
17
34
  const snap = foldTranscript(events);
35
+ const session = state.sessions.find((row) => row.sessionId === state.sessionId);
18
36
  return {
19
37
  sessionId: isDraft(state.sessionId) ? "" : state.sessionId,
20
38
  items: snap.items,
@@ -25,8 +43,20 @@ function viewOf(state, cache, pendingQuestions) {
25
43
  phase: state.phase,
26
44
  sessions: state.sessions,
27
45
  sessionsReady: state.sessionsReady,
46
+ workspaces: state.workspaces,
47
+ workspacesReady: state.workspacesReady,
48
+ workspaceError: state.workspaceError,
49
+ archivedSessionIds: state.archivedSessionIds,
28
50
  historyLoading: Boolean(state.sessionId) && !page?.ready && events.length === 0,
29
51
  question: pendingQuestions.get(state.sessionId) ?? null,
52
+ approval: pendingApprovals.get(state.sessionId) ?? null,
53
+ queue: queues.get(state.sessionId) ?? [],
54
+ permissions: permissionSelect(session),
55
+ plan: session?.plan,
56
+ contextPressure: session?.contextPressure,
57
+ contextBreakdown: session?.contextBreakdown,
58
+ goal: session?.goal ?? null,
59
+ todos: session?.todos ?? null,
30
60
  };
31
61
  }
32
62
 
@@ -44,6 +74,9 @@ export function createChatRuntime(client) {
44
74
  const listeners = new Set();
45
75
  const cache = createSessionCache();
46
76
  const pendingQuestions = new Map();
77
+ const pendingApprovals = new Map();
78
+ const queues = new Map();
79
+ const projectionSeqs = new Map();
47
80
  const state = {
48
81
  sessionId: "",
49
82
  error: "",
@@ -51,16 +84,21 @@ export function createChatRuntime(client) {
51
84
  phase: "idle",
52
85
  sessions: [],
53
86
  sessionsReady: false,
87
+ workspaces: [],
88
+ workspacesReady: false,
89
+ workspaceError: "",
90
+ archivedSessionIds: [],
54
91
  };
55
92
  let liveScroll = { top: 0, stick: true };
56
93
  let abort = null;
57
94
  let dead = false;
58
95
  let muxLoop = null;
59
96
  let listing = null;
97
+ let workspaceListing = null;
60
98
  let creating = null;
61
99
 
62
100
  const emit = () => {
63
- const view = viewOf(state, cache, pendingQuestions);
101
+ const view = viewOf(state, cache, pendingQuestions, pendingApprovals, queues);
64
102
  for (const listener of listeners) listener(view);
65
103
  };
66
104
 
@@ -71,10 +109,57 @@ export function createChatRuntime(client) {
71
109
  return true;
72
110
  }
73
111
 
112
+ /**
113
+ * The mux carries events for every session, so a row that is not on screen
114
+ * can be dated here instead of waiting for the next `session.list` — that is
115
+ * what lets an unwatched session show as unseen while it works. Coalesced,
116
+ * because a streaming answer would otherwise re-emit on every token.
117
+ */
118
+ function noteActivity(sessionId, now = Date.now()) {
119
+ if (!sessionId || sessionId === state.sessionId || isDraft(sessionId)) return false;
120
+ const row = state.sessions.find((item) => item.sessionId === sessionId);
121
+ if (!row || now - (Number(row.updatedAt) || 0) < ACTIVITY_COALESCE_MS) return false;
122
+ state.sessions = state.sessions.map((item) => (
123
+ item.sessionId === sessionId ? { ...item, updatedAt: now } : item
124
+ ));
125
+ return true;
126
+ }
127
+
128
+ function notePermissions(sessionId, select) {
129
+ if (!sessionId || !select) return false;
130
+ const row = state.sessions.find((item) => item.sessionId === sessionId);
131
+ if (!row || row.permissions?.currentValue === select.currentValue) return false;
132
+ state.sessions = state.sessions.map((item) => (
133
+ item.sessionId === sessionId ? { ...item, permissions: select } : item
134
+ ));
135
+ return true;
136
+ }
137
+
138
+ function noteComposerProjections(sessionId, source) {
139
+ if (!sessionId) return false;
140
+ const row = state.sessions.find((item) => item.sessionId === sessionId);
141
+ if (!row) return false;
142
+ const next = composerProjections(source);
143
+ const changed = Object.entries(next).some(([key, value]) => (
144
+ value !== undefined && JSON.stringify(row[key]) !== JSON.stringify(value)
145
+ ));
146
+ if (!changed) return false;
147
+ state.sessions = state.sessions.map((item) => (
148
+ item.sessionId === sessionId
149
+ ? {
150
+ ...item,
151
+ ...Object.fromEntries(Object.entries(next).filter(([, value]) => value !== undefined)),
152
+ }
153
+ : item
154
+ ));
155
+ return true;
156
+ }
157
+
74
158
  function ingest(sessionId, extra) {
75
159
  const changed = cache.merge(sessionId, extra);
76
160
  const filled = extra?.length ? noteFilled(sessionId) : false;
77
- if (filled || (changed && sessionId === state.sessionId)) emit();
161
+ const moved = extra?.length ? noteActivity(sessionId) : false;
162
+ if (filled || moved || (changed && sessionId === state.sessionId)) emit();
78
163
  }
79
164
 
80
165
  function rememberListed(row) {
@@ -98,7 +183,10 @@ export function createChatRuntime(client) {
98
183
  const events = history.value?.events;
99
184
  const changed = cache.rememberHistory(sessionId, events);
100
185
  const filled = events?.length ? noteFilled(sessionId) : false;
101
- if (filled || (changed && sessionId === state.sessionId)) emit();
186
+ // The history tail carries the same projections a list row does, and it
187
+ // is the fresher of the two for the session being opened.
188
+ const projected = noteComposerProjections(sessionId, history.value);
189
+ if (filled || projected || (changed && sessionId === state.sessionId)) emit();
102
190
  });
103
191
  }
104
192
 
@@ -125,6 +213,22 @@ export function createChatRuntime(client) {
125
213
  ingest(item.sessionId, [item.event]);
126
214
  continue;
127
215
  }
216
+ if (item.kind === "queue") {
217
+ queues.set(item.sessionId, item.items);
218
+ if (item.sessionId === state.sessionId) emit();
219
+ continue;
220
+ }
221
+ if (item.kind === "projection") {
222
+ const projectionKey = `${item.sessionId}\u0000${item.key}`;
223
+ const previousSeq = projectionSeqs.get(projectionKey) ?? -1;
224
+ if (Number.isFinite(item.seq) && item.seq <= previousSeq) continue;
225
+ if (Number.isFinite(item.seq)) projectionSeqs.set(projectionKey, item.seq);
226
+ state.sessions = state.sessions.map((row) => (
227
+ row.sessionId === item.sessionId ? { ...row, [item.key]: item.value } : row
228
+ ));
229
+ if (item.sessionId === state.sessionId) emit();
230
+ continue;
231
+ }
128
232
  if (item.kind === "question") {
129
233
  pendingQuestions.set(item.sessionId, {
130
234
  rpcId: item.rpcId,
@@ -133,6 +237,19 @@ export function createChatRuntime(client) {
133
237
  emit();
134
238
  continue;
135
239
  }
240
+ if (item.kind === "approval") {
241
+ pendingApprovals.set(item.sessionId, item);
242
+ if (item.sessionId === state.sessionId) emit();
243
+ continue;
244
+ }
245
+ if (item.kind === "approval-resolved") {
246
+ const current = pendingApprovals.get(item.sessionId);
247
+ if (!current || current.approvalId === item.approvalId) {
248
+ pendingApprovals.delete(item.sessionId);
249
+ if (item.sessionId === state.sessionId) emit();
250
+ }
251
+ continue;
252
+ }
136
253
  if (item.kind === "question-resolved") {
137
254
  const current = pendingQuestions.get(item.sessionId);
138
255
  if (!current || !item.rpcId || current.rpcId === item.rpcId) {
@@ -179,7 +296,7 @@ export function createChatRuntime(client) {
179
296
  if (dead) return state.sessions;
180
297
  if (listing) return listing;
181
298
  listing = (async () => {
182
- const listed = await listRootSessions(client.rpc);
299
+ const listed = await listNavigableSessions(client.rpc);
183
300
  if (listed.ok) state.sessions = listed.value.items;
184
301
  state.sessionsReady = true;
185
302
  emit();
@@ -190,6 +307,28 @@ export function createChatRuntime(client) {
190
307
  return listing;
191
308
  }
192
309
 
310
+ async function refreshWorkspaces() {
311
+ if (dead) return state.workspaces;
312
+ if (workspaceListing) return workspaceListing;
313
+ workspaceListing = (async () => {
314
+ const listed = await client.rpc("workspace.list", {});
315
+ if (listed.ok) {
316
+ const normalized = normalizeWorkspaceList(listed.value);
317
+ state.workspaces = normalized.items;
318
+ state.archivedSessionIds = normalized.archivedSessionIds;
319
+ state.workspaceError = "";
320
+ } else {
321
+ state.workspaceError = listed.error?.message || listed.error?.code || "workspace";
322
+ }
323
+ state.workspacesReady = true;
324
+ emit();
325
+ return state.workspaces;
326
+ })().finally(() => {
327
+ workspaceListing = null;
328
+ });
329
+ return workspaceListing;
330
+ }
331
+
193
332
  function occupy(sessionId) {
194
333
  state.sessionId = sessionId;
195
334
  state.error = "";
@@ -198,15 +337,26 @@ export function createChatRuntime(client) {
198
337
  emit();
199
338
  }
200
339
 
201
- function currentIsEmpty() {
340
+ function currentIsEmpty(workspaceId, scoped) {
202
341
  if (!state.sessionId || isDraft(state.sessionId)) return false;
342
+ if (scoped) {
343
+ const currentWorkspace = workspaceForSession(state.workspaces, state.sessionId)?.workspaceId;
344
+ if ((currentWorkspace || "") !== (workspaceId || "")) return false;
345
+ }
203
346
  const page = cache.peek(state.sessionId);
204
347
  return Boolean(page?.ready && page.events.length === 0);
205
348
  }
206
349
 
207
- function reusableBlank() {
350
+ function reusableBlank(workspaceId, scoped) {
351
+ const target = state.workspaces.find((row) => row.workspaceId === workspaceId);
352
+ const account = scoped && workspaceId ? new Set(target?.sessionIds ?? []) : null;
353
+ const accounted = scoped && !workspaceId
354
+ ? new Set(state.workspaces.flatMap((row) => row.sessionIds ?? []))
355
+ : null;
208
356
  return state.sessions.find((row) => {
209
357
  if (!row?.blank || !row.sessionId || row.sessionId === state.sessionId) return false;
358
+ if (account && !account.has(row.sessionId)) return false;
359
+ if (accounted && accounted.has(row.sessionId)) return false;
210
360
  const page = cache.peek(row.sessionId);
211
361
  return !page || page.events.length === 0;
212
362
  })?.sessionId;
@@ -223,15 +373,17 @@ export function createChatRuntime(client) {
223
373
  ensureMux();
224
374
  }
225
375
 
226
- async function createSession() {
376
+ async function createSession(workspaceId = null) {
227
377
  if (dead) return;
228
378
  if (creating) return creating;
379
+ const scoped = workspaceId !== null;
380
+ const targetWorkspaceId = workspaceId || undefined;
229
381
  creating = (async () => {
230
- if (currentIsEmpty()) {
382
+ if (currentIsEmpty(targetWorkspaceId, scoped)) {
231
383
  emit();
232
384
  return;
233
385
  }
234
- const blankId = reusableBlank();
386
+ const blankId = reusableBlank(targetWorkspaceId, scoped);
235
387
  if (blankId) {
236
388
  cache.readyEmpty(blankId);
237
389
  occupy(blankId);
@@ -243,7 +395,10 @@ export function createChatRuntime(client) {
243
395
  cache.readyEmpty(DRAFT_SESSION);
244
396
  occupy(DRAFT_SESSION);
245
397
  ensureMux();
246
- const opened = await client.rpc("session.create", {});
398
+ const opened = await client.rpc(
399
+ "session.create",
400
+ targetWorkspaceId ? { workspaceId: targetWorkspaceId } : {},
401
+ );
247
402
  if (dead) return;
248
403
  if (!opened.ok) {
249
404
  cache.drop(DRAFT_SESSION);
@@ -256,6 +411,13 @@ export function createChatRuntime(client) {
256
411
  const sessionId = opened.value.sessionId;
257
412
  cache.readyEmpty(sessionId);
258
413
  rememberListed(summarizeSession({ ...opened.value, sessionId, blank: true }));
414
+ if (targetWorkspaceId) {
415
+ state.workspaces = state.workspaces.map((workspace) => (
416
+ workspace.workspaceId === targetWorkspaceId
417
+ ? { ...workspace, sessionIds: [sessionId, ...(workspace.sessionIds ?? []).filter((id) => id !== sessionId)] }
418
+ : workspace
419
+ ));
420
+ }
259
421
  cache.drop(DRAFT_SESSION);
260
422
  if (state.sessionId !== DRAFT_SESSION && state.sessionId !== sessionId) return;
261
423
  occupy(sessionId);
@@ -267,13 +429,13 @@ export function createChatRuntime(client) {
267
429
  }
268
430
 
269
431
  return {
270
- snapshot: () => viewOf(state, cache, pendingQuestions),
432
+ snapshot: () => viewOf(state, cache, pendingQuestions, pendingApprovals, queues),
271
433
  subscribe(listener) {
272
434
  listeners.add(listener);
273
- listener(viewOf(state, cache, pendingQuestions));
435
+ listener(viewOf(state, cache, pendingQuestions, pendingApprovals, queues));
274
436
  return () => listeners.delete(listener);
275
437
  },
276
- async start() {
438
+ async start(options = {}) {
277
439
  if (dead) return;
278
440
  if (state.phase === "starting") return;
279
441
  if (state.phase === "live" && muxLoop) return;
@@ -292,6 +454,24 @@ export function createChatRuntime(client) {
292
454
  emit();
293
455
  return;
294
456
  }
457
+ if (options.workspaces === true) {
458
+ await Promise.all([refreshSessions(), refreshWorkspaces()]);
459
+ const existing = state.sessions[0];
460
+ if (existing?.sessionId) {
461
+ state.sessionId = existing.sessionId;
462
+ liveScroll = cache.scroll(state.sessionId);
463
+ await pullHistory(state.sessionId);
464
+ ensureMux();
465
+ emit();
466
+ return;
467
+ }
468
+ const workspaceId = state.workspaces[0]?.workspaceId;
469
+ if (workspaceId) {
470
+ await createSession(workspaceId);
471
+ emit();
472
+ return;
473
+ }
474
+ }
295
475
  const opened = await client.ensureSession();
296
476
  if (!opened.ok) {
297
477
  state.phase = "idle";
@@ -309,6 +489,7 @@ export function createChatRuntime(client) {
309
489
  emit();
310
490
  },
311
491
  refreshSessions,
492
+ refreshWorkspaces,
312
493
  async listSessions() {
313
494
  if (state.sessionsReady) {
314
495
  refreshSessions();
@@ -318,11 +499,197 @@ export function createChatRuntime(client) {
318
499
  },
319
500
  openSession,
320
501
  createSession,
321
- async send(text) {
502
+ async searchSessions(query) {
503
+ return client.rpc("session.search", { query });
504
+ },
505
+ async renameSession(sessionId, title) {
506
+ const renamed = await client.rpc("session.rename", { sessionId, title });
507
+ if (!renamed.ok) return renamed;
508
+ state.sessions = state.sessions.map((row) => (
509
+ row.sessionId === sessionId ? { ...row, title: renamed.value?.title || title } : row
510
+ ));
511
+ emit();
512
+ return renamed;
513
+ },
514
+ async forkSession(sessionId) {
515
+ if (!sessionId || isDraft(sessionId)) {
516
+ return { ok: false, error: { code: "no_session", message: "no session" } };
517
+ }
518
+ const forked = await client.rpc("session.fork", { sessionId });
519
+ const childId = forked.ok ? forked.value?.sessionId : "";
520
+ if (!childId) {
521
+ const failure = forked.ok
522
+ ? { ok: false, error: { code: "fork", message: "fork returned no session" } }
523
+ : forked;
524
+ if (dead) return failure;
525
+ state.failed = failure.error?.message || failure.error?.code || "fork";
526
+ emit();
527
+ return failure;
528
+ }
529
+ // The child's title, order and workspace are decided host-side, so re-read
530
+ // whichever lists this runtime tracks rather than guessing them locally.
531
+ await Promise.all([refreshSessions(), state.workspacesReady ? refreshWorkspaces() : null]);
532
+ await openSession(childId);
533
+ return forked;
534
+ },
535
+ async createWorkspace(path) {
536
+ const created = await client.rpc("workspace.create", { path });
537
+ if (!created.ok) return created;
538
+ const workspace = created.value?.workspace;
539
+ if (workspace) {
540
+ state.workspaces = [
541
+ workspace,
542
+ ...state.workspaces.filter((row) => row.workspaceId !== workspace.workspaceId),
543
+ ];
544
+ state.workspacesReady = true;
545
+ emit();
546
+ }
547
+ return created;
548
+ },
549
+ async renameWorkspace(workspaceId, title) {
550
+ const renamed = await client.rpc("workspace.rename", { workspaceId, title });
551
+ if (!renamed.ok) return renamed;
552
+ const workspace = renamed.value?.workspace;
553
+ if (workspace) {
554
+ state.workspaces = state.workspaces.map((row) => (
555
+ row.workspaceId === workspaceId ? workspace : row
556
+ ));
557
+ emit();
558
+ }
559
+ return renamed;
560
+ },
561
+ async deleteWorkspace(workspaceId) {
562
+ const deleted = await client.rpc("workspace.delete", { workspaceId });
563
+ if (!deleted.ok) return deleted;
564
+ state.workspaces = state.workspaces.filter((row) => row.workspaceId !== workspaceId);
565
+ emit();
566
+ return deleted;
567
+ },
568
+ async insertWorkspaceBefore(workspaceId, beforeWorkspaceId) {
569
+ const moved = await client.rpc("workspace.insertBefore", {
570
+ workspaceId,
571
+ ...(beforeWorkspaceId ? { beforeWorkspaceId } : {}),
572
+ });
573
+ if (!moved.ok) return moved;
574
+ const byId = new Map(state.workspaces.map((row) => [row.workspaceId, row]));
575
+ state.workspaces = (moved.value?.workspaceIds ?? []).map((id) => byId.get(id)).filter(Boolean);
576
+ emit();
577
+ return moved;
578
+ },
579
+ async insertSessionBefore(workspaceId, sessionId, beforeSessionId) {
580
+ const moved = await client.rpc("workspace.insertSessionBefore", {
581
+ workspaceId,
582
+ sessionId,
583
+ ...(beforeSessionId ? { beforeSessionId } : {}),
584
+ });
585
+ if (!moved.ok) return moved;
586
+ const workspace = moved.value?.workspace;
587
+ if (workspace) {
588
+ state.workspaces = state.workspaces.map((row) => (
589
+ row.workspaceId === workspaceId ? workspace : row
590
+ ));
591
+ emit();
592
+ }
593
+ return moved;
594
+ },
595
+ async archiveSession(sessionId) {
596
+ const archived = await client.rpc("workspace.archiveSession", { sessionId });
597
+ if (!archived.ok) return archived;
598
+ state.archivedSessionIds = archived.value?.archivedSessionIds ?? state.archivedSessionIds;
599
+ emit();
600
+ return archived;
601
+ },
602
+ sessionModels(sessionId = state.sessionId) {
603
+ if (!sessionId || isDraft(sessionId)) {
604
+ return Promise.resolve({ ok: false, error: { code: "no_session", message: "no session" } });
605
+ }
606
+ return client.rpc("session.models", { sessionId });
607
+ },
608
+ selectModel(selection) {
609
+ const sessionId = state.sessionId;
610
+ if (!sessionId || isDraft(sessionId)) {
611
+ return Promise.resolve({ ok: false, error: { code: "no_session", message: "no session" } });
612
+ }
613
+ return client.rpc("session.selectModel", { sessionId, ...selection });
614
+ },
615
+ cancel() {
616
+ const sessionId = state.sessionId;
617
+ if (!sessionId || isDraft(sessionId)) {
618
+ return Promise.resolve({ ok: false, error: { code: "no_session", message: "no session" } });
619
+ }
620
+ return client.rpc("session.cancel", { sessionId });
621
+ },
622
+ updateQueue(itemId, action) {
623
+ const sessionId = state.sessionId;
624
+ if (!sessionId || isDraft(sessionId) || !itemId) {
625
+ return Promise.resolve({ ok: false, error: { code: "no_session", message: "no session" } });
626
+ }
627
+ return client.rpc("session.updateQueue", { sessionId, itemId, action });
628
+ },
629
+ listDirectory(path) {
630
+ return client.rpc("host.listDirectory", path ? { path } : {});
631
+ },
632
+ createDirectory(path, name) {
633
+ return client.rpc("host.createDirectory", { path, name });
634
+ },
635
+ /**
636
+ * The preset is switched with the host's own `/permission` command; there
637
+ * is no RPC for it. The projection behind the chip is recomputed host-side,
638
+ * so the switch shows at once and the following list read confirms it.
639
+ */
640
+ async setPermission(value) {
641
+ const sessionId = state.sessionId;
642
+ if (!value || !sessionId || isDraft(sessionId)) {
643
+ return { ok: false, error: { code: "no_session", message: "no session" } };
644
+ }
645
+ const sent = await client.prompt(sessionId, permissionLine(value));
646
+ if (!sent.ok || dead) return sent;
647
+ const current = permissionSelect(state.sessions.find((row) => row.sessionId === sessionId));
648
+ if (current) notePermissions(sessionId, { ...current, currentValue: value });
649
+ emit();
650
+ void refreshSessions();
651
+ return sent;
652
+ },
653
+ listCommands() {
654
+ if (typeof client.commands !== "function") return Promise.resolve([]);
655
+ return client.commands(state.sessionId);
656
+ },
657
+ async listReferences(query, quoted = false) {
658
+ if (!state.sessionId || isDraft(state.sessionId)) return [];
659
+ const files = typeof client.references === "function"
660
+ ? await client.references(state.sessionId, query, quoted)
661
+ : [];
662
+ if (quoted) return files.filter((row) => row.kind !== "session");
663
+ const sessions = sessionReferenceCandidates(state.sessions, state.sessionId, query);
664
+ const fileRows = files.filter((row) => row.kind !== "session");
665
+ return [...fileRows, ...sessions];
666
+ },
667
+ runCommand(line) {
668
+ if (!state.sessionId || isDraft(state.sessionId)) {
669
+ return Promise.resolve({ ok: false, error: { message: "no session" } });
670
+ }
671
+ return client.prompt(state.sessionId, line);
672
+ },
673
+ goalAction(kind, value) {
674
+ const sessionId = state.sessionId;
675
+ const projection = state.sessions.find((row) => row.sessionId === sessionId)?.goal;
676
+ const ref = projection?.goal
677
+ ? { id: projection.goal.id, revision: projection.goal.revision }
678
+ : null;
679
+ if (!sessionId || isDraft(sessionId) || !ref || !["edit", "pause", "resume", "clear"].includes(kind)) {
680
+ return Promise.resolve({ ok: false, error: { code: "no-current-goal", message: "no current goal" } });
681
+ }
682
+ return client.rpc(`goal.${kind}`, {
683
+ sessionId,
684
+ ref,
685
+ ...(kind === "edit" ? { objective: String(value ?? "") } : {}),
686
+ });
687
+ },
688
+ async send(content, mode = "queue") {
322
689
  if (!state.sessionId || isDraft(state.sessionId)) {
323
690
  return { ok: false, error: { message: "no session" } };
324
691
  }
325
- return client.prompt(state.sessionId, text);
692
+ return client.prompt(state.sessionId, content, mode);
326
693
  },
327
694
  async answerQuestion(answers) {
328
695
  const pending = pendingQuestions.get(state.sessionId);
@@ -338,6 +705,24 @@ export function createChatRuntime(client) {
338
705
  }
339
706
  return { ok: true };
340
707
  },
708
+ async answerApproval(outcome) {
709
+ const pending = pendingApprovals.get(state.sessionId);
710
+ if (!pending?.rpcId || !pending.approvalId || typeof client.respond !== "function") {
711
+ return { ok: false, error: { message: "no approval" } };
712
+ }
713
+ const receipt = await client.respond(wrapClientResponse(pending.rpcId, {
714
+ ok: true,
715
+ value: {
716
+ sessionId: state.sessionId,
717
+ approvalId: pending.approvalId,
718
+ outcome: outcome === "allowed-once" ? "allowed-once" : "rejected",
719
+ },
720
+ }));
721
+ if (!receipt?.accepted) {
722
+ return { ok: false, error: { message: receipt?.reason || "not-pending" } };
723
+ }
724
+ return { ok: true };
725
+ },
341
726
  preview(path) {
342
727
  if (!state.sessionId || typeof client.preview !== "function") {
343
728
  return Promise.reject(new Error("file_preview_failed"));
@@ -386,6 +771,7 @@ export function createChatRuntime(client) {
386
771
  abort?.abort();
387
772
  muxLoop = null;
388
773
  listing = null;
774
+ workspaceListing = null;
389
775
  creating = null;
390
776
  cache.clear();
391
777
  pendingQuestions.clear();
@@ -1,8 +1,29 @@
1
1
  import { createSnapshotStore } from "../tools/async.js";
2
+ import { callRpc } from "./chat.js";
3
+ import {
4
+ CONVERSATION_SETTINGS_NAMESPACE,
5
+ busyEnterBehavior,
6
+ conversationSettings,
7
+ } from "./submission-settings.js";
2
8
 
3
9
  const modelsStore = createSnapshotStore();
4
10
  const voiceStore = createSnapshotStore();
5
11
  const searchStore = createSnapshotStore();
12
+ const conversationStore = createSnapshotStore();
13
+ const conversationListeners = new Set();
14
+
15
+ function publishConversation(value) {
16
+ conversationStore.remember(value);
17
+ for (const listener of conversationListeners) listener(value);
18
+ return value;
19
+ }
20
+
21
+ export function subscribeConversationSettings(listener) {
22
+ conversationListeners.add(listener);
23
+ const current = conversationStore.peek();
24
+ if (current) listener(current);
25
+ return () => conversationListeners.delete(listener);
26
+ }
6
27
 
7
28
  function fail(res, path) {
8
29
  const err = res?.body?.error;
@@ -14,6 +35,11 @@ function payload(res, path) {
14
35
  return res.body;
15
36
  }
16
37
 
38
+ function rpcValue(result, method) {
39
+ if (result?.ok) return result.value;
40
+ throw new Error(result?.error?.message || result?.error?.code || method);
41
+ }
42
+
17
43
  function sttModels(body) {
18
44
  const list = Array.isArray(body?.stt) ? body.stt : [];
19
45
  return list.map((item) => (typeof item === "string" ? item : item?.id)).filter(Boolean);
@@ -24,6 +50,7 @@ export function rememberedSettings() {
24
50
  models: modelsStore.peek(),
25
51
  voice: voiceStore.peek(),
26
52
  search: searchStore.peek(),
53
+ conversation: conversationStore.peek(),
27
54
  };
28
55
  }
29
56
 
@@ -31,6 +58,7 @@ export function resetHostSettingsCache() {
31
58
  modelsStore.remember(null);
32
59
  voiceStore.remember(null);
33
60
  searchStore.remember(null);
61
+ conversationStore.remember(null);
34
62
  }
35
63
 
36
64
  export function createHostSettings(request) {
@@ -96,5 +124,26 @@ export function createHostSettings(request) {
96
124
  ),
97
125
  );
98
126
  },
127
+ async conversation(options = {}) {
128
+ const value = await conversationStore.load(async () => {
129
+ const described = rpcValue(await callRpc(request, "settings.describe"), "settings.describe");
130
+ return conversationSettings(described);
131
+ }, options);
132
+ return publishConversation(value);
133
+ },
134
+ async setBusyEnter(value) {
135
+ const current = await this.conversation();
136
+ if (!current.writable) throw new Error("settings are read-only");
137
+ const next = rpcValue(await callRpc(request, "settings.update", {
138
+ ns: CONVERSATION_SETTINGS_NAMESPACE,
139
+ patch: { busyEnter: busyEnterBehavior(value) },
140
+ ...(current.revision === undefined ? {} : { expectedRevision: current.revision }),
141
+ }), "settings.update");
142
+ return publishConversation({
143
+ busyEnter: busyEnterBehavior(next.value?.busyEnter),
144
+ revision: Number.isInteger(next.revision) ? next.revision : undefined,
145
+ writable: true,
146
+ });
147
+ },
99
148
  };
100
149
  }