@unieai/code 0.0.30 → 0.0.31

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.
@@ -177,6 +177,20 @@ await launchAppServer({
177
177
  defaultModel: dsh.defaultModel,
178
178
  threadMode,
179
179
  onShutdown: () => Promise.all([...hosts.values()].map((host) => host.close())),
180
+ // What this account can actually run: a model it does not offer (a config
181
+ // written for another account — Studio's ids differ from Rabi's) becomes
182
+ // its default, so the client shows the model the turns run on.
183
+ resolveModel: (model) => {
184
+ if (!model) return model;
185
+ try {
186
+ const account = writeAccount();
187
+ if (account.models.includes(model)) return model;
188
+ log(`[uac] this account does not offer "${model}"; using ${account.defaultModel}`);
189
+ return account.defaultModel;
190
+ } catch {
191
+ return model;
192
+ }
193
+ },
180
194
  buildEngine: ({ cwd, model, modelProvider, sandboxMode: _mode, oneShot, variant, readConfig, ...callbacks }) => {
181
195
  // The user's `[mcp_servers]` and Studio's (knowledge bases, SQL, skills),
182
196
  // read when each session opens so a config edit or new sign-in counts.
@@ -194,6 +208,8 @@ await launchAppServer({
194
208
  } catch (error) {
195
209
  log("[uac] could not refresh the account for dsh:", error.message);
196
210
  }
211
+ // Already resolved by `resolveModel` for threads the client started;
212
+ // a stored thread's model is checked again here.
197
213
  const chosen = model && account.models.includes(model) ? model : account.defaultModel;
198
214
  // The client's hidden structured turns (thread titles): one tool-less call.
199
215
  if (oneShot) return createOneShotEngine({ oneShot: oneShotModel, model: chosen, onText: callbacks.onText });
@@ -215,7 +231,7 @@ await launchAppServer({
215
231
  // Threads stored before modes existed have none: standard.
216
232
  host: hostFor(variant ?? "standard"),
217
233
  workspace: cwd,
218
- model: model && account.models.includes(model) ? model : account.defaultModel,
234
+ model: chosen,
219
235
  onLog: (line) => log("[uac]", line),
220
236
  mcpServers,
221
237
  ...callbacks,
@@ -163,10 +163,17 @@ export function createHandlers({
163
163
  // workspace-write, so the header promised a restriction nothing enforced.
164
164
  sandboxMode = "workspace-write",
165
165
  now = () => new Date().toISOString(),
166
+ // Called with what made a turn fail, so the reason is in the server's log
167
+ // and not only in the one line the client shows.
168
+ onTurnError = () => {},
166
169
  threadStore = null,
167
170
  // The engine variant a new thread runs in (uac: the dsh mode), read when
168
171
  // the thread starts; a forked thread keeps its source's.
169
172
  threadMode = () => null,
173
+ // A requested model as this account can actually run it. A config written
174
+ // for another account names models this one does not have, and the client
175
+ // would otherwise show a model no turn runs on.
176
+ resolveModel = (model) => model,
170
177
  }) {
171
178
  // Thread state lives here, not in the Rust process: its ThreadStateManager
172
179
  // keeps threads in an in-process HashMap, so a thread created there is not
@@ -250,6 +257,49 @@ export function createHandlers({
250
257
  return child ? subagents.threadFields(child) : null;
251
258
  };
252
259
 
260
+ /** What the thread runs under, as the protocol's SandboxPolicy. */
261
+ const sandboxShape = (t) =>
262
+ t.readOnly || (t.permissions?.sandbox ?? (sandboxMode === "readOnly" ? "read-only" : null)) === "read-only"
263
+ ? { type: "readOnly", networkAccess: false }
264
+ : t.permissions?.sandbox === "danger-full-access" || sandboxMode === "dangerFullAccess"
265
+ ? { type: PROTOCOL_SANDBOX["danger-full-access"] }
266
+ : { type: "workspaceWrite", networkAccess: true };
267
+
268
+ /** The named profile in force: the client's own when it asked for one. */
269
+ const permissionProfileShape = (t) => ({
270
+ id: t.permissionProfile ?? (sandboxMode === "readOnly" || t.readOnly ? ":read-only" : ":workspace-write"),
271
+ extends: null,
272
+ });
273
+
274
+ /**
275
+ * The thread as the protocol's ThreadSettings, for `thread/settings/updated`.
276
+ *
277
+ * The TUI holds a pending permission change until a notification confirms
278
+ * the profile it asked for is the one in force, and blocks switching task,
279
+ * forking and `/cd` while it waits ("Wait for permissions to update before
280
+ * ..."). We answered `thread/settings/update` and sent nothing, so the wait
281
+ * never ended: one `/permissions` left the rest of the session fenced off
282
+ * until it was restarted.
283
+ */
284
+ const threadSettings = (t) => ({
285
+ disabledPluginIds: [],
286
+ cwd: t.cwd,
287
+ approvalPolicy: t.permissions?.approval === "never" ? "never" : "on-request",
288
+ approvalsReviewer: "user",
289
+ sandboxPolicy: sandboxShape(t),
290
+ activePermissionProfile: permissionProfileShape(t),
291
+ model: t.model || defaultModel || "",
292
+ modelProvider: t.modelProvider || defaultProvider || "",
293
+ serviceTier: null,
294
+ effort: t.effort ?? null,
295
+ summary: null,
296
+ // dsh has no plan/default split of its own; a thread is always the one
297
+ // the client started, and its model is the thread's.
298
+ collaborationMode: { mode: "default", settings: { model: t.model || defaultModel || "" } },
299
+ multiAgentMode: "explicitRequestOnly",
300
+ personality: null,
301
+ });
302
+
253
303
  /** The session-settings half of thread/start, /resume and /fork responses. */
254
304
  const sessionResponse = (t, params, turns = []) => ({
255
305
  thread: threadShape(t, turns),
@@ -263,15 +313,8 @@ export function createHandlers({
263
313
  approvalPolicy: params?.approvalPolicy || "on-request",
264
314
  approvalsReviewer: params?.approvalsReviewer || "user",
265
315
  // What the thread runs under: its own -s when it has one.
266
- sandbox: t.readOnly || (t.permissions?.sandbox ?? (sandboxMode === "readOnly" ? "read-only" : null)) === "read-only"
267
- ? { type: "readOnly", networkAccess: false }
268
- : t.permissions?.sandbox === "danger-full-access" || sandboxMode === "dangerFullAccess"
269
- ? { type: PROTOCOL_SANDBOX["danger-full-access"] }
270
- : { type: "workspaceWrite", networkAccess: true },
271
- activePermissionProfile: {
272
- id: t.permissionProfile ?? (sandboxMode === "readOnly" || t.readOnly ? ":read-only" : ":workspace-write"),
273
- extends: null,
274
- },
316
+ sandbox: sandboxShape(t),
317
+ activePermissionProfile: permissionProfileShape(t),
275
318
  // dsh decides how much the model thinks; no effort is chosen here, and
276
319
  // "none" would read as thinking turned off.
277
320
  reasoningEffort: null,
@@ -392,6 +435,9 @@ export function createHandlers({
392
435
  if (method === "item/completed" && openCards.delete(item?.id) && openCards.size === 0) runAfterCards();
393
436
  return sent;
394
437
  },
438
+ // What the model said so far is a finished message: anything that is
439
+ // not a card (the plan, a question) still ends the text before it.
440
+ closeText,
395
441
  /** Run `place` now, or once the cards open in this turn have completed. */
396
442
  whenNoOpenCards: (place) => {
397
443
  if (openCards.size === 0) place();
@@ -418,7 +464,9 @@ export function createHandlers({
418
464
  // The client files an error by thread and turn and needs to know
419
465
  // whether to expect a retry; without those it cannot place the error
420
466
  // at all, so an engine failure was invisible.
421
- ctx?.emit?.("error", { error: { message: String(error?.message || error) }, willRetry: false, threadId: thread.id, turnId });
467
+ const message = String(error?.message || error);
468
+ onTurnError(message, { threadId: thread.id, turnId });
469
+ ctx?.emit?.("error", { error: { message }, willRetry: false, threadId: thread.id, turnId });
422
470
  }
423
471
  thread.activeTurn = null;
424
472
  thread.pendingAnswer = null;
@@ -533,6 +581,7 @@ export function createHandlers({
533
581
  onSteerDelivered: ({ clientId, text }) => emitUserMessage(thread, text, clientId),
534
582
  // The model's questions, through the client's request_user_input screen.
535
583
  askUser: async ({ questions }) => {
584
+ thread.pendingAnswer?.closeText?.();
536
585
  const emitItem = thread.pendingAnswer?.emitItem;
537
586
  for (const detail of questionDetails(questions)) {
538
587
  const item = agentMessageItem(newItemId(), detail);
@@ -586,6 +635,10 @@ export function createHandlers({
586
635
  request: (...args) => thread.connection.request(...args),
587
636
  emit: (method, params) => {
588
637
  const pending = thread.pendingAnswer;
638
+ // The plan checklist shows no card, but it still marks the end of
639
+ // what the model was saying: a client holding the message open (and
640
+ // queueing what follows behind it) would wait forever.
641
+ if (method === "turn/plan/updated") pending?.closeText?.();
589
642
  // Text deltas are routed through the turn's answer item so they carry
590
643
  // its id; everything else just gains the thread/turn stamps.
591
644
  if (method === "item/agentMessage/delta" && pending?.emitAnswerDelta) {
@@ -607,12 +660,17 @@ export function createHandlers({
607
660
  */
608
661
  const applySettings = async (thread, params) => {
609
662
  const changed = (value, current) => (typeof value === "string" && value && value !== current ? value : null);
610
- const model = changed(params?.model, thread.model);
663
+ const model = changed(resolveModel(params?.model), thread.model);
611
664
  const effort = changed(params?.effort, thread.effort);
612
665
  const wanted = Object.fromEntries(Object.entries(requestedPermissions(params)).filter(([, value]) => value));
613
666
  const permissions = { ...thread.permissions, ...wanted };
614
667
  const permissionsChanged = JSON.stringify(permissions) !== JSON.stringify(thread.permissions ?? {});
615
- if (!model && !effort && !permissionsChanged) return;
668
+ // The named profile the client asked for, as thread/start records it: it
669
+ // is what the client matches against to see its change took effect.
670
+ const profile = typeof params?.permissions === "string" ? params.permissions : null;
671
+ const profileChanged = profile !== null && profile !== thread.permissionProfile;
672
+ if (profileChanged) thread.permissionProfile = profile;
673
+ if (!model && !effort && !permissionsChanged && !profileChanged) return;
616
674
  if (model) thread.model = model;
617
675
  if (effort) thread.effort = effort;
618
676
  if (permissionsChanged) thread.permissions = permissions;
@@ -690,7 +748,7 @@ export function createHandlers({
690
748
  async "thread/start"(params, ctx) {
691
749
  const thread = newThread({
692
750
  cwd: params?.cwd || process.cwd(),
693
- model: params?.model,
751
+ model: resolveModel(params?.model),
694
752
  modelProvider: params?.modelProvider,
695
753
  ephemeral: params?.ephemeral ?? false,
696
754
  mode: threadMode(),
@@ -724,9 +782,16 @@ export function createHandlers({
724
782
  },
725
783
 
726
784
  /** /model, /permissions: kept on the thread and applied from the next step. */
727
- async "thread/settings/update"(params) {
785
+ async "thread/settings/update"(params, ctx) {
728
786
  const thread = getThread(params?.threadId);
729
787
  await applySettings(thread, params);
788
+ // Always, even when nothing moved: the client is waiting to be told
789
+ // what is in force, and a request that changed nothing still answers
790
+ // that question. See threadSettings.
791
+ (ctx ?? thread.connection)?.emit?.("thread/settings/updated", {
792
+ threadId: thread.id,
793
+ threadSettings: threadSettings(thread),
794
+ });
730
795
  return {};
731
796
  },
732
797
 
@@ -1119,8 +1184,8 @@ export function textOf(input) {
1119
1184
  * — useful in tests, wrong in production, where the platform surface has to
1120
1185
  * reach the Rust app-server.
1121
1186
  */
1122
- export async function startAppServer({ socketPath, createEngineFor, codexHome, version, sandboxMode, threadStore = null, defaultModel = null, threadMode, forward = null, onError = () => {}, onTrace = null }) {
1123
- const handlers = createHandlers({ createEngineFor, codexHome, version, sandboxMode, threadStore, defaultModel, threadMode });
1187
+ export async function startAppServer({ socketPath, createEngineFor, codexHome, version, sandboxMode, threadStore = null, defaultModel = null, threadMode, resolveModel, onTurnError, forward = null, onError = () => {}, onTrace = null, onIdle = null }) {
1188
+ const handlers = createHandlers({ createEngineFor, codexHome, version, sandboxMode, threadStore, defaultModel, threadMode, resolveModel, onTurnError });
1124
1189
  // Ours are unknown to the Rust side: a thread id it would look up is dropped.
1125
1190
  const forwardPlatform = forward
1126
1191
  ? (method, params, ...context) => {
@@ -1212,6 +1277,9 @@ export async function startAppServer({ socketPath, createEngineFor, codexHome, v
1212
1277
  conn.onClose(() => {
1213
1278
  live.delete(conn);
1214
1279
  trace(`connection ${connId} closed`);
1280
+ // The client this server was started for is gone: whoever owns the
1281
+ // process decides whether it stays (see onIdle).
1282
+ if (live.size === 0) onIdle?.();
1215
1283
  for (const { resolve, timer } of pending.values()) {
1216
1284
  if (timer) clearTimeout(timer);
1217
1285
  resolve({ decision: "cancel" }); // the client is gone; cancel, not "maybe"
@@ -1223,6 +1291,8 @@ export async function startAppServer({ socketPath, createEngineFor, codexHome, v
1223
1291
 
1224
1292
  await transport.listen();
1225
1293
  return {
1294
+ /** How many clients are connected right now. */
1295
+ connections: () => live.size,
1226
1296
  ...transport,
1227
1297
  handlers,
1228
1298
  socketPath,
@@ -13,7 +13,7 @@ import { startAppServer } from "./server.mjs";
13
13
  import { createForwarder } from "./forward.mjs";
14
14
  import { createItemBridge } from "./items.mjs";
15
15
  import { createApprovalBridge } from "./approval.mjs";
16
- import { sandboxBin } from "../config.mjs";
16
+ import { loadCredentials, sandboxBin } from "../config.mjs";
17
17
 
18
18
  export const log = (...parts) => process.stderr.write(`${parts.join(" ")}\n`);
19
19
 
@@ -23,7 +23,11 @@ export const log = (...parts) => process.stderr.write(`${parts.join(" ")}\n`);
23
23
  * thread. `threadStore` (see unieai-thread-store.mjs) makes threads persistent.
24
24
  * `onShutdown` runs before the process exits.
25
25
  */
26
- export async function launchAppServer({ name, version, buildEngine, sandboxMode, threadStore = null, defaultModel = null, threadMode, onShutdown = async () => {} }) {
26
+ /** How long the server waits for another client before stopping. */
27
+ const IDLE_EXIT_MS = Number(process.env.UNIEAI_UAC_IDLE_EXIT_MS) || 15_000;
28
+
29
+ export async function launchAppServer({ name, version, buildEngine, sandboxMode, threadStore = null, defaultModel = null, threadMode, resolveModel, onShutdown = async () => {} }) {
30
+ let idleTimer = null;
27
31
  const codexHome = process.env.CODEX_HOME || process.env.UNIEAI_HOME || join(homedir(), ".unieai");
28
32
  const socketPath =
29
33
  process.env.UNIEAI_APP_SERVER_SOCKET || join(codexHome, "app-server-control", "app-server-control.sock");
@@ -54,8 +58,24 @@ export async function launchAppServer({ name, version, buildEngine, sandboxMode,
54
58
  threadStore,
55
59
  defaultModel,
56
60
  threadMode,
61
+ resolveModel,
62
+ // The server is this client's: when the client goes, so does it, unless
63
+ // another connects first (an engine switch or an update relaunches the
64
+ // client within seconds). Left running, it would serve the next client
65
+ // with another account's models and an older release's behaviour.
66
+ onIdle: () => {
67
+ clearTimeout(idleTimer);
68
+ idleTimer = setTimeout(() => {
69
+ if (server?.connections?.() === 0) {
70
+ log(`no client for ${IDLE_EXIT_MS}ms; stopping`);
71
+ shutdown();
72
+ }
73
+ }, IDLE_EXIT_MS);
74
+ idleTimer.unref?.();
75
+ },
57
76
  forward: (method, params) => forwarder.forward(method, params),
58
77
  onError: (error) => log("[app-server]", error.message),
78
+ onTurnError: (message, { threadId }) => log(`[turn] ${threadId} failed: ${message}`),
59
79
  onTrace: (line) => log("[trace]", line),
60
80
  createEngineFor: ({ cwd, model, effort, modelProvider, emit, request, sandboxMode: mode, ids, newItemId, resumeState, onState, clientTools, oneShot, mode: variant, permissions, onSteerDelivered, onGoalChanged, onEngineTurn, onSubagent, onChildActivity, askUser, onCompaction }) =>
61
81
  buildEngine({
@@ -113,6 +133,9 @@ export async function launchAppServer({ name, version, buildEngine, sandboxMode,
113
133
  const stamp = {
114
134
  pid: process.pid,
115
135
  cliVersion: process.env.UNIEAI_CLI_VERSION ?? null,
136
+ // Which account it serves: its models, threads and gateway are that
137
+ // account's, so a client signed in as someone else needs its own.
138
+ accountId: loadCredentials().accountId || null,
116
139
  engineVersion: version,
117
140
  socket: socketPath,
118
141
  startedAt: new Date().toISOString(),
@@ -24,13 +24,26 @@ export function loadCredentials() {
24
24
  gatewayBaseUrl: parsed.gateway_base_url,
25
25
  gatewayApiKey: parsed.gateway_api_key,
26
26
  studioUrl: parsed.studio_url,
27
+ // Which account this is, for a client to notice it changed (see
28
+ // accountId): never the key itself.
29
+ accountId: accountId(parsed),
27
30
  models
28
31
  };
29
32
  } catch {
30
- return { signedIn: false, gatewayBaseUrl: "", gatewayApiKey: "", studioUrl: "", models: [] };
33
+ return { signedIn: false, gatewayBaseUrl: "", gatewayApiKey: "", studioUrl: "", accountId: "", models: [] };
31
34
  }
32
35
  }
33
36
 
37
+ /**
38
+ * Who a credentials file signs in as: the product, the gateway it uses and
39
+ * the user. A long-running server started for one account serves models and
40
+ * threads that belong to it, so a client on another account must replace it.
41
+ */
42
+ export function accountId(parsed) {
43
+ if (!parsed) return "";
44
+ return [parsed.account ?? "studio", parsed.gateway_base_url ?? "", parsed.email ?? ""].join("|");
45
+ }
46
+
34
47
  /** Configure agent-core's upstream env for this process. */
35
48
  export function applyUpstreamEnv(credentials) {
36
49
  if (credentials.gatewayBaseUrl) process.env.AGENT_CORE_UPSTREAM_BASE_URL = credentials.gatewayBaseUrl;
@@ -17,13 +17,23 @@ export const ACP_PROTOCOL_VERSION = 1;
17
17
 
18
18
  export class AcpError extends Error {
19
19
  constructor(message, { code, data } = {}) {
20
- super(message);
20
+ // dsh answers an internal failure with "Internal error" and puts what
21
+ // actually went wrong in `data.details`. Dropping it left the user with
22
+ // a message that says nothing at all.
23
+ super(detailed(message, data));
21
24
  this.name = "AcpError";
22
25
  this.code = code;
23
26
  this.data = data;
24
27
  }
25
28
  }
26
29
 
30
+ /** `message`, with the detail dsh attached when it adds anything. */
31
+ function detailed(message, data) {
32
+ const details = typeof data?.details === "string" ? data.details.trim() : "";
33
+ if (!details || String(message).includes(details)) return String(message);
34
+ return `${message}: ${details}`;
35
+ }
36
+
27
37
  /**
28
38
  * Speak ACP over an already-open pair of streams. Split from the spawning so
29
39
  * tests can drive it with an in-memory agent.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unieai/code",
3
- "version": "0.0.30",
3
+ "version": "0.0.31",
4
4
  "description": "UnieAI Code is a coding agent that runs locally on your computer.",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -282,11 +282,11 @@
282
282
  "@deepseek-ai/schemastery": "3.18.2"
283
283
  },
284
284
  "optionalDependencies": {
285
- "@unieai/code-linux-x64": "npm:@unieai/code@0.0.30-linux-x64",
286
- "@unieai/code-linux-arm64": "npm:@unieai/code@0.0.30-linux-arm64",
287
- "@unieai/code-darwin-x64": "npm:@unieai/code@0.0.30-darwin-x64",
288
- "@unieai/code-darwin-arm64": "npm:@unieai/code@0.0.30-darwin-arm64",
289
- "@unieai/code-win32-x64": "npm:@unieai/code@0.0.30-win32-x64",
290
- "@unieai/code-win32-arm64": "npm:@unieai/code@0.0.30-win32-arm64"
285
+ "@unieai/code-linux-x64": "npm:@unieai/code@0.0.31-linux-x64",
286
+ "@unieai/code-linux-arm64": "npm:@unieai/code@0.0.31-linux-arm64",
287
+ "@unieai/code-darwin-x64": "npm:@unieai/code@0.0.31-darwin-x64",
288
+ "@unieai/code-darwin-arm64": "npm:@unieai/code@0.0.31-darwin-arm64",
289
+ "@unieai/code-win32-x64": "npm:@unieai/code@0.0.31-win32-x64",
290
+ "@unieai/code-win32-arm64": "npm:@unieai/code@0.0.31-win32-arm64"
291
291
  }
292
292
  }