@opencode-ai/client 0.0.0-dev-17688 → 0.0.0-dev-17694

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,4 +1,5 @@
1
1
  import type { AgentInfo, CommandInfo, FormInfo, IntegrationInfo, LocationRef, LocationGetOutput, McpResource, McpServer, ModelInfo, PermissionSavedInfo, PermissionRequest, PermissionReplyInput, Project, ProviderInfo, ReferenceInfo, SessionMessageInfo, SessionInfo, SessionInboxInfo, ShellInfo, SkillInfo, VcsInfo, OpenCodeEvent, OpenCodeClient, WebSearchProvider } from "../promise";
2
+ import { type SessionPromptInput } from "../promise";
2
3
  export type DataSessionStatus = "idle" | "running";
3
4
  export type CreateDataInput = {
4
5
  readonly api: () => OpenCodeClient;
@@ -49,6 +50,7 @@ export declare function createData(config: CreateDataInput): {
49
50
  sync(sessionID: string): Promise<void>;
50
51
  invalidate(sessionID: string): void;
51
52
  };
53
+ prompt(input: SessionPromptInput): Promise<import("../promise").SessionInboxUser>;
52
54
  sync(sessionID: string, options?: {
53
55
  children?: boolean;
54
56
  }): Promise<void>;
@@ -3,9 +3,10 @@
3
3
  // merges, live/history overlays, or other race machinery here—last write wins.
4
4
  // Reconnect invalidates cached reads; active UI owners decide what to sync again.
5
5
  import { Worktree } from "@opencode-ai/schema/worktree";
6
+ import { SessionMessage } from "@opencode-ai/schema/session-message";
6
7
  import { isPermissionNotFoundError } from "../promise";
7
8
  import { createStore, produce, reconcile } from "solid-js/store";
8
- import { createEffect, createSignal, onCleanup } from "solid-js";
9
+ import { batch, createEffect, createSignal, onCleanup } from "solid-js";
9
10
  const messageIDFromEvent = (eventID) => eventID.replace(/^evt_/, "msg_");
10
11
  export function locationKey(location) {
11
12
  return JSON.stringify([location.directory, location.workspaceID]);
@@ -75,11 +76,6 @@ export function createData(config) {
75
76
  function setSessionActive(sessionID, status) {
76
77
  setStore("session", "active", sessionID, status);
77
78
  }
78
- function addPending(item) {
79
- if (store.session.pending[item.sessionID]?.some((pending) => pending.id === item.id))
80
- return;
81
- setStore("session", "pending", item.sessionID, [...(store.session.pending[item.sessionID] ?? []), item]);
82
- }
83
79
  function removePending(sessionID, inboxID) {
84
80
  if (!inboxID)
85
81
  return;
@@ -101,6 +97,56 @@ export function createData(config) {
101
97
  return;
102
98
  setStore("session", "pending", sessionID, index, { ...item, delivery });
103
99
  }
100
+ // Inbox IDs of optimistic prompt admissions still awaiting their durable
101
+ // echo. This is the one deliberate piece of in-flight bookkeeping in this
102
+ // layer: it exists so a rejection only rolls back rows the server never
103
+ // acknowledged, and so a concurrent pending re-fetch cannot wipe a row the
104
+ // server does not know about yet. Entries clear on the enqueued echo or on
105
+ // rollback — not on POST success, which typically precedes the echo.
106
+ const outbox = new Set();
107
+ // Upsert an admitted inbox item into pending, input, and (for user and
108
+ // synthetic items) the visible transcript. Used by the inbox.enqueued
109
+ // handler and by optimistic prompt admission; the upsert is what reconciles
110
+ // the durable echo with an optimistic placeholder — the durable payload and
111
+ // times replace the client's guess.
112
+ function admitLocal(item) {
113
+ batch(() => {
114
+ const pending = store.session.pending[item.sessionID] ?? [];
115
+ const at = pending.findIndex((entry) => entry.id === item.id);
116
+ setStore("session", "pending", item.sessionID, at < 0 ? [...pending, item] : pending.map((entry, index) => (index === at ? item : entry)));
117
+ const input = store.session.input[item.sessionID] ?? [];
118
+ if (!input.includes(item.id))
119
+ setStore("session", "input", item.sessionID, [...input, item.id]);
120
+ if (item.type !== "user" && item.type !== "synthetic")
121
+ return;
122
+ message.update(item.sessionID, (draft, index) => {
123
+ const row = item.type === "user"
124
+ ? { id: item.id, type: "user", ...item.payload, time: { created: item.timeCreated } }
125
+ : { id: item.id, type: "synthetic", ...item.payload, time: { created: item.timeCreated } };
126
+ const position = index.get(item.id);
127
+ if (position === undefined)
128
+ return message.append(draft, index, row);
129
+ draft[position] = row;
130
+ });
131
+ });
132
+ }
133
+ // Remove an inbox item from pending, input, and the visible transcript.
134
+ // Used by the inbox.cancelled handler and by optimistic rollback.
135
+ function retractLocal(sessionID, inboxID) {
136
+ batch(() => {
137
+ removePending(sessionID, inboxID);
138
+ if (!messageIndex.get(sessionID)?.has(inboxID))
139
+ return;
140
+ message.update(sessionID, (draft, index) => {
141
+ const position = index.get(inboxID);
142
+ if (position === undefined)
143
+ return;
144
+ draft.splice(position, 1);
145
+ index.delete(inboxID);
146
+ message.reindex(draft, index, position);
147
+ });
148
+ });
149
+ }
104
150
  const message = {
105
151
  update(sessionID, fn) {
106
152
  setStore("session", "message", produce((draft) => {
@@ -198,6 +244,7 @@ export function createData(config) {
198
244
  }));
199
245
  }
200
246
  function removeSession(sessionID) {
247
+ store.session.pending[sessionID]?.forEach((item) => outbox.delete(item.id));
201
248
  messageIndex.delete(sessionID);
202
249
  sync.invalidate(`session:${sessionID}`);
203
250
  sync.invalidate(`session.pending:${sessionID}`);
@@ -366,47 +413,16 @@ export function createData(config) {
366
413
  updatePending(event.data.sessionID, event.data.inboxID, event.data.delivery);
367
414
  return;
368
415
  case "session.inbox.cancelled": {
369
- removePending(event.data.sessionID, event.data.inboxID);
370
- if (messageIndex.get(event.data.sessionID)?.has(event.data.inboxID))
371
- message.update(event.data.sessionID, (draft, index) => {
372
- const position = index.get(event.data.inboxID);
373
- if (position === undefined)
374
- return;
375
- draft.splice(position, 1);
376
- index.delete(event.data.inboxID);
377
- message.reindex(draft, index, position);
378
- });
416
+ retractLocal(event.data.sessionID, event.data.inboxID);
379
417
  return;
380
418
  }
381
419
  case "session.inbox.enqueued": {
382
- const item = event.data.item;
383
- addPending({
420
+ outbox.delete(event.data.inboxID);
421
+ admitLocal({
384
422
  id: event.data.inboxID,
385
423
  sessionID: event.data.sessionID,
386
424
  timeCreated: event.created,
387
- ...item,
388
- });
389
- if (!store.session.input[event.data.sessionID]?.includes(event.data.inboxID))
390
- setStore("session", "input", event.data.sessionID, [
391
- ...(store.session.input[event.data.sessionID] ?? []),
392
- event.data.inboxID,
393
- ]);
394
- if (item.type !== "user" && item.type !== "synthetic")
395
- return;
396
- message.update(event.data.sessionID, (draft, index) => {
397
- message.append(draft, index, item.type === "user"
398
- ? {
399
- id: event.data.inboxID,
400
- type: "user",
401
- ...item.payload,
402
- time: { created: event.created },
403
- }
404
- : {
405
- id: event.data.inboxID,
406
- type: "synthetic",
407
- ...item.payload,
408
- time: { created: event.created },
409
- });
425
+ ...event.data.item,
410
426
  });
411
427
  return;
412
428
  }
@@ -927,14 +943,60 @@ export function createData(config) {
927
943
  sync(sessionID) {
928
944
  return sync.run(`session.pending:${sessionID}`, async () => {
929
945
  const pending = await api().session.inbox.list({ sessionID });
930
- setStore("session", "pending", sessionID, reconcile(pending));
931
- setStore("session", "input", sessionID, reconcile(pending.filter((item) => item.type !== "compaction").map((item) => item.id)));
946
+ // Keep optimistic rows still awaiting their echo: this fetch may
947
+ // have raced ahead of an in-flight admission the server does not
948
+ // know about yet.
949
+ const inflight = (store.session.pending[sessionID] ?? []).filter((item) => outbox.has(item.id) && !pending.some((row) => row.id === item.id));
950
+ const merged = inflight.length === 0 ? pending : [...pending, ...inflight];
951
+ setStore("session", "pending", sessionID, reconcile(merged));
952
+ setStore("session", "input", sessionID, reconcile(merged.filter((item) => item.type !== "compaction").map((item) => item.id)));
932
953
  });
933
954
  },
934
955
  invalidate(sessionID) {
935
956
  sync.invalidate(`session.pending:${sessionID}`);
936
957
  },
937
958
  },
959
+ // Optimistic prompt admission: render the prompt immediately under a
960
+ // client-minted ID, send it, and let the durable inbox.enqueued echo
961
+ // upsert that same ID with the server's payload. Server admission is
962
+ // idempotent per ID, so retrying with the identical payload cannot
963
+ // double-admit.
964
+ prompt(input) {
965
+ const id = input.id ?? SessionMessage.ID.create();
966
+ // A retry may reuse an ID that is already rendered — and possibly
967
+ // already durable. Admit optimistically only for new IDs so a failed
968
+ // retry cannot roll back acknowledged state.
969
+ const fresh = !messageIndex.get(input.sessionID)?.has(id) &&
970
+ !store.session.pending[input.sessionID]?.some((item) => item.id === id);
971
+ if (fresh) {
972
+ outbox.add(id);
973
+ admitLocal({
974
+ id,
975
+ sessionID: input.sessionID,
976
+ timeCreated: Date.now(),
977
+ type: "user",
978
+ delivery: input.delivery ?? "steer",
979
+ // Files and skills stay off the optimistic row: their durable
980
+ // forms are server-loaded (content, mime, resolution), so they
981
+ // fill in when the echo upserts the row.
982
+ payload: {
983
+ text: input.text,
984
+ agents: input.agents?.map((agent) => ({ ...agent })),
985
+ metadata: input.metadata,
986
+ },
987
+ });
988
+ }
989
+ // Wrapped so even a synchronous client failure reaches the rollback.
990
+ return Promise.resolve()
991
+ .then(() => api().session.prompt({ ...input, id }))
992
+ .catch((error) => {
993
+ // Roll back only rows this call admitted and the echo has not
994
+ // acknowledged: anything else is server state.
995
+ if (fresh && outbox.delete(id))
996
+ retractLocal(input.sessionID, id);
997
+ throw error;
998
+ });
999
+ },
938
1000
  sync(sessionID, options) {
939
1001
  return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => {
940
1002
  const [info, children] = await Promise.all([
@@ -971,7 +1033,12 @@ export function createData(config) {
971
1033
  sync(sessionID) {
972
1034
  return sync.run(`session.message:${sessionID}`, async () => {
973
1035
  const response = await api().message.list({ sessionID, limit: 200, order: "desc" });
974
- const messages = response.data.toReversed();
1036
+ const fetched = response.data.toReversed();
1037
+ // Same protection as the pending sync: a re-fetch racing an
1038
+ // optimistic admission must not wipe the in-flight transcript row.
1039
+ const ids = new Set(fetched.map((item) => item.id));
1040
+ const inflight = (store.session.message[sessionID] ?? []).filter((item) => outbox.has(item.id) && !ids.has(item.id));
1041
+ const messages = inflight.length === 0 ? fetched : [...fetched, ...inflight];
975
1042
  messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])));
976
1043
  setStore("session", "message", sessionID, reconcile(messages));
977
1044
  setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@opencode-ai/client",
4
- "version": "0.0.0-dev-17688",
4
+ "version": "0.0.0-dev-17694",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -57,8 +57,8 @@
57
57
  "typecheck": "tsgo --noEmit"
58
58
  },
59
59
  "dependencies": {
60
- "@opencode-ai/schema": "0.0.0-dev-17688",
61
- "@opencode-ai/protocol": "0.0.0-dev-17688"
60
+ "@opencode-ai/schema": "0.0.0-dev-17694",
61
+ "@opencode-ai/protocol": "0.0.0-dev-17694"
62
62
  },
63
63
  "peerDependencies": {
64
64
  "effect": "4.0.0-beta.107",
@@ -74,7 +74,7 @@
74
74
  },
75
75
  "devDependencies": {
76
76
  "@effect/platform-node": "4.0.0-beta.107",
77
- "@opencode-ai/httpapi-codegen": "0.0.0-dev-17688",
77
+ "@opencode-ai/httpapi-codegen": "0.0.0-dev-17694",
78
78
  "@tsconfig/bun": "1.0.9",
79
79
  "@types/bun": "1.3.13",
80
80
  "@typescript/native-preview": "7.0.0-dev.20251207.1",