@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.
- package/examples/conversation/README.md +4 -1
- package/examples/conversation/integrations.json +6 -4
- package/examples/conversation/package.json +3 -3
- package/package.json +1 -1
- package/src/client/conversation/messageDelivery.js +13 -3
- package/src/server/conversation/claudeTurn.js +32 -60
- package/src/server/conversation/codexProcess.js +8 -3
- package/src/server/conversation/localExecution.js +120 -43
- package/src/server/conversation/openCodePlugin.js +3 -2
- package/src/server/conversation/openCodeProcess.js +11 -3
- package/src/server/conversation/openCodeTurn.js +13 -2
- package/test/aiConnectionClient.test.js +2 -2
- package/test/claudeConversationSession.test.js +4 -4
- package/test/claudeTurn.test.js +120 -82
- package/test/codexConversationSession.test.js +2 -2
- package/test/codexProcess.test.js +51 -0
- package/test/conversationReplacement.test.js +1 -1
- package/test/conversationSession.test.js +3 -3
- package/test/localExecution.test.js +150 -0
- package/test/messageDelivery.test.js +17 -0
- package/test/openCodeTools.test.js +10 -0
- package/test/opencodeConversationSession.test.js +28 -3
|
@@ -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": "
|
|
10
|
+
"model": "zai/glm-4.7-flash"
|
|
11
11
|
},
|
|
12
12
|
"authentication": {
|
|
13
|
-
"method": "
|
|
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": "
|
|
22
|
+
"model": "zai/glm-4.7-flash"
|
|
22
23
|
},
|
|
23
24
|
"authentication": {
|
|
24
|
-
"method": "
|
|
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.
|
|
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.
|
|
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.
|
|
22
|
+
"vite": "^8.2.4"
|
|
23
23
|
}
|
|
24
24
|
}
|
package/package.json
CHANGED
|
@@ -59,7 +59,7 @@ function createAssistantMessageDelivery({ deliver: defaultDeliver } = {}) {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
function turns(savedTurns = []) {
|
|
62
|
-
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
//
|
|
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,
|
|
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,
|
|
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.
|
|
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))
|
|
1422
|
-
|
|
1423
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1563
|
-
|
|
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
|
|
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
|
|
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
|
|
1703
|
-
if (!active ||
|
|
1704
|
-
const completed = !active && !
|
|
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
|
-
//
|
|
1710
|
-
//
|
|
1711
|
-
//
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
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,
|
|
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 ?
|
|
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,
|