@jskit-ai/assistant-core 0.1.235 → 0.1.237

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.235",
11
+ "@jskit-ai/assistant-core": "0.1.237",
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.235",
3
+ "version": "0.1.237",
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) {
@@ -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,
@@ -223,10 +223,21 @@ export function openCodePersistentConversationMessages(rows, conversationMessage
223
223
  });
224
224
  }
225
225
 
226
+ // The plugin refuses every native call this conversation was not granted. OpenCode
227
+ // returns each refusal to the model, which can keep retrying without ever answering.
228
+ export const OPENCODE_REFUSED_TOOL_ERROR = "This conversation has no authorization for that native tool.";
229
+ const OPENCODE_REFUSED_TOOL_LIMIT = 5;
230
+
226
231
  export async function projectOpenCodeConversationMessages(messages, expectedId, {
227
232
  maximumOutput, outputSchema, previous, onMessage
228
233
  }) {
229
- for (const message of openCodeRowsForInput(messages, expectedId)) {
234
+ const rows = openCodeRowsForInput(messages, expectedId);
235
+ const refused = rows.flatMap(message => message.content || []).filter(part =>
236
+ part.type === "tool" && part.state?.status === "error" && part.state.error === OPENCODE_REFUSED_TOOL_ERROR).length;
237
+ if (refused >= OPENCODE_REFUSED_TOOL_LIMIT) {
238
+ throw new Error("The model kept calling tools that this conversation does not provide and gave no answer. No tool ran. Your message is kept; retry or choose another model.");
239
+ }
240
+ for (const message of rows) {
230
241
  if (message.type !== "assistant" || message.summary || message.error) continue;
231
242
  const text = assistantMessageText(message);
232
243
  const thinking = (message.content || []).filter(part => part.type === "reasoning").map(part => part.text || "").join("\n\n");
@@ -243,7 +254,7 @@ export async function projectOpenCodeConversationMessages(messages, expectedId,
243
254
  }
244
255
  }
245
256
  }
246
- const result = openCodeLastAssistantResult(openCodeRowsForInput(messages, expectedId));
257
+ const result = openCodeLastAssistantResult(rows);
247
258
  return { failure: result.error, providerApiFailure: text(result.message?.error?.name) === "APIError" };
248
259
  }
249
260
 
@@ -442,13 +442,19 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
442
442
  : Math.max(0, initialWindow - (request?.input.origin === "user" ? 1 : 0)),
443
443
  maximumInitialMessageCharacters: limits.maxInitialNativeHistoryMessageCharacters,
444
444
  ...(budget ? { maximumHistoryBytes: async options => {
445
- await access(context, entry.id, request.operation, entry);
446
- signal.throwIfAborted();
447
- // A host may free native context first; show that as compacting.
448
- const bytes = await budget({ ...options, context, signal,
449
- setPhase: phase => setPhase(entry, phase === "compacting" ? "compacting" : "preparing") });
450
- signal.throwIfAborted();
451
- return bytes;
445
+ try {
446
+ await access(context, entry.id, request.operation, entry);
447
+ signal.throwIfAborted();
448
+ // A host may free native context first; show that as compacting.
449
+ const bytes = await budget({ ...options, context, signal,
450
+ setPhase: phase => setPhase(entry, phase === "compacting" ? "compacting" : "preparing") });
451
+ signal.throwIfAborted();
452
+ return bytes;
453
+ } catch (error) {
454
+ // Sizing missed history precedes native dispatch, so this message
455
+ // was definitely not sent. Hosts and clients report it as such.
456
+ throw Object.assign(error, { delivery: "not-sent" });
457
+ }
452
458
  } } : {}),
453
459
  presentation: entry.conversation?.presentation || { unconfirmedCode: "conversation_delivery_uncertain",
454
460
  unconfirmedMessage: "The previous submission has uncertain delivery. Retry this message to check its native receipt; it will not be resent automatically." },
@@ -4,7 +4,7 @@ import { createAiConnectionResolver } from "../../connectors-catalog/src/server/
4
4
  import { createAiConnectionClient } from "../src/server/lib/aiConnectionClient.js";
5
5
  import { createAiClient } from "../src/server/lib/aiClient.js";
6
6
 
7
- function publicConnection(model = "opencode/big-pickle") {
7
+ function publicConnection(model = "opencode/mimo-v2.5-free") {
8
8
  return createAiConnectionResolver({
9
9
  configuration: { schemaVersion: 1, registrations: {}, integrations: {
10
10
  assistant: { provider: "ai", accountMode: "shared", scopes: [], authentication: { method: "none" }, settings: { model } }
@@ -28,7 +28,7 @@ test("the configured public AI integration streams its first chunk before comple
28
28
  assert.equal(String(url), "https://opencode.ai/zen/v1/chat/completions");
29
29
  assert.equal(new Headers(options.headers).get("authorization"), "Bearer public");
30
30
  const body = JSON.parse(options.body);
31
- assert.equal(body.model, "big-pickle");
31
+ assert.equal(body.model, "mimo-v2.5-free");
32
32
  assert.equal(body.stream, true);
33
33
  return new Response(source.readable, { headers: { "content-type": "text/event-stream" } });
34
34
  }
@@ -2361,7 +2361,8 @@ test("bound Main cannot dispatch or retain a pending prompt when its history bud
2361
2361
  f.binding.transcript.history = async () => [{ id: "earlier", version: "v1", role: "user", text: "Earlier work." }];
2362
2362
  await f.binding.state.write({ lastEngine: "codex", engines: { codex: { seen: {} } } });
2363
2363
  f.binding.maximumHistoryBytes = async () => { throw new Error("Current native context usage unavailable"); };
2364
- await assert.rejects(f.conversation.send({ messageId: "no-budget", text: "Continue" }), /Current native context usage unavailable/u);
2364
+ await assert.rejects(f.conversation.send({ messageId: "no-budget", text: "Continue" }), error =>
2365
+ /Current native context usage unavailable/u.test(error.message) && error.delivery === "not-sent");
2365
2366
  assert.equal(f.processes.length, 0);
2366
2367
  assert.equal(f.messages.length, 0);
2367
2368
  assert.equal((await f.binding.state.read())?.engines?.claude?.pending, undefined);
@@ -842,7 +842,7 @@ test("Codex receives authorized image content and retains receipts through uncer
842
842
 
843
843
  function apiOptions() {
844
844
  return { connections: createAiConnectionResolver({ configuration: { schemaVersion: 1, registrations: {}, integrations: {
845
- assistant: { provider: "ai", accountMode: "shared", scopes: [], authentication: { method: "none" }, settings: { model: "opencode/big-pickle" } }
845
+ assistant: { provider: "ai", accountMode: "shared", scopes: [], authentication: { method: "none" }, settings: { model: "opencode/mimo-v2.5-free" } }
846
846
  } }, authorize: () => ({ applicationId: "test", subjectId: "owner" }) }), fetch: async () => {
847
847
  const chunk = { choices: [{ index: 0, delta: { content: "API answer" }, finish_reason: "stop" }] };
848
848
  return new Response(`data: ${JSON.stringify(chunk)}\n\n`, { headers: { "content-type": "text/event-stream" } });
@@ -1998,7 +1998,7 @@ test("a failed write while recording rejected steering can be recovered when the
1998
1998
  test("one logical conversation switches between Codex and API across restart without duplicating the displayed history", async t => {
1999
1999
  const requests = [];
2000
2000
  const connections = createAiConnectionResolver({ configuration: { schemaVersion: 1, registrations: {}, integrations: {
2001
- assistant: { provider: "ai", accountMode: "shared", scopes: [], authentication: { method: "none" }, settings: { model: "opencode/big-pickle" } }
2001
+ assistant: { provider: "ai", accountMode: "shared", scopes: [], authentication: { method: "none" }, settings: { model: "opencode/mimo-v2.5-free" } }
2002
2002
  } }, authorize: () => ({ applicationId: "test", subjectId: "owner" }) });
2003
2003
  const f = await fixture(t, { connections, fetch: async (_url, init) => {
2004
2004
  requests.push(JSON.parse(init.body));
@@ -0,0 +1,51 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "node:test";
6
+ import { createCodexAppServerRuntime } from "../src/server/conversation/codexProcess.js";
7
+
8
+ /** Starts a runtime whose execution host records the launch and reports failure. */
9
+ async function launchRequest(t, configuration, executionMode = "interactive") {
10
+ const runtimeDir = await mkdtemp(join(tmpdir(), "jskit-codex-"));
11
+ t.after(() => rm(runtimeDir, { recursive: true, force: true }));
12
+ let request;
13
+ const runtime = createCodexAppServerRuntime({
14
+ configuration,
15
+ credentials: { identity: async () => "account", generation: async () => "generation", assertCurrent: async () => {} },
16
+ execution: {
17
+ async run(value) {
18
+ request = value;
19
+ return { ok: false, output: "recorded launch" };
20
+ }
21
+ }
22
+ });
23
+ await assert.rejects(runtime.start({ codexCommand: process.execPath, executionMode, runtimeDir }), /recorded launch/);
24
+ return request;
25
+ }
26
+
27
+ test("the default startup script keeps the shared-file umask in launch and process profile", async (t) => {
28
+ for (const executionMode of ["interactive", "helper"]) {
29
+ const request = await launchRequest(t, {}, executionMode);
30
+ assert.match(request.args[1], /^umask 0007\n/);
31
+ assert.equal(request.processProfile.startup, request.args[1]);
32
+ }
33
+ });
34
+
35
+ test("an empty umask inherits the host's own and emits no umask line", async (t) => {
36
+ for (const executionMode of ["interactive", "helper"]) {
37
+ const request = await launchRequest(t, { umask: "" }, executionMode);
38
+ assert.doesNotMatch(request.args[1], /umask/);
39
+ assert.equal(request.processProfile.startup, request.args[1]);
40
+ }
41
+ assert.doesNotMatch((await launchRequest(t, { umask: null })).args[1], /umask/);
42
+ assert.match((await launchRequest(t, { umask: "0022" })).args[1], /^umask 0022\n/);
43
+ });
44
+
45
+ test("a non-octal umask is refused before it reaches the startup script", () => {
46
+ assert.throws(() => createCodexAppServerRuntime({
47
+ configuration: { umask: "0007; touch /tmp/x" },
48
+ credentials: {},
49
+ execution: {}
50
+ }), /umask must be octal/);
51
+ });
@@ -9,7 +9,7 @@ import { createAiConnectionResolver } from "../../connectors-catalog/src/server/
9
9
  const configuration = { systemPrompt: "Answer the current question.", integrationId: "assistant" };
10
10
  const connections = createAiConnectionResolver({
11
11
  configuration: { schemaVersion: 1, registrations: {}, integrations: {
12
- assistant: { provider: "ai", accountMode: "shared", scopes: [], authentication: { method: "none" }, settings: { model: "opencode/big-pickle" } }
12
+ assistant: { provider: "ai", accountMode: "shared", scopes: [], authentication: { method: "none" }, settings: { model: "opencode/mimo-v2.5-free" } }
13
13
  } }, authorize: () => ({ applicationId: "test", subjectId: "owner" })
14
14
  });
15
15
 
@@ -21,7 +21,7 @@ async function fixture(t, options = {}) {
21
21
  const storage = options.storage || createMemoryConversationStorage();
22
22
  const connections = createAiConnectionResolver({
23
23
  configuration: { schemaVersion: 1, registrations: {}, integrations: {
24
- assistant: { provider: "ai", accountMode: "shared", scopes: [], authentication: { method: "none" }, settings: { model: "opencode/big-pickle" } }
24
+ assistant: { provider: "ai", accountMode: "shared", scopes: [], authentication: { method: "none" }, settings: { model: "opencode/mimo-v2.5-free" } }
25
25
  } },
26
26
  authorize: actor => actor === context && state.modelAllowed ? actor : null
27
27
  });
@@ -96,10 +96,10 @@ test("saved history and current instructions use the existing authorized model c
96
96
 
97
97
  test("API configuration uses the common model and effort fields without overriding the authorized selection", async t => {
98
98
  const f = await fixture(t);
99
- await f.conversation.configure({ model: "big-pickle", effort: "high" });
99
+ await f.conversation.configure({ model: "mimo-v2.5-free", effort: "high" });
100
100
  await f.conversation.send(input);
101
101
  assert.equal((await f.conversation.wait()).conversationLog[0].metadata.runtime.status, "complete");
102
- assert.equal(f.requests[0].model, "big-pickle");
102
+ assert.equal(f.requests[0].model, "mimo-v2.5-free");
103
103
  assert.equal(f.requests[0].reasoning_effort, "high");
104
104
  await assert.rejects(f.conversation.configure({ effort: "arbitrary" }), /does not support/);
105
105
  await assert.rejects(f.conversation.configure({ systemPrompt: "a".repeat(128001) }), /configuration requires/);
@@ -0,0 +1,150 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import { randomUUID } from "node:crypto";
4
+ import { test } from "node:test";
5
+ import { createConversationProcessIdentity } from "../src/server/conversation/localExecution.js";
6
+
7
+ const TOKEN_ENV = "JSKIT_EXECUTION_RUNTIME_TOKEN";
8
+ const HASH_ENV = "JSKIT_EXECUTION_COMMAND_HASH";
9
+ const runtimeToken = "0b6c8f8e-3f7a-4c1e-9d2b-5a4e6f7a8b9c";
10
+ const commandHash = "abcdef012345";
11
+ // Process-group ids far above any live pid, so liveness probes find nothing.
12
+ const LEADER = 9_000_001;
13
+ const CHILD = 9_000_002;
14
+ const STARTED = "Wed Oct 21 10:00:00 2026";
15
+ const STARTED_SECONDS = String(Date.UTC(2026, 9, 21, 10, 0, 0) / 1000);
16
+ const uid = process.getuid?.() ?? 0;
17
+
18
+ const IDENTITY_ENV = `PATH=/usr/bin ${TOKEN_ENV}=${runtimeToken} ${HASH_ENV}=${commandHash}`;
19
+
20
+ function row(pid, pgid, { owner = uid, stat = "Ss", started = STARTED, args = "/usr/bin/codex app-server", env = IDENTITY_ENV } = {}) {
21
+ return { pid, pgid, owner, stat, started, args, env };
22
+ }
23
+
24
+ /** Renders the three darwin listings: every process, then the user's commands without and with `-E`. */
25
+ function psListing(rows, args) {
26
+ if (args[0] === "-ax") {
27
+ return rows.map(({ pid, pgid, owner, stat, started }) =>
28
+ `${String(pid).padStart(7)} ${String(pgid).padStart(7)} ${String(owner).padStart(5)} ${stat.padEnd(4)} ${started}`).join("\n");
29
+ }
30
+ const withEnvironment = args[0] === "-E";
31
+ return rows.filter(({ owner }) => owner === uid).map(({ pid, args: command, env }) =>
32
+ `${String(pid).padStart(7)} ${command}${withEnvironment && env ? ` ${env}` : ""}`).join("\n");
33
+ }
34
+
35
+ function darwinIdentity(table, captureOutput = `${LEADER} ${STARTED}`) {
36
+ const calls = [];
37
+ const identity = createConversationProcessIdentity({
38
+ platform: "darwin",
39
+ async ps(args) {
40
+ calls.push(args);
41
+ if (args.includes("-p")) return captureOutput;
42
+ if (table instanceof Error) throw table;
43
+ return psListing(table, args);
44
+ }
45
+ });
46
+ return { calls, identity };
47
+ }
48
+
49
+ const metadata = (overrides = {}) => ({
50
+ pid: LEADER,
51
+ processState: "running",
52
+ processIdentity: { commandHash, platform: "darwin", runtimeToken, startTimeTicks: STARTED_SECONDS, version: 1, ...overrides }
53
+ });
54
+
55
+ test("darwin capture records the group leader's ps start time as epoch seconds", async () => {
56
+ const { calls, identity } = darwinIdentity([]);
57
+ const captured = await identity.capture({ commandHash, pid: LEADER, runtimeToken });
58
+ assert.deepEqual(captured, { commandHash, platform: "darwin", runtimeToken, startTimeTicks: STARTED_SECONDS, version: 1 });
59
+ assert.deepEqual(calls, [["-o", "pgid=,lstart=", "-p", String(LEADER)]]);
60
+ assert.equal(identity.isValid(captured), true);
61
+ });
62
+
63
+ test("darwin capture parses the padded BSD ps `pgid= lstart=` line", async () => {
64
+ // BSD ps prints lstart with strftime("%c"), which space-pads single-digit days in the C locale.
65
+ const { identity } = darwinIdentity([], ` ${LEADER} Tue Oct 7 09:05:03 2026\n`);
66
+ const captured = await identity.capture({ commandHash, pid: LEADER, runtimeToken });
67
+ assert.equal(captured.startTimeTicks, String(Date.UTC(2026, 9, 7, 9, 5, 3) / 1000));
68
+ });
69
+
70
+ test("darwin capture refuses a non-leader, an unparsable line or a failed ps", async (t) => {
71
+ for (const [name, output] of [
72
+ ["another group", `${CHILD} ${STARTED}`],
73
+ ["a non-numeric group", `pgid ${STARTED}`],
74
+ ["an unknown start format", `${LEADER} 2026-10-21T10:00:00Z`],
75
+ ["empty output", ""]
76
+ ]) {
77
+ await t.test(name, async () => {
78
+ const { identity } = darwinIdentity([], output);
79
+ await assert.rejects(identity.capture({ commandHash, pid: LEADER, runtimeToken }),
80
+ { code: "native_process_identity_unavailable" });
81
+ });
82
+ }
83
+ });
84
+
85
+ test("darwin inspection is exact only when every group member carries the identity", async () => {
86
+ const { calls, identity } = darwinIdentity([
87
+ row(LEADER, LEADER),
88
+ row(CHILD, CHILD),
89
+ row(9_000_003, 9_000_003, { env: "" })
90
+ ]);
91
+ assert.deepEqual(await identity.inspect(metadata()), { processGroupIds: [LEADER, CHILD], status: "exact" });
92
+ assert.deepEqual(calls, [
93
+ ["-ax", "-o", "pid=,pgid=,uid=,stat=,lstart="],
94
+ ["-x", "-U", String(uid), "-ww", "-o", "pid=,command="],
95
+ ["-E", "-x", "-U", String(uid), "-ww", "-o", "pid=,command="]
96
+ ]);
97
+ });
98
+
99
+ test("darwin inspection keeps the absent, mismatch and ambiguous outcomes", async (t) => {
100
+ const cases = [
101
+ ["no process in the group", [], {}, "absent"],
102
+ ["zombies are ignored", [row(LEADER, LEADER, { stat: "Z" })], {}, "absent"],
103
+ ["an unrelated process reuses the group", [row(LEADER, LEADER, { env: "PATH=/usr/bin" })], {}, "mismatch"],
104
+ ["the token only in the arguments", [row(LEADER, LEADER, {
105
+ args: `/usr/bin/env -i ${TOKEN_ENV}=${runtimeToken} ${HASH_ENV}=${commandHash} codex`, env: "PATH=/usr/bin"
106
+ })], {}, "mismatch"],
107
+ ["a hidden environment", [row(LEADER, LEADER, { env: "" })], {}, "ambiguous"],
108
+ ["the token only in the arguments of a hidden environment", [row(LEADER, LEADER, {
109
+ args: `/usr/bin/env ${TOKEN_ENV}=${runtimeToken} ${HASH_ENV}=${commandHash} codex`, env: ""
110
+ })], {}, "ambiguous"],
111
+ ["another runtime token", [row(LEADER, LEADER)], { runtimeToken: randomUUID() }, "mismatch"],
112
+ ["the leader restarted", [row(LEADER, LEADER, { started: "Wed Oct 21 10:00:01 2026" })], {}, "ambiguous"],
113
+ ["another user's process shares the group", [row(LEADER, LEADER), row(CHILD, LEADER, { owner: uid + 1 })], {}, "ambiguous"],
114
+ ["another user's environment is not trusted", [row(LEADER, LEADER, { owner: uid + 1 })], {}, "ambiguous"],
115
+ ["an unmarked member shares the group", [row(LEADER, LEADER), row(CHILD, LEADER, { env: "PATH=/usr/bin" })], {}, "ambiguous"],
116
+ ["ps fails", new Error("ps unavailable"), {}, "ambiguous"],
117
+ ["a Linux identity", [row(LEADER, LEADER)], { platform: "linux-proc" }, "ambiguous"]
118
+ ];
119
+ for (const [name, table, overrides, status] of cases) {
120
+ await t.test(name, async () => {
121
+ const { identity } = darwinIdentity(table);
122
+ assert.equal((await identity.inspect(metadata(overrides))).status, status);
123
+ });
124
+ }
125
+ });
126
+
127
+ async function liveGroup(t, env) {
128
+ const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
129
+ detached: true, stdio: "ignore", env: { ...process.env, ...env }
130
+ });
131
+ await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
132
+ t.after(() => { try { process.kill(-child.pid, "SIGKILL"); } catch {} });
133
+ return child;
134
+ }
135
+
136
+ const nativePlatform = { linux: "linux-proc", darwin: "darwin" }[process.platform];
137
+
138
+ test("the host platform captures, inspects and stops a real owned process group", { skip: !nativePlatform }, async (t) => {
139
+ const token = randomUUID();
140
+ const child = await liveGroup(t, { [TOKEN_ENV]: token, [HASH_ENV]: commandHash });
141
+ const identity = createConversationProcessIdentity();
142
+ const captured = await identity.capture({ commandHash, pid: child.pid, runtimeToken: token });
143
+ assert.equal(captured.platform, nativePlatform);
144
+ const owned = { pid: child.pid, processState: "running", processIdentity: captured };
145
+ assert.deepEqual(await identity.inspect(owned), { processGroupIds: [child.pid], status: "exact" });
146
+ assert.equal((await identity.inspect({ ...owned, processIdentity: { ...captured, runtimeToken: randomUUID() } })).status, "mismatch");
147
+ const stopped = await identity.stop(owned, { termTimeoutMs: 2000 });
148
+ assert.equal(stopped.processExitVerified, true);
149
+ assert.deepEqual(stopped.signaledProcessGroups, [child.pid]);
150
+ });
@@ -146,6 +146,23 @@ test("a failed message retains its retry identity while later guidance is pendin
146
146
  assert.equal(delivery.state.sending, false);
147
147
  });
148
148
 
149
+ test("a failed client-only message keeps its time while newer saved turns and pending messages follow it", async () => {
150
+ const delivery = createAssistantMessageDelivery({ deliver: async () => ({ ok: false, error: "Unavailable" }) });
151
+ await delivery.send({ message: "Lost" }, { messageId: "lost" });
152
+ const sentAt = delivery.find("lost").createdAtMs;
153
+ const earlier = { turnId: "1", user: { messageId: "earlier", text: "Earlier", at: new Date(sentAt - 1000).toISOString() } };
154
+ const newer = { turnId: "2", user: { messageId: "newer", text: "Newer", at: new Date(sentAt + 1000).toISOString() }, pending: true };
155
+ assert.deepEqual(delivery.turns([earlier, newer]).map(turn => turn.turnId), ["1", "lost", "2"]);
156
+ assert.deepEqual(delivery.turns([earlier]).map(turn => turn.turnId), ["1", "lost"]);
157
+ const waiting = Promise.withResolvers();
158
+ const queued = createAssistantMessageDelivery({ deliver: () => waiting.promise });
159
+ const sending = queued.send({ message: "Queued" }, { messageId: "queued" });
160
+ assert.deepEqual(queued.turns([{ ...newer, user: { ...newer.user, at: new Date(Date.now() + 60000).toISOString() } }])
161
+ .map(turn => turn.turnId), ["2", "queued"]);
162
+ waiting.resolve({ ok: true });
163
+ await sending;
164
+ });
165
+
149
166
  test("reset discards queued delivery without waiting for the old transport", async () => {
150
167
  const request = Promise.withResolvers();
151
168
  const sent = [];
@@ -242,6 +242,16 @@ test("non-project common OpenCode tools stay behind approval and require the exe
242
242
  const guarded = openCodeConversationAgents({ sessionEnvironmentRegistry: "/private/sessions.json" });
243
243
  assert.equal(guarded["jskit-assistant"].permission["*"], "ask");
244
244
  assert.equal(guarded["jskit-assistant-actions"].permission["*"], "ask");
245
+ // OpenCode applies the agent's last matching rule: an agent without application
246
+ // tools must not be offered them, and no agent waits on a repeated-call approval.
247
+ for (const name of ["assistant_action_search", "assistant_action_contract", "assistant_action_execute"]) {
248
+ assert.equal(guarded["jskit-assistant"].permission[name], "deny");
249
+ assert.equal(guarded["jskit-assistant-actions"].permission[name], "allow");
250
+ assert.equal(Object.keys(guarded["jskit-assistant"].permission).indexOf(name) > 0, true);
251
+ }
252
+ assert.equal(guarded["jskit-assistant"].permission.doom_loop, "deny");
253
+ assert.equal(guarded["jskit-assistant-actions"].permission.doom_loop, "deny");
254
+ assert.equal(guarded["jskit-assistant-coding"].permission["*"], "deny");
245
255
  });
246
256
 
247
257
  test("common non-project OpenCode calls retain the original fail-closed execution guard", async t => {
@@ -194,6 +194,18 @@ async function fixture(t, options = {}) {
194
194
  session.messages.push(row("assistant", randomUUID(), [], { error: { data: { message: error.message } } })); save(); return;
195
195
  }
196
196
  }
197
+ if (text === "refused-tools") {
198
+ // A model that keeps retrying an ungranted native tool and never answers.
199
+ while (busy.has(session.id)) {
200
+ const part = { type: "tool", callID: randomUUID(), tool: "assistant_action_search", state: { status: "running", input: {} } };
201
+ session.messages.push(row("assistant", randomUUID(), [part], { parentID: body.messageID, finish: "tool-calls" }));
202
+ try { await hooks["tool.execute.before"]({ tool: part.tool, sessionID: session.id }, { args: {} }); }
203
+ catch (error) { part.state = { ...part.state, status: "error", error: error.message }; }
204
+ save();
205
+ await new Promise(resolve => setTimeout(resolve, 20));
206
+ }
207
+ return;
208
+ }
197
209
  if (text === "compact") {
198
210
  session.messages.push(row("user", randomUUID(), [{ type: "compaction", auto: true }]));
199
211
  session.messages.push(row("assistant", randomUUID(), [{ type: "text", text: "Native summary" }], { summary: true, finish: "stop" }));
@@ -327,7 +339,8 @@ test("OpenCode admits once, streams and saves native reasoning through the commo
327
339
  assert.equal(trace.find(item => item.url === "/api/session").body.model.id, "test-model");
328
340
  assert.equal(trace.find(item => item.url?.endsWith("/prompt_async")).body.model.modelID, "test-model");
329
341
  assert.equal(trace[0].inheritedSecret, null);
330
- assert.deepEqual(trace[0].configuration.agent[trace.find(item => item.url?.endsWith("/prompt_async")).body.agent].permission, { "*": "ask" });
342
+ assert.deepEqual(trace[0].configuration.agent[trace.find(item => item.url?.endsWith("/prompt_async")).body.agent].permission, { "*": "ask",
343
+ assistant_action_search: "deny", assistant_action_contract: "deny", assistant_action_execute: "deny", doom_loop: "deny" });
331
344
  assert.match(trace[0].configuration.plugin[0], /openCodePlugin\.js$/u);
332
345
  assert.deepEqual(trace[0].configuration.provider.test.models["test-model"].limit, { context: 32000, output: 4000 });
333
346
  assert.equal(trace[0].configuration.provider.test.models["test-model"].id, "test-model");
@@ -423,6 +436,17 @@ test("the production recovery stops after two reasoning-only completions through
423
436
  assert.equal(state.conversationLog[0].assistant, null);
424
437
  });
425
438
 
439
+ test("a model that keeps calling refused native tools fails visibly and stops its native turn", async t => {
440
+ const f = await fixture(t);
441
+ await f.conversation.send({ messageId: "client-message-refused-tools", text: "refused-tools" });
442
+ const state = await f.conversation.wait();
443
+ assert.equal(state.conversationLog.length, 1);
444
+ assert.equal(state.conversationLog[0].metadata.runtime.status, "failed");
445
+ assert.equal(state.error, "The model kept calling tools that this conversation does not provide and gave no answer. No tool ran. Your message is kept; retry or choose another model.");
446
+ assert.equal(state.conversationLog[0].assistant, null);
447
+ assert.ok((await f.trace()).some(row => row.url?.endsWith("/abort")), "The refused native loop is stopped");
448
+ });
449
+
426
450
  test("OpenCode steering follows the newly admitted input in the same native conversation", async t => {
427
451
  const f = await fixture(t);
428
452
  const started = Promise.withResolvers();
@@ -520,7 +544,7 @@ test("OpenCode shares discovery, saved results and current permissions across na
520
544
  assert.equal(executions, 1);
521
545
  const trace = await f.trace();
522
546
  assert.deepEqual(trace[0].configuration.agent[trace.find(item => item.url?.endsWith("/prompt_async")).body.agent].permission, { "*": "ask", assistant_action_search: "allow",
523
- assistant_action_contract: "allow", assistant_action_execute: "allow" });
547
+ assistant_action_contract: "allow", assistant_action_execute: "allow", doom_loop: "deny" });
524
548
  const original = await f.binding();
525
549
  await f.first.close();
526
550
  const resumed = await f.runtime().open({ id: "conversation", context });
@@ -1625,7 +1649,8 @@ test("completed OpenCode caller markers preserve ordinary native discovery witho
1625
1649
  assert.equal(ordinaryPrompt.body.agent, "jskit-assistant-actions");
1626
1650
  assert.match(ordinaryPrompt.body.parts[0].text, /\[Application data\]/);
1627
1651
  assert.deepEqual(initialTrace[0].configuration.agent[ordinaryPrompt.body.agent].permission, {
1628
- "*": "ask", assistant_action_search: "allow", assistant_action_contract: "allow", assistant_action_execute: "allow"
1652
+ "*": "ask", assistant_action_search: "allow", assistant_action_contract: "allow", assistant_action_execute: "allow",
1653
+ doom_loop: "deny"
1629
1654
  });
1630
1655
  await assert.rejects(f.prepare({ messageId: "ordinary-opencode", turnId: receipt.turnId }), /no current verified receipt/);
1631
1656
  // The original native fixture invokes discovery only for exact plain tools.