@jskit-ai/assistant-core 0.1.236 → 0.1.238

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.
@@ -29,9 +29,12 @@ The example uses JSKIT's existing `ai` integration configuration and authorized
29
29
  connection resolver. To call the explicitly selected model in `integrations.json`:
30
30
 
31
31
  ```sh
32
- ASSISTANT_INTEGRATIONS=./integrations.json npm run server
32
+ APP_AI_API_KEY=... ASSISTANT_INTEGRATIONS=./integrations.json npm run server
33
33
  ```
34
34
 
35
+ The sample selects Z.AI's GLM-4.7-Flash, which needs a regular Z.AI API key in
36
+ `APP_AI_API_KEY`. Choose your own provider, model and key; none is preselected.
37
+
35
38
  Edit that portable configuration through JSKIT's integration editor or its JSON
36
39
  contract. Shared keys use `env:NAME` references; the key stays in the server's
37
40
  normal environment. The example binds authorization to its one local user.
@@ -7,10 +7,11 @@
7
7
  "accountMode": "shared",
8
8
  "scopes": [],
9
9
  "settings": {
10
- "model": "opencode/big-pickle"
10
+ "model": "zai/glm-4.7-flash"
11
11
  },
12
12
  "authentication": {
13
- "method": "none"
13
+ "method": "api-key",
14
+ "secretRef": "env:APP_AI_API_KEY"
14
15
  }
15
16
  },
16
17
  "suggestions": {
@@ -18,10 +19,11 @@
18
19
  "accountMode": "shared",
19
20
  "scopes": [],
20
21
  "settings": {
21
- "model": "opencode/big-pickle"
22
+ "model": "zai/glm-4.7-flash"
22
23
  },
23
24
  "authentication": {
24
- "method": "none"
25
+ "method": "api-key",
26
+ "secretRef": "env:APP_AI_API_KEY"
25
27
  }
26
28
  }
27
29
  }
@@ -8,17 +8,17 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.1.236",
11
+ "@jskit-ai/assistant-core": "0.1.238",
12
12
  "@jskit-ai/http-runtime": "0.1.242",
13
13
  "@jskit-ai/kernel": "0.1.244",
14
14
  "@mdi/js": "^7.4.47",
15
15
  "vue": "^3.5.13",
16
16
  "vuetify": "^4.0.0",
17
17
  "@jskit-ai/connectors-core": "0.1.61",
18
- "@jskit-ai/connectors-catalog": "0.1.61"
18
+ "@jskit-ai/connectors-catalog": "0.1.62"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@vitejs/plugin-vue": "^6.0.7",
22
- "vite": "^8.2.1"
22
+ "vite": "^8.2.4"
23
23
  }
24
24
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.1.236",
3
+ "version": "0.1.238",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -59,7 +59,7 @@ function createAssistantMessageDelivery({ deliver: defaultDeliver } = {}) {
59
59
  }
60
60
 
61
61
  function turns(savedTurns = []) {
62
- return [
62
+ const shown = [
63
63
  ...savedTurns.map(turn => {
64
64
  const message = state.messages.find(message => message.status !== "accepted" &&
65
65
  turnMatchesOptimisticMessage(turn, message) &&
@@ -67,9 +67,19 @@ function createAssistantMessageDelivery({ deliver: defaultDeliver } = {}) {
67
67
  if (!message) return turn;
68
68
  const pending = optimisticTurn(message);
69
69
  return { ...turn, optimistic: pending.optimistic, ...(pending.system ? { system: pending.system } : {}) };
70
- }),
71
- ...unmatchedOptimisticMessages(savedTurns, state.messages).map(optimisticTurn)
70
+ })
72
71
  ];
72
+ // Pending and uncertain messages follow the saved history. A failed message
73
+ // never reached the conversation, so it keeps its own time: newer saved turns
74
+ // and the working indicator stay below it instead of looking blocked by it.
75
+ for (const message of unmatchedOptimisticMessages(savedTurns, state.messages)) {
76
+ const turn = optimisticTurn(message);
77
+ const later = message.status !== "failed" ? -1 : shown.findIndex(saved => !saved.optimistic &&
78
+ Date.parse((saved.user || saved.system)?.at || "") > message.createdAtMs);
79
+ if (later < 0) shown.push(turn);
80
+ else shown.splice(later, 0, turn);
81
+ }
82
+ return shown;
73
83
  }
74
84
 
75
85
  function optimisticTurn(message) {
@@ -593,21 +593,18 @@ export function createClaudeConversationOwner({
593
593
  deliveryTiming?.("process-ready");
594
594
  if (command && !command.steering) command.signal.throwIfAborted();
595
595
  const steering = command ? command.steering === true : Boolean(entry.turn?.active);
596
- const sendNow = steering && native.client.supportsSendNow?.() === true;
597
596
  if (input.signal?.aborted) throw Object.assign(createError("The goal command was stopped before delivery."), { delivery: "not-sent" });
598
597
  if (steering) {
599
598
  if (command) {
600
599
  await command.beforeDispatch({ threadId: entry.id });
601
600
  command.signal.throwIfAborted();
602
601
  }
603
- try {
604
- await entry.nativeTurn.interruptGeneration(command ? entry.process.client : native.client,
605
- { ...(command ? { timeoutMs: command.timeoutMs } : {}), sendNow });
606
- if (command) command.interrupted = !sendNow;
607
- } catch (error) {
602
+ // A mid-turn message is never an interrupt: Claude Code queues it and
603
+ // reads it after the running tool call. Only explicit Stop interrupts.
604
+ try { await entry.nativeTurn.prepareSteering(); }
605
+ catch (error) {
608
606
  if (!command) await entry.nativeTurn.stopProcess(error.message);
609
- // Interruption precedes this input's pipe write. Preserve that definite
610
- // non-delivery so a failed interrupt cannot strand an attempted receipt.
607
+ // This precedes the input's pipe write: a refused steer is definitely not sent.
611
608
  throw Object.assign(error, { delivery: "not-sent" });
612
609
  }
613
610
  }
@@ -688,7 +685,7 @@ export function createClaudeConversationOwner({
688
685
  else await input.onPromptSending?.({ threadId: entry.id, displayAttachments: input.displayAttachments, turnMetadata: actorMetadata });
689
686
  if (input.signal?.aborted) throw Object.assign(createError("The goal command was stopped before delivery."), { delivery: "not-sent" });
690
687
  await entry.nativeTurn.send(command ? entry.process.client : native.client, command ? preparation.message(entry, input) : prompt,
691
- { messageId: uuid, accept, sendNow, ...(command ? { timeoutMs: command.timeoutMs } : {}) });
688
+ { messageId: uuid, accept, ...(command ? { timeoutMs: command.timeoutMs } : {}) });
692
689
  } catch (error) {
693
690
  if (!command) await entry.nativeTurn.stopProcess(error.message);
694
691
  throw error;
@@ -1026,11 +1023,11 @@ export function createClaudeConversationOwner({
1026
1023
  active.steer = async ({ input, beforeDispatch, accept, onNativeReceipt, onReceiptPersisted, deliveryTiming }) => {
1027
1024
  await ready.promise;
1028
1025
  const nativeDispatch = { active, signal, beforeDispatch, accept, onNativeReceipt, onReceiptPersisted, deliveryTiming,
1029
- timeoutMs, steering: true, interrupted: false, attempted: false, admitted: false };
1026
+ timeoutMs, steering: true, attempted: false, admitted: false };
1030
1027
  try { await dispatch(control, { input, context, signal }, { acquire, entry, dispatch: nativeDispatch }); }
1031
1028
  catch (error) {
1032
1029
  if (nativeDispatch.attempted && !nativeDispatch.admitted && error.delivery !== "not-sent") error.delivery = "uncertain";
1033
- if (nativeDispatch.interrupted || nativeDispatch.attempted) completion.reject(error);
1030
+ if (nativeDispatch.attempted) completion.reject(error);
1034
1031
  throw error;
1035
1032
  }
1036
1033
  };
@@ -1142,6 +1139,9 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1142
1139
  const assistantRoles = new Map();
1143
1140
  const admissions = new Map();
1144
1141
  const inFlight = new Set();
1142
+ // Inputs Claude has begun reading. A queued steer joins the running loop, so
1143
+ // one result can finish several of them; queued-only input stays in flight.
1144
+ const startedInputs = new Set();
1145
1145
  const tasks = new Set();
1146
1146
  let turnId = "";
1147
1147
  let messageId = "";
@@ -1153,7 +1153,6 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1153
1153
  let generation = 0;
1154
1154
  let stopped = false;
1155
1155
  let cancelling = false;
1156
- let steering;
1157
1156
  let maximumOutput = 4 * 1024 * 1024;
1158
1157
  let inputOwner = null;
1159
1158
  let acceptedInput = null;
@@ -1418,11 +1417,13 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1418
1417
  if (frame.type === "user" && !inFlight.has(uuid) && !admissions.has(uuid)) return;
1419
1418
  // Native notifications have their own lifecycle UUIDs, including work
1420
1419
  // folded into this input. They must not steal its receipt/result owner.
1421
- if ((frame.state === "started" || frame.type === "user") && inFlight.has(uuid)) currentCommandId = uuid;
1422
- if (frame.state === "completed") inFlight.delete(uuid);
1423
- if (frame.state === "cancelled" && steering) {
1420
+ if ((frame.state === "started" || frame.type === "user") && inFlight.has(uuid)) {
1421
+ currentCommandId = uuid;
1422
+ startedInputs.add(uuid);
1423
+ }
1424
+ if (frame.state === "completed") {
1424
1425
  inFlight.delete(uuid);
1425
- steering.resolve();
1426
+ startedInputs.delete(uuid);
1426
1427
  }
1427
1428
  if (frame.state === "queued" || frame.type === "user") {
1428
1429
  const pending = admissions.get(uuid);
@@ -1559,10 +1560,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1559
1560
  } : {}) });
1560
1561
  }
1561
1562
  inFlight.delete(currentCommandId);
1562
- if (steering) {
1563
- steering.resolve();
1564
- return;
1565
- }
1563
+ for (const uuid of startedInputs) inFlight.delete(uuid);
1564
+ startedInputs.clear();
1566
1565
  if (cancelling) return;
1567
1566
  outcome = { status: interrupted ? "interrupted" : failed ? "failed" : "completed", error: message };
1568
1567
  // The reply is final, but native background tasks still own the turn
@@ -1601,6 +1600,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1601
1600
  }
1602
1601
  admissions.clear();
1603
1602
  inFlight.clear();
1603
+ startedInputs.clear();
1604
1604
  tasks.clear();
1605
1605
  // Verified process cleanup can precede Claude's terminal generation frame.
1606
1606
  // Retain already displayed text for the existing transcript reader, with
@@ -1616,7 +1616,6 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1616
1616
  }
1617
1617
  pendingAssistantMessages.clear();
1618
1618
  assistantRoles.clear();
1619
- steering?.reject(failure(reason));
1620
1619
  if (active) outcome = { status: "interrupted", error: reason };
1621
1620
  active = false;
1622
1621
  phase = "";
@@ -1670,7 +1669,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1670
1669
  acceptedInput = null;
1671
1670
  clearFinalAssistantResult();
1672
1671
  },
1673
- async send(client, message, { messageId: uuid, accept, timeoutMs = 30_000, sendNow = false } = {}) {
1672
+ async send(client, message, { messageId: uuid, accept, timeoutMs = 30_000 } = {}) {
1674
1673
  if (!active || stopped || cancelling) throw Object.assign(
1675
1674
  failure("Claude has no active turn for this message.", !active && !stopped && !cancelling
1676
1675
  ? "conversation_steer_turn_finished" : "assistant_claude_turn_failed"),
@@ -1691,7 +1690,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1691
1690
  )), timeoutMs);
1692
1691
  admissions.set(uuid, { ...admitted, accept, timer, receiptOwner });
1693
1692
  try {
1694
- await Promise.all([client.send(message, { messageId: uuid, sessionId: conversationId, ...(sendNow ? { sendNow: true } : {}) }), admitted.promise]);
1693
+ await Promise.all([client.send(message, { messageId: uuid, sessionId: conversationId }), admitted.promise]);
1695
1694
  } catch (error) {
1696
1695
  if (inputOwner === receiptOwner) invalidateFinalAssistantResult();
1697
1696
  throw error;
@@ -1699,49 +1698,22 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1699
1698
  clearTimeout(timer);
1700
1699
  }
1701
1700
  },
1702
- async interruptGeneration(client, { timeoutMs = 30_000, sendNow = false } = {}) {
1703
- if (!active || steering || cancelling) {
1704
- const completed = !active && !steering && !stopped && !cancelling && outcome?.status === "completed";
1701
+ async prepareSteering() {
1702
+ if (!active || cancelling) {
1703
+ const completed = !active && !stopped && !cancelling && outcome?.status === "completed";
1705
1704
  throw Object.assign(failure("This Claude turn cannot accept steering now.", completed
1706
1705
  ? "conversation_steer_turn_finished" : "assistant_claude_turn_failed"),
1707
1706
  { delivery: "not-sent", ...(completed ? { statusCode: 409 } : {}) });
1708
1707
  }
1709
- // Targeted Send now admits the new input before asking the native loop to
1710
- // make way; unlike plain Stop it preserves background agents. A completed
1711
- // main loop also has no further interruption result to wait for.
1712
- if (sendNow || outcome && inFlight.size === 0) {
1713
- // The next input owns completion now. A task finishing during host
1714
- // preparation must not settle this turn using the previous result.
1715
- outcome = null;
1716
- acceptedInput = null;
1717
- clearFinalAssistantResult();
1718
- await publish({ type: "diagnostic", stage: sendNow ? "steering_send_now" : "steering_background_only", backgroundTasks: tasks.size });
1719
- return;
1720
- }
1721
- // Streaming user input otherwise queues behind the entire running turn.
1722
- // Claude's supported steering path interrupts generation, then continues
1723
- // the same native history with the new instruction.
1708
+ // Claude Code acknowledges streamed input as queued and folds it into the
1709
+ // running turn after the current tool call, or starts it once the main
1710
+ // loop is idle. Interrupting instead would kill a running foreground tool.
1711
+ // The next input owns completion now: a result or task finishing before
1712
+ // it starts must not settle this turn with the previous answer.
1713
+ outcome = null;
1724
1714
  acceptedInput = null;
1725
1715
  clearFinalAssistantResult();
1726
- steering = Promise.withResolvers();
1727
- let timer;
1728
- try {
1729
- await publish({ type: "diagnostic", stage: "steering_requested", pendingCommands: inFlight.size, backgroundTasks: tasks.size });
1730
- await Promise.all([client.interrupt(), Promise.race([
1731
- steering.promise,
1732
- new Promise((_, reject) => {
1733
- timer = setTimeout(() => reject(Object.assign(failure(
1734
- "Claude did not confirm the interrupted generation. This instruction was not sent; retry after reconnecting.",
1735
- "assistant_claude_interrupt_timeout"
1736
- ), { statusCode: 503 })), timeoutMs);
1737
- })
1738
- ])]);
1739
- await publish({ type: "diagnostic", stage: "steering_confirmed", pendingCommands: inFlight.size, backgroundTasks: tasks.size });
1740
- } catch (error) {
1741
- await publish({ type: "diagnostic", stage: "steering_failed", pendingCommands: inFlight.size, backgroundTasks: tasks.size });
1742
- throw error;
1743
- }
1744
- finally { clearTimeout(timer); steering = null; }
1716
+ await publish({ type: "diagnostic", stage: "steering_queued", pendingCommands: inFlight.size, backgroundTasks: tasks.size });
1745
1717
  },
1746
1718
  async cancel(client, stopProcess) {
1747
1719
  try {
@@ -572,18 +572,23 @@ export function createCodexAppServerRuntime({ execution, credentials, configurat
572
572
  return acquireCodexRuntimeLock(runtimeDir, { ...options, busyCode: `${errorPrefix}codex_app_server_runtime_busy` });
573
573
  }
574
574
 
575
- const CODEX_APP_SERVER_MANAGED_UMASK = umask;
575
+ // An empty or null umask inherits the host's own; hosts that share files set one.
576
+ const startupUmask = normalizeText(umask);
577
+ if (startupUmask && !/^[0-7]{3,4}$/u.test(startupUmask)) {
578
+ throw new TypeError(`Codex app-server umask must be octal, not ${JSON.stringify(startupUmask)}.`);
579
+ }
580
+ const CODEX_APP_SERVER_UMASK_LINES = startupUmask ? [`umask ${startupUmask}`] : [];
576
581
 
577
582
  const CODEX_APP_SERVER_MANAGED_SHELL = "/bin/sh";
578
583
 
579
584
  const CODEX_APP_SERVER_MANAGED_STARTUP_SCRIPT = [
580
- `umask ${CODEX_APP_SERVER_MANAGED_UMASK}`,
585
+ ...CODEX_APP_SERVER_UMASK_LINES,
581
586
  `unset ${[...CODEX_APP_SERVER_DESKTOP_BUS_ENV_NAMES].join(" ")}`,
582
587
  'exec "$@"'
583
588
  ].join("\n");
584
589
 
585
590
  const CODEX_APP_SERVER_HELPER_STARTUP_SCRIPT = [
586
- `umask ${CODEX_APP_SERVER_MANAGED_UMASK}`,
591
+ ...CODEX_APP_SERVER_UMASK_LINES,
587
592
  "exec /usr/bin/env -i \\",
588
593
  ' HOME="$HOME" LOGNAME="$LOGNAME" USER="$USER" PATH="$PATH" \\',
589
594
  ' CODEX_HOME="$CODEX_HOME" LANG="$LANG" LC_ALL="$LC_ALL" LC_CTYPE="$LC_CTYPE" \\',
@@ -1,4 +1,4 @@
1
- import { spawn } from "node:child_process";
1
+ import { execFile, spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { closeSync, openSync } from "node:fs";
4
4
  import { mkdtemp, open, readFile, readdir, rm } from "node:fs/promises";
@@ -6,6 +6,18 @@ import { isPlainObject, normalizeText } from "./normalize.js";
6
6
  import { isAbsolute, join } from "node:path";
7
7
  import { tmpdir } from "node:os";
8
8
  import { setTimeout as delay } from "node:timers/promises";
9
+ import { promisify } from "node:util";
10
+
11
+ const execFileAsync = promisify(execFile);
12
+
13
+ // Fixed locale and zone make `lstart` parseable and stable across restarts.
14
+ async function runPs(args) {
15
+ const { stdout } = await execFileAsync("ps", args, {
16
+ env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
17
+ maxBuffer: 64 * 1024 * 1024
18
+ });
19
+ return stdout;
20
+ }
9
21
 
10
22
  /** Owned POSIX process groups for a trusted, single-user application.
11
23
  * Managed hosts replace this facility to enforce resource and identity policy.
@@ -170,12 +182,16 @@ export function createConversationProcessIdentity({
170
182
  runtimeTokenEnvironmentName = "JSKIT_EXECUTION_RUNTIME_TOKEN",
171
183
  commandHashEnvironmentName = "JSKIT_EXECUTION_COMMAND_HASH",
172
184
  label = "Native",
173
- unavailableCode = "native_process_identity_unavailable"
185
+ unavailableCode = "native_process_identity_unavailable",
186
+ platform = process.platform,
187
+ ps = runPs
174
188
  } = {}) {
175
189
  const PROCESS_IDENTITY_SETTLE_TIMEOUT_MS = 1000;
176
- const PROCESS_IDENTITY_SETTLE_POLL_MS = 25;
190
+ // Darwin inspection spawns ps instead of reading /proc, so it polls less often.
191
+ const PROCESS_IDENTITY_SETTLE_POLL_MS = platform === "darwin" ? 100 : 25;
177
192
  const PROCESS_IDENTITY_VERSION = 1;
178
- const PROCESS_IDENTITY_PLATFORM = "linux-proc";
193
+ const PROCESS_IDENTITY_PLATFORMS = Object.freeze({ linux: "linux-proc", darwin: "darwin" });
194
+ const hostIdentityPlatform = PROCESS_IDENTITY_PLATFORMS[platform] || "";
179
195
  const PROCESS_STATE = Object.freeze({ RUNNING: "running", STOPPED: "stopped" });
180
196
  const PROCESS_IDENTITY_STATUS = Object.freeze({
181
197
  ABSENT: "absent", AMBIGUOUS: "ambiguous", EXACT: "exact", INVALID: "invalid", MISMATCH: "mismatch"
@@ -281,21 +297,17 @@ export function createConversationProcessIdentity({
281
297
  const identity = normalizeProcessIdentity(value);
282
298
  return Boolean(
283
299
  identity.version === PROCESS_IDENTITY_VERSION &&
284
- identity.platform === PROCESS_IDENTITY_PLATFORM &&
300
+ Object.values(PROCESS_IDENTITY_PLATFORMS).includes(identity.platform) &&
285
301
  /^[a-f0-9]{12}$/u.test(identity.commandHash) &&
286
302
  /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(identity.runtimeToken) &&
287
303
  /^\d+$/u.test(identity.startTimeTicks)
288
304
  );
289
305
  }
290
306
 
291
- function linuxProcessEnvironmentValue(buffer = Buffer.alloc(0), name = "") {
292
- const prefix = `${name}=`;
293
- for (const entry of buffer.toString("utf8").split("\0")) {
294
- if (entry.startsWith(prefix)) {
295
- return entry.slice(prefix.length);
296
- }
297
- }
298
- return "";
307
+ /** The value of `name` in a list of NAME=value environment entries. */
308
+ function environmentValue(entries = [], name = "") {
309
+ const entry = entries.find((assignment) => assignment.startsWith(`${name}=`));
310
+ return entry ? entry.slice(name.length + 1) : "";
299
311
  }
300
312
 
301
313
  async function linuxProcessRecords() {
@@ -323,11 +335,80 @@ export function createConversationProcessIdentity({
323
335
  }
324
336
  environmentReadable = false;
325
337
  }
338
+ const entries = environmentReadable ? environment.toString("utf8").split("\0") : [];
326
339
  records.push({
327
340
  ...processStat,
328
- environment,
341
+ commandHash: environmentValue(entries, commandHashEnvironmentName),
329
342
  environmentReadable,
330
- pid
343
+ pid,
344
+ runtimeToken: environmentValue(entries, runtimeTokenEnvironmentName)
345
+ });
346
+ }
347
+ return records;
348
+ }
349
+
350
+ const DARWIN_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
351
+
352
+ /** `ps -o lstart=` in the C locale and UTC, as whole epoch seconds. */
353
+ function darwinStartTime(value = "") {
354
+ const match = /^\w{3}\s+(\w{3})\s+(\d{1,2})\s+(\d{2}):(\d{2}):(\d{2})\s+(\d{4})$/u.exec(String(value).trim());
355
+ const month = match ? DARWIN_MONTHS.indexOf(match[1]) : -1;
356
+ if (month < 0) {
357
+ return "";
358
+ }
359
+ const [, , day, hours, minutes, seconds, year] = match.map(Number);
360
+ return String(Date.UTC(year, month, day, hours, minutes, seconds) / 1000);
361
+ }
362
+
363
+ function darwinCommandsByPid(output = "") {
364
+ const commands = new Map();
365
+ for (const line of String(output).split("\n")) {
366
+ const match = /^\s*(\d+) (.*)$/u.exec(line);
367
+ if (match) {
368
+ commands.set(Number(match[1]), match[2]);
369
+ }
370
+ }
371
+ return commands;
372
+ }
373
+
374
+ /** The `ps -E` text after the argument list, as its assignments, or null when no environment is shown. */
375
+ function darwinEnvironment(command, commandWithEnvironment) {
376
+ if (command === undefined || !commandWithEnvironment?.startsWith(command)) {
377
+ return null;
378
+ }
379
+ const assignments = commandWithEnvironment.slice(command.length).split(/\s+/u).filter((entry) => /^[^=\s]+=/u.test(entry));
380
+ return assignments.length ? assignments : null;
381
+ }
382
+
383
+ /** The darwin counterpart of the /proc scan. Only the current user's environments are readable;
384
+ * a listing without a separate environment section stays unreadable so inspection degrades to ambiguous. */
385
+ async function darwinProcessRecords() {
386
+ const currentUid = process.getuid();
387
+ const ownProcesses = ["-x", "-U", String(currentUid), "-ww", "-o", "pid=,command="];
388
+ const [table, commandOutput, environmentOutput] = await Promise.all([
389
+ ps(["-ax", "-o", "pid=,pgid=,uid=,stat=,lstart="]),
390
+ ps(ownProcesses),
391
+ ps(["-E", ...ownProcesses])
392
+ ]);
393
+ const commands = darwinCommandsByPid(commandOutput);
394
+ const commandsWithEnvironment = darwinCommandsByPid(environmentOutput);
395
+ const records = [];
396
+ for (const line of String(table).split("\n")) {
397
+ const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\w{3}\s+\w{3}\s+\d{1,2}\s+[\d:]{8}\s+\d{4})\s*$/u.exec(line);
398
+ if (!match || match[4].startsWith("Z")) {
399
+ continue;
400
+ }
401
+ const pid = Number(match[1]);
402
+ const environment = Number(match[3]) === currentUid
403
+ ? darwinEnvironment(commands.get(pid), commandsWithEnvironment.get(pid))
404
+ : null;
405
+ records.push({
406
+ commandHash: environmentValue(environment || [], commandHashEnvironmentName),
407
+ environmentReadable: Boolean(environment),
408
+ pid,
409
+ processGroupId: Number(match[2]),
410
+ runtimeToken: environmentValue(environment || [], runtimeTokenEnvironmentName),
411
+ startTimeTicks: darwinStartTime(match[5])
331
412
  });
332
413
  }
333
414
  return records;
@@ -347,7 +428,7 @@ export function createConversationProcessIdentity({
347
428
  };
348
429
  }
349
430
 
350
- async function inspectLinuxProcessIdentity(metadata = {}) {
431
+ async function inspectHostProcessIdentity(metadata = {}) {
351
432
  if (!processMetadataIsIdentifiable(metadata)) {
352
433
  return {
353
434
  processGroupIds: [],
@@ -360,14 +441,18 @@ export function createConversationProcessIdentity({
360
441
  status: PROCESS_IDENTITY_STATUS.ABSENT
361
442
  };
362
443
  }
363
- if (process.platform !== "linux") {
444
+ const identity = metadata.processIdentity;
445
+ const records = identity.platform !== hostIdentityPlatform
446
+ ? null
447
+ : platform === "darwin"
448
+ ? await darwinProcessRecords().catch(() => null)
449
+ : await linuxProcessRecords();
450
+ if (!records) {
364
451
  return {
365
452
  processGroupIds: [],
366
453
  status: PROCESS_IDENTITY_STATUS.AMBIGUOUS
367
454
  };
368
455
  }
369
- const identity = metadata.processIdentity;
370
- const records = await linuxProcessRecords();
371
456
  const rootProcessGroupId = Number(metadata.pid);
372
457
  const rootGroupMembers = records.filter(({ processGroupId }) => processGroupId === rootProcessGroupId);
373
458
  const exactRecords = [];
@@ -376,15 +461,7 @@ export function createConversationProcessIdentity({
376
461
  if (!record.environmentReadable) {
377
462
  continue;
378
463
  }
379
- const runtimeToken = linuxProcessEnvironmentValue(
380
- record.environment,
381
- runtimeTokenEnvironmentName
382
- );
383
- const commandHash = linuxProcessEnvironmentValue(
384
- record.environment,
385
- commandHashEnvironmentName
386
- );
387
- if (runtimeToken === identity.runtimeToken && commandHash === identity.commandHash) {
464
+ if (record.runtimeToken === identity.runtimeToken && record.commandHash === identity.commandHash) {
388
465
  exactRecords.push(record);
389
466
  }
390
467
  }
@@ -438,7 +515,7 @@ export function createConversationProcessIdentity({
438
515
  async function inspectProcessIdentity(metadata = {}, options = {}) {
439
516
  const inspector = typeof options.processIdentityInspector === "function"
440
517
  ? options.processIdentityInspector
441
- : inspectLinuxProcessIdentity;
518
+ : inspectHostProcessIdentity;
442
519
  return normalizeProcessIdentityInspection(await inspector(metadata));
443
520
  }
444
521
 
@@ -610,6 +687,15 @@ export function createConversationProcessIdentity({
610
687
  };
611
688
  }
612
689
 
690
+ async function captureProcessStart(pid) {
691
+ if (platform !== "darwin") {
692
+ return linuxProcessStat(await readFile(`/proc/${pid}/stat`, "utf8").catch(() => ""));
693
+ }
694
+ const output = String(await ps(["-o", "pgid=,lstart=", "-p", String(pid)]).catch(() => ""));
695
+ const match = /^\s*(\d+)\s+(.+?)\s*$/u.exec(output);
696
+ return match ? { processGroupId: Number(match[1]), startTimeTicks: darwinStartTime(match[2]) } : null;
697
+ }
698
+
613
699
  async function captureProcessIdentity({
614
700
  commandHash = "",
615
701
  pid = null,
@@ -627,26 +713,17 @@ export function createConversationProcessIdentity({
627
713
  return reported;
628
714
  }
629
715
  const normalizedPid = Number(pid);
630
- if (
631
- process.platform !== "linux" ||
632
- !Number.isSafeInteger(normalizedPid) ||
633
- normalizedPid <= 0
634
- ) {
635
- const error = new Error(`${label} process identity could not be captured.`);
636
- error.code = unavailableCode;
637
- throw error;
638
- }
639
- const processStat = linuxProcessStat(
640
- await readFile(`/proc/${normalizedPid}/stat`, "utf8").catch(() => "")
641
- );
642
- if (!processStat || processStat.processGroupId !== normalizedPid) {
716
+ const processStat = hostIdentityPlatform && Number.isSafeInteger(normalizedPid) && normalizedPid > 0
717
+ ? await captureProcessStart(normalizedPid)
718
+ : null;
719
+ if (!processStat?.startTimeTicks || processStat.processGroupId !== normalizedPid) {
643
720
  const error = new Error(`${label} process identity could not be captured.`);
644
721
  error.code = unavailableCode;
645
722
  throw error;
646
723
  }
647
724
  return {
648
725
  commandHash: expectedCommandHash,
649
- platform: PROCESS_IDENTITY_PLATFORM,
726
+ platform: hostIdentityPlatform,
650
727
  runtimeToken: expectedRuntimeToken,
651
728
  startTimeTicks: processStat.startTimeTicks,
652
729
  version: PROCESS_IDENTITY_VERSION
@@ -1,5 +1,6 @@
1
1
  import { createOpenCodeConversationAdapter } from "./providers/opencode.js";
2
2
  import { wrapNativeCommand } from "./commandWrapper.js";
3
+ import { OPENCODE_REFUSED_TOOL_ERROR } from "./openCodeTurn.js";
3
4
  import { readOpenCodeEnvironments, openCodeEnvironmentForSession, openCodeEnvironmentForDirectory,
4
5
  assertOpenCodeModelProvider, limitOpenCodeModelOutput } from "./openCodeRuntime.js";
5
6
 
@@ -44,7 +45,7 @@ export default async function conversationInstructions({ client, registryPath =
44
45
  ? conversation?.tools?.schemas.some(({ function: definition }) => definition.name === tool)
45
46
  : conversation?.nativeTools;
46
47
  if (!allowed) {
47
- throw new Error("This conversation has no authorization for that native tool.");
48
+ throw new Error(OPENCODE_REFUSED_TOOL_ERROR);
48
49
  }
49
50
  if (tool === "bash" && conversation.commandWrapper) {
50
51
  output.args.command = wrapNativeCommand(conversation.commandWrapper, output.args.command);
@@ -70,7 +71,7 @@ export default async function conversationInstructions({ client, registryPath =
70
71
  }
71
72
  const connection = (await readBinding(context.sessionID)).conversation?.tools;
72
73
  if (!connection?.schemas.some(({ function: definition }) => definition.name === name)) {
73
- throw new Error("This conversation has no authorization for that native tool.");
74
+ throw new Error(OPENCODE_REFUSED_TOOL_ERROR);
74
75
  }
75
76
  const response = await fetch(connection.url, { method: "POST", signal: context.abort,
76
77
  headers: { authorization: `Bearer ${connection.token}`, "content-type": "application/json" },
@@ -118,7 +118,7 @@ async function waitForOpenCodeReady({
118
118
  throw error;
119
119
  }
120
120
 
121
- const actionPermission = Object.fromEntries(DISCOVERY_TOOL_DESCRIPTORS.map(tool => [tool.name, "allow"]));
121
+ const actionPermission = action => Object.fromEntries(DISCOVERY_TOOL_DESCRIPTORS.map(tool => [tool.name, action]));
122
122
  const nativePermission = { bash: "allow", read: "allow", write: "allow", edit: "allow",
123
123
  glob: "allow", grep: "allow", list: "allow", patch: "allow", apply_patch: "allow", todoread: "allow", todowrite: "allow" };
124
124
  export const openCodeApplicationToolSchemas = DISCOVERY_TOOL_DESCRIPTORS.map(({ name, description, parameters }) => ({
@@ -136,12 +136,20 @@ export function openCodeConversationAgents({ sessionEnvironmentRegistry = "" } =
136
136
  hidden: true, mode: "primary",
137
137
  // Keep native definitions behind approval, as in the original host agents.
138
138
  // The registered plugin rejects every ungranted native call before execution.
139
- permission: nativeTools || tools || guarded ? { "*": !nativeTools && guarded ? "ask" : "deny",
140
- ...(nativeTools ? nativePermission : {}), ...(tools ? actionPermission : {}) } : "deny"
139
+ permission: nativeTools || tools || guarded ? agentPermission(!nativeTools && guarded, nativeTools, tools) : "deny"
141
140
  }
142
141
  ])));
143
142
  }
144
143
 
144
+ // OpenCode applies an agent's last matching rule, so its "*": "ask" overrides the
145
+ // global policy. Restate two denials after it: application tools the agent does not
146
+ // own are not offered to the model, and the repeated-call check does not wait for
147
+ // an approval that no unattended conversation can give.
148
+ function agentPermission(ask, nativeTools, tools) {
149
+ return { "*": ask ? "ask" : "deny", ...(nativeTools ? nativePermission : {}),
150
+ ...(tools ? actionPermission("allow") : ask ? actionPermission("deny") : {}), ...(ask ? { doom_loop: "deny" } : {}) };
151
+ }
152
+
145
153
  /** Shared conversation configuration around the extracted server lifecycle. */
146
154
  export async function createOpenCodeConversationServer({
147
155
  command = "opencode", execution = createLocalConversationExecution(), env = process.env,