agents-can-communicate 0.8.3 → 0.8.5
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/bin/acc-update-worker.mjs +4 -1
- package/bin/entrypoints/acc-mcp.mjs +3 -0
- package/bin/entrypoints/acc.mjs +5 -0
- package/docs/ADAPTER_AUTHORING.md +3 -0
- package/docs/ARCHITECTURE.md +7 -2
- package/docs/CLI.md +7 -1
- package/docs/CONCEPTS.md +2 -2
- package/docs/CONFIGURATION.md +3 -2
- package/docs/GETTING_STARTED.md +12 -9
- package/docs/HOW_IT_WORKS.md +2 -1
- package/docs/TROUBLESHOOTING.md +18 -2
- package/docs/UPGRADING.md +35 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +7 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +24 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/embedded-host.mjs +108 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +16 -6
- package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +2 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +33 -9
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +61 -2
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-discovery.mjs +82 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/service-setup.mjs +59 -5
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +2 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt.mjs +6 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +17 -7
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +12 -2
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +2 -1
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +19 -2
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +25 -8
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/install.mjs +13 -3
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +19 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +53 -3
- package/node_modules/@agents-can-communicate/cli/src/native-delivery-status.mjs +5 -1
- package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +7 -1
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/src/native-reason.mjs +22 -0
- package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +19 -3
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +6 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +6 -3
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +123 -10
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +11 -1
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +3 -0
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/package.json +1 -1
|
@@ -9,6 +9,7 @@ import { stageOwnGeneration } from "./generation.mjs";
|
|
|
9
9
|
import { writeLaunchers } from "./launchers.mjs";
|
|
10
10
|
import { canonicalManagerRoot, readControl, writeControl } from "./state.mjs";
|
|
11
11
|
import { scheduleWorker } from "./schedule.mjs";
|
|
12
|
+
import { reclaimAsActive } from "./worker.mjs";
|
|
12
13
|
import { releaseStagingHold } from "./staging.mjs";
|
|
13
14
|
import { withManagerLock } from "./mutex.mjs";
|
|
14
15
|
import { retireManagedHolds } from "./retire.mjs";
|
|
@@ -36,7 +37,8 @@ export async function installManaged({ packageRoot, managerRoot, dataHome, home,
|
|
|
36
37
|
// writes below) or abandoned (any throw between here and there).
|
|
37
38
|
try {
|
|
38
39
|
await verifyGeneration(candidate, { env });
|
|
39
|
-
|
|
40
|
+
let ready = null;
|
|
41
|
+
const installed = await withManagerLock(root, async () => {
|
|
40
42
|
const previous = await readControl(root);
|
|
41
43
|
if (previous !== null && previous.home !== home) {
|
|
42
44
|
throw new Error("this ACC data home manages another client home; use a separate ACC_DATA_HOME");
|
|
@@ -67,13 +69,21 @@ export async function installManaged({ packageRoot, managerRoot, dataHome, home,
|
|
|
67
69
|
const integrationFailed = result.failed.some(failure => failure.stage !== "service-setup"
|
|
68
70
|
|| !result.operations?.some(operation => operation.adapterId === failure.adapterId && operation.applied === true));
|
|
69
71
|
if (!integrationFailed) {
|
|
70
|
-
|
|
71
|
-
await scheduleWorker(root, ready, { env });
|
|
72
|
+
ready = await writeControl(root, { ...control, phase: "ready", pending: null });
|
|
72
73
|
} else {
|
|
73
74
|
await writeControl(root, { ...control, notice: "Integration refresh is incomplete; run acc update to recover." });
|
|
74
75
|
}
|
|
75
76
|
return result;
|
|
76
77
|
});
|
|
78
|
+
if (ready !== null) {
|
|
79
|
+
// This install runs the code of the generation it activated, so its
|
|
80
|
+
// reclaim is that generation's rule (#208). It takes the manager lock
|
|
81
|
+
// itself, hence after the fence; a worker is then started only for work
|
|
82
|
+
// that is actually due.
|
|
83
|
+
await reclaimAsActive(root, candidate.root);
|
|
84
|
+
await scheduleWorker(root, ready, { env });
|
|
85
|
+
}
|
|
86
|
+
return installed;
|
|
77
87
|
} finally {
|
|
78
88
|
await releaseStagingHold(candidate.hold);
|
|
79
89
|
}
|
|
@@ -4,9 +4,27 @@ import { confirmedDead } from "./mutex.mjs";
|
|
|
4
4
|
import { readManagedJson } from "./state.mjs";
|
|
5
5
|
import { checkDue, networkDisabled } from "./policy.mjs";
|
|
6
6
|
|
|
7
|
+
// The active generation has not yet reclaimed with its own rule (#208): the
|
|
8
|
+
// worker records `reclaim.json` naming the root it reclaimed as. Local work, so
|
|
9
|
+
// neither the update opt-out nor the no-network override holds it back. An
|
|
10
|
+
// unreadable marker is not a reason to start a worker on every entry.
|
|
11
|
+
// A pass an unknown holder postponed is retried, at most hourly, so a holder
|
|
12
|
+
// that never becomes readable cannot start a worker on every entry.
|
|
13
|
+
const RECLAIM_RETRY_MS = 60 * 60_000;
|
|
14
|
+
async function reclaimDue(root, control) {
|
|
15
|
+
let marker;
|
|
16
|
+
try { marker = await readManagedJson(path.join(root, "reclaim.json")); } catch { return false; }
|
|
17
|
+
if (marker?.activeRoot !== control.active.root) return true;
|
|
18
|
+
if (marker.complete !== false) return false;
|
|
19
|
+
const attempted = Date.parse(marker.attemptedAt);
|
|
20
|
+
return !Number.isFinite(attempted) || Date.now() - attempted >= RECLAIM_RETRY_MS;
|
|
21
|
+
}
|
|
22
|
+
|
|
7
23
|
/** No network waits here: this only starts an independent background process. */
|
|
8
24
|
export async function scheduleWorker(root, control, { env = process.env } = {}) {
|
|
9
|
-
if (!control?.
|
|
25
|
+
if (!control?.active) return false;
|
|
26
|
+
const update = control.auto && !networkDisabled(env) && (control.pending || checkDue(control));
|
|
27
|
+
if (!update && !await reclaimDue(root, control)) return false;
|
|
10
28
|
try {
|
|
11
29
|
for (const directory of [path.join(root, "worker"), path.join(root, "worker", "poller")]) {
|
|
12
30
|
const owner = await readManagedJson(path.join(directory, "manager.lock", "owner.json"));
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { activatePending, reclaimGenerations } from "./activation.mjs";
|
|
5
|
+
import { listRuntimeHolds } from "./leases.mjs";
|
|
2
6
|
import { downloadRelease, fetchRelease, newerVersion } from "./download.mjs";
|
|
3
7
|
import { withManagerLock } from "./mutex.mjs";
|
|
4
|
-
import { readControl, writeControl } from "./state.mjs";
|
|
8
|
+
import { readControl, writeControl, writeManagedJson } from "./state.mjs";
|
|
5
9
|
import { releaseStagingHold } from "./staging.mjs";
|
|
6
10
|
import { MAINTENANCE_ACTIVE, maintenanceNotice, maintenanceReport, readMaintenance } from "./maintenance-state.mjs";
|
|
7
11
|
import { requestMaintenance } from "./maintenance.mjs";
|
|
@@ -62,8 +66,54 @@ export async function performUpdate(root, { force = false, check = false, env =
|
|
|
62
66
|
return { ...checked, pending: generation.version, ...await activate(root, { env, ignorePid }) };
|
|
63
67
|
}
|
|
64
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Reclaim with this generation's own rule, when it is the active one (#208).
|
|
71
|
+
*
|
|
72
|
+
* An update is run by the older installed code, so the reclaim right after an
|
|
73
|
+
* activation applies the older rule, and a fix to that rule used to take
|
|
74
|
+
* effect only one release later. The active generation's worker runs this on
|
|
75
|
+
* its first pass, which the scheduler starts for a generation that has not
|
|
76
|
+
* recorded it yet, and on every later pass. A reclaim that finds an unknown
|
|
77
|
+
* holder still postpones itself; the next pass tries again.
|
|
78
|
+
*/
|
|
79
|
+
export const RECLAIM_MARKER = "reclaim.json";
|
|
80
|
+
export async function reclaimAsActive(root, generationRoot) {
|
|
81
|
+
if (typeof generationRoot !== "string") return;
|
|
82
|
+
try {
|
|
83
|
+
const control = await readControl(root);
|
|
84
|
+
if (control === null || await realpath(generationRoot) !== await realpath(control.active.root)) return;
|
|
85
|
+
const result = await reclaimGenerations({ root });
|
|
86
|
+
// The process that activated this generation still runs from the one it
|
|
87
|
+
// replaced, and its own lease keeps that generation through this pass -
|
|
88
|
+
// measured in the 0.8.5 upgrade preflight. That pass is unfinished, so the
|
|
89
|
+
// hourly retry removes the old generation once the process has exited. A
|
|
90
|
+
// worker started at once would race every command right after an update.
|
|
91
|
+
const active = await realpath(control.active.root);
|
|
92
|
+
const own = await listRuntimeHolds(root).then(async holds => {
|
|
93
|
+
for (const hold of holds) {
|
|
94
|
+
if (hold.pid === process.pid && await realpath(hold.runtime.root).catch(() => null) !== active) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}, () => false);
|
|
100
|
+
// A postponed pass is recorded as unfinished too, so the scheduler tries
|
|
101
|
+
// again (hourly) even with automatic updates off.
|
|
102
|
+
await writeManagedJson(path.join(root, RECLAIM_MARKER), { schemaVersion: 1,
|
|
103
|
+
activeRoot: control.active.root, complete: result?.postponed !== true && !own,
|
|
104
|
+
attemptedAt: new Date().toISOString() });
|
|
105
|
+
} catch { /* Best effort; the next pass reclaims again. */ }
|
|
106
|
+
}
|
|
107
|
+
|
|
65
108
|
/** One poller may wait; the update lock stays available between its passes. */
|
|
66
|
-
export async function runWorker(root, { force = false, env = process.env, wait = false, ignorePid = null
|
|
109
|
+
export async function runWorker(root, { force = false, env = process.env, wait = false, ignorePid = null,
|
|
110
|
+
generationRoot = null } = {}) {
|
|
111
|
+
const result = await pass(root, { force, env, wait, ignorePid });
|
|
112
|
+
await reclaimAsActive(root, generationRoot);
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function pass(root, { force, env, wait, ignorePid }) {
|
|
67
117
|
const poll = async () => {
|
|
68
118
|
for (;;) {
|
|
69
119
|
const result = await withManagerLock(`${root}/worker`,
|
|
@@ -43,7 +43,11 @@ export function nativeRemediation(entry) {
|
|
|
43
43
|
}
|
|
44
44
|
if (native.reasonCode === "native_endpoint_unavailable") {
|
|
45
45
|
if (["blocked", "unsupported"].includes(service?.state)) steps.push(service.diagnostic);
|
|
46
|
-
else if (service?.state === "needed" &&
|
|
46
|
+
else if (service?.state === "needed" && service.startsOnLaunch === true) {
|
|
47
|
+
// The client starts it itself when its next session opens.
|
|
48
|
+
steps.push(`${entry.displayName}: its local delivery service starts with the next `
|
|
49
|
+
+ `${entry.displayName} session; open one, then run acc doctor here`);
|
|
50
|
+
} else if (service?.state === "needed" && native.configured) {
|
|
47
51
|
steps.push(service.requiresInstall && entry.deliveryDecision?.installPrerequisites === false
|
|
48
52
|
? `acc install --adapter ${entry.adapterId} --delivery ${native.policy === "all" ? "all" : "actionable"}`
|
|
49
53
|
+ " # allow the official Codex download and complete service setup"
|
|
@@ -104,6 +104,9 @@ const failures = {
|
|
|
104
104
|
session_generation_stale: "the session was replaced during the handshake",
|
|
105
105
|
workspace_identity_unavailable: "the bound client session is no longer in this workspace; "
|
|
106
106
|
+ "start a new client session here",
|
|
107
|
+
// It stays embedded until it closes; a later service never adopts it.
|
|
108
|
+
client_session_embedded: "the client runs this session on its own embedded service, which "
|
|
109
|
+
+ "peers cannot reach; start a new client session while its local delivery service runs",
|
|
107
110
|
};
|
|
108
111
|
|
|
109
112
|
function describeAttempt(attempt) {
|
|
@@ -117,7 +120,10 @@ function describeAttempt(attempt) {
|
|
|
117
120
|
: attempt.policyStatus === "unavailable" ? `${source} delivery policy could not be read`
|
|
118
121
|
: `${source} delivery policy was off`;
|
|
119
122
|
} else if (attempt.state === "active") detail = "local handshake succeeded";
|
|
120
|
-
else
|
|
123
|
+
else if (attempt.reasonCode === "client_session_embedded" && attempt.launchOption) {
|
|
124
|
+
detail = `${describeNativeReason(attempt.reasonCode, { launchOption: attempt.launchOption })}; `
|
|
125
|
+
+ `start a new client session without ${attempt.launchOption}`;
|
|
126
|
+
} else detail = failures[attempt.reasonCode] ?? describeNativeReason(attempt.reasonCode);
|
|
121
127
|
return `last attempt ${attempt.at}: ${detail}`;
|
|
122
128
|
}
|
|
123
129
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { listSessionBindings, loadNativeAttempt } from "@agents-can-communicate/adapter-sdk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Why a session has no live transport, as its own last native attempt said:
|
|
5
|
+
* `{ reasonCode, launchOption? }`, the option being the client launch option
|
|
6
|
+
* that attempt named as the cause.
|
|
7
|
+
*
|
|
8
|
+
* The same record `acc doctor` reads, found through the hook-owner file that
|
|
9
|
+
* names this exact session generation. Null when none says: an attempt that
|
|
10
|
+
* succeeded, a record for another generation, or no runtime to read.
|
|
11
|
+
*/
|
|
12
|
+
export async function lastNativeReason({ runtimeDir, sessionId, generation }) {
|
|
13
|
+
if (typeof runtimeDir !== "string") return null;
|
|
14
|
+
const owners = (await listSessionBindings({ runtimeDir }))
|
|
15
|
+
.filter(owner => owner.accSessionId === sessionId && owner.generation === generation);
|
|
16
|
+
if (owners.length !== 1) return null;
|
|
17
|
+
const attempt = await loadNativeAttempt({ runtimeDir,
|
|
18
|
+
harnessSessionId: owners[0].harnessSessionId, accSessionId: sessionId, generation });
|
|
19
|
+
if (attempt === null || attempt.state === "active") return null;
|
|
20
|
+
return { reasonCode: attempt.reasonCode,
|
|
21
|
+
...(attempt.launchOption ? { launchOption: attempt.launchOption } : {}) };
|
|
22
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { evaluateVersionContract }
|
|
1
|
+
import { NATIVE_REASON_CODES, evaluateVersionContract, isLaunchOption }
|
|
2
|
+
from "@agents-can-communicate/adapter-sdk";
|
|
2
3
|
|
|
4
|
+
import { lastNativeReason } from "./native-reason.mjs";
|
|
3
5
|
import { refreshExpiredBinding } from "./refresh-binding.mjs";
|
|
4
6
|
|
|
5
7
|
const SAFE_ERRORS = new Set(["ambiguous_recipient_sessions", "delivery_disabled",
|
|
@@ -45,9 +47,23 @@ function safeTransport(value, opaqueEndpointRef) {
|
|
|
45
47
|
return opaqueEndpointRef === "live-adapter" ? "native-live" : "live-adapter";
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
export function createDeliveryRouter({ service, adapters, clock, readLivePolicy
|
|
50
|
+
export function createDeliveryRouter({ service, adapters, clock, readLivePolicy,
|
|
51
|
+
readNativeReason = input => lastNativeReason({ runtimeDir: service.store?.root, ...input }) }) {
|
|
49
52
|
const registry = adaptersById(adapters);
|
|
50
53
|
|
|
54
|
+
// A session that bound no live transport may have said why: the reason its
|
|
55
|
+
// own last native attempt recorded, from the closed vocabulary only, and the
|
|
56
|
+
// client launch option behind it when the attempt named one.
|
|
57
|
+
async function withoutLiveTransport(participantId, target) {
|
|
58
|
+
const found = await Promise.resolve()
|
|
59
|
+
.then(() => readNativeReason({ sessionId: target.sessionId, generation: target.generation }))
|
|
60
|
+
.catch(() => null);
|
|
61
|
+
const reason = found?.reasonCode;
|
|
62
|
+
if (!NATIVE_REASON_CODES.includes(reason)) return durable(participantId, "no_live_transport");
|
|
63
|
+
return { ...durable(participantId, "no_live_transport"), nativeReasonCode: reason,
|
|
64
|
+
...(isLaunchOption(found.launchOption) ? { nativeLaunchOption: found.launchOption } : {}) };
|
|
65
|
+
}
|
|
66
|
+
|
|
51
67
|
// Read at offer time, not from the binding: consent withdrawn after a session
|
|
52
68
|
// bound must stop the next offer, not the next rebind.
|
|
53
69
|
async function policyFor(adapter, binding) {
|
|
@@ -94,7 +110,7 @@ export function createDeliveryRouter({ service, adapters, clock, readLivePolicy
|
|
|
94
110
|
&& binding.generation === target.generation);
|
|
95
111
|
// Online, with nothing to push through: a CLI participant, or a client
|
|
96
112
|
// that bound no live transport. It reads its inbox, so it is not gone.
|
|
97
|
-
if (bindings.length === 0) return
|
|
113
|
+
if (bindings.length === 0) return withoutLiveTransport(participantId, target);
|
|
98
114
|
const evaluated = await Promise.all(bindings.map(async binding => {
|
|
99
115
|
const adapter = registry.get(binding.adapterId);
|
|
100
116
|
return { binding, adapter, policy: await policyFor(adapter, binding) };
|
|
@@ -45,6 +45,7 @@ export async function bindNative({ adapter, event, hookBinding, clientVersion, p
|
|
|
45
45
|
reasonCode: result.reasonCode, policy, policySource, policyStatus,
|
|
46
46
|
clientProcess: Number.isInteger(hookBinding.clientPid) && hookBinding.clientPid > 0
|
|
47
47
|
? "identified" : "unknown",
|
|
48
|
+
...(result.launchOption === undefined ? {} : { launchOption: result.launchOption }),
|
|
48
49
|
} }).catch(() => {});
|
|
49
50
|
}
|
|
50
51
|
return result;
|
|
@@ -57,6 +58,7 @@ function asHint(value) {
|
|
|
57
58
|
if (typeof value === "string") return { line: value, release: async () => {} };
|
|
58
59
|
if (value !== null && typeof value === "object" && typeof value.line === "string") {
|
|
59
60
|
return { line: value.line,
|
|
61
|
+
...(usableHint(value.userMessage) ? { userMessage: value.userMessage } : {}),
|
|
60
62
|
release: typeof value.release === "function" ? value.release : async () => {} };
|
|
61
63
|
}
|
|
62
64
|
if (value !== null && typeof value === "object" && typeof value.release === "function") {
|
|
@@ -66,7 +68,8 @@ function asHint(value) {
|
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
function usableHint(line) {
|
|
69
|
-
return line !== "" && !/[\r\n]/.test(line)
|
|
71
|
+
return typeof line === "string" && line !== "" && !/[\r\n]/.test(line)
|
|
72
|
+
&& Buffer.byteLength(line, "utf8") <= HINT_MAX_BYTES;
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
// An adapter may release with a synchronous function. Calling `.catch` on its
|
|
@@ -91,6 +94,8 @@ export function callRelease(release) {
|
|
|
91
94
|
* A string has nothing reserved. `{ line, release }` has reserved an ask; this
|
|
92
95
|
* function calls `release` when it drops the line, including when the adapter
|
|
93
96
|
* answers after the budget. The caller releases a line it does not deliver.
|
|
97
|
+
* `{ line, userMessage }` also asks for a line the user sees and the model
|
|
98
|
+
* does not, held to the same bound; one that does not fit is dropped alone.
|
|
94
99
|
*/
|
|
95
100
|
export async function nativeActivationHintFor({ adapter, event, nativeBinding, binding, context,
|
|
96
101
|
paths, deadline }) {
|
|
@@ -37,8 +37,11 @@ function discardEndpoint(adapter, handshake, runtimeDir) {
|
|
|
37
37
|
export async function establishNativeBinding({ adapter, event, hookBinding, clientVersion,
|
|
38
38
|
platform, livePolicy, service, runtimeDir, clock, env,
|
|
39
39
|
timeoutMs = DEFAULT_TIMEOUT_MS, heartbeatCadenceMs = DEFAULT_CADENCE_MS }) {
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
// A refusal may name the client launch option behind it; see
|
|
41
|
+
// validateNativeHandshake, which admits only a bare option token.
|
|
42
|
+
const outcome = (state, reasonCode, modes = [], launchOption = null) =>
|
|
43
|
+
Object.freeze({ state, reasonCode, modes: Object.freeze([...modes]),
|
|
44
|
+
...(launchOption === null ? {} : { launchOption }) });
|
|
42
45
|
const sessionId = hookBinding?.accSessionId;
|
|
43
46
|
const generation = hookBinding?.generation;
|
|
44
47
|
if (typeof sessionId !== "string" || typeof generation !== "string") return outcome("off", null);
|
|
@@ -80,7 +83,7 @@ export async function establishNativeBinding({ adapter, event, hookBinding, clie
|
|
|
80
83
|
if (!verdict.ok) {
|
|
81
84
|
await discardEndpoint(adapter, handshake, runtimeDir);
|
|
82
85
|
return outcome(STATIC_REASONS.has(verdict.reasonCode) ? "unsupported" : "degraded",
|
|
83
|
-
verdict.reasonCode);
|
|
86
|
+
verdict.reasonCode, [], verdict.launchOption ?? null);
|
|
84
87
|
}
|
|
85
88
|
const now = Date.parse(clock.now());
|
|
86
89
|
const lease = Date.parse(verdict.leaseUntil);
|
|
@@ -5,12 +5,14 @@ import path from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
|
|
7
7
|
import { capabilityEvidence, clearNativeAttempt, clearSessionBinding, effectiveCapabilities,
|
|
8
|
-
loadSessionBinding, storeSessionBinding }
|
|
8
|
+
loadSessionBinding, normalizedEvent, storeSessionBinding }
|
|
9
9
|
from "@agents-can-communicate/adapter-sdk";
|
|
10
10
|
import { createCoordinationService } from "@agents-can-communicate/core";
|
|
11
|
+
import { loadOwnership } from "@agents-can-communicate/installer";
|
|
11
12
|
import { AccError, createId } from "@agents-can-communicate/protocol";
|
|
12
13
|
import { openFilesystemStore } from "@agents-can-communicate/storage-filesystem";
|
|
13
|
-
import { clearPin, createGitProbe, resolveHookWorkspace, platformDataHome, runtimePaths,
|
|
14
|
+
import { clearPin, createGitProbe, resolveHookWorkspace, platformDataHome, runtimePaths,
|
|
15
|
+
writePin as writeRuntimePin }
|
|
14
16
|
from "@agents-can-communicate/cli";
|
|
15
17
|
|
|
16
18
|
import { resolveClientPid } from "./client-pid.mjs";
|
|
@@ -236,8 +238,8 @@ async function openContext({ event, adapterId, dataHome, runtime, env, deadline
|
|
|
236
238
|
// offered. Runs after the heartbeat and the bounded native retry.
|
|
237
239
|
// `render` is how the projected text reaches this client: before a turn it is
|
|
238
240
|
// the adapter's context envelope, at the end of one it is a continuation.
|
|
239
|
-
async function projectTurn({ binding, context, adapter, adapterId,
|
|
240
|
-
|
|
241
|
+
async function projectTurn({ binding, context, adapter, adapterId, render = null,
|
|
242
|
+
activationHint = null, activationNotice = null, repeats = true }) {
|
|
241
243
|
const sync = await context.service.sync({ sessionId: binding.accSessionId,
|
|
242
244
|
cursor: null, scope: "delta" });
|
|
243
245
|
|
|
@@ -281,11 +283,17 @@ async function projectTurn({ binding, context, adapter, adapterId,
|
|
|
281
283
|
const owner = hintFits ? `${header}\n${activationHint}` : header;
|
|
282
284
|
const withHint = (outcome, attached) => ({ ...outcome,
|
|
283
285
|
activationHintIncluded: attached === true && outcome.stdout !== "" });
|
|
286
|
+
// The ask's line for the user travels with its line for the model, or not
|
|
287
|
+
// at all: the adapter shows it where the client shows a hook's message.
|
|
288
|
+
const notice = hintFits && activationNotice !== null ? { userMessage: activationNotice } : null;
|
|
289
|
+
const inject = text => notice === null ? adapter.injectOutcome?.(text)
|
|
290
|
+
: adapter.injectOutcome?.(text, notice);
|
|
291
|
+
const renderText = render ?? inject;
|
|
284
292
|
// A peer can join after this prompt has begun. The current turn must already
|
|
285
293
|
// have its own arguments when it needs inbox/reply, without reattaching or
|
|
286
294
|
// waiting for another user prompt. Solo emits identity, not a peer notice.
|
|
287
295
|
if (sync.solo && messages.length === 0) {
|
|
288
|
-
return withHint(ownerOnlyOutcome(
|
|
296
|
+
return withHint(ownerOnlyOutcome(inject, owner, totalBudget),
|
|
289
297
|
hintFits);
|
|
290
298
|
}
|
|
291
299
|
|
|
@@ -334,7 +342,7 @@ async function projectTurn({ binding, context, adapter, adapterId,
|
|
|
334
342
|
// Own claims make sync non-solo but produce no peer context. They cannot
|
|
335
343
|
// remove this turn's identity or consume a nonexistent body separator.
|
|
336
344
|
if (body === "" && messages.length === 0) {
|
|
337
|
-
return withHint(ownerOnlyOutcome(
|
|
345
|
+
return withHint(ownerOnlyOutcome(inject, owner, totalBudget),
|
|
338
346
|
hintFits);
|
|
339
347
|
}
|
|
340
348
|
const projected = body === "" ? "" : ownerFits ? `${owner}\n${body}` : body;
|
|
@@ -359,7 +367,7 @@ async function projectTurn({ binding, context, adapter, adapterId,
|
|
|
359
367
|
// The entry point owns the transport boundary. This handler only prepares
|
|
360
368
|
// offer inputs; recording them here would claim delivery before stdout's
|
|
361
369
|
// callback proves that the bytes crossed.
|
|
362
|
-
const outcome = { stdout: "", ...
|
|
370
|
+
const outcome = { stdout: "", ...renderText(projected) };
|
|
363
371
|
const writableOffers = outcome.stdout === "" ? [] : offerInputs;
|
|
364
372
|
return withHint({ ...outcome,
|
|
365
373
|
stderr: [outcome.stderr, ownerWarning, degradation].filter(Boolean).join("\n"),
|
|
@@ -371,7 +379,8 @@ async function projectTurn({ binding, context, adapter, adapterId,
|
|
|
371
379
|
// so the reservation goes with it.
|
|
372
380
|
async function projectActivation(input, offered) {
|
|
373
381
|
try {
|
|
374
|
-
const turn = await projectTurn({ ...input, activationHint: offered?.line ?? null
|
|
382
|
+
const turn = await projectTurn({ ...input, activationHint: offered?.line ?? null,
|
|
383
|
+
activationNotice: offered?.userMessage ?? null });
|
|
375
384
|
const included = turn.activationHintIncluded === true;
|
|
376
385
|
delete turn.activationHintIncluded;
|
|
377
386
|
if (!offered) return turn;
|
|
@@ -388,11 +397,16 @@ async function projectActivation(input, offered) {
|
|
|
388
397
|
}
|
|
389
398
|
|
|
390
399
|
const HANDLERS = {
|
|
400
|
+
// `knownClientPid` and `pins: false` are registration's (#167): it runs in
|
|
401
|
+
// a peer's process, so the client's pid is handed over rather than found in
|
|
402
|
+
// this process's ancestry, and it pins nothing because no hook code of any
|
|
403
|
+
// generation runs for the session until its first real hook pins its own.
|
|
391
404
|
async sessionStart({ event, context, adapter, adapterId, binding, paths,
|
|
392
|
-
readProcessTable, probeClientVersion, platform, deadline }) {
|
|
405
|
+
readProcessTable, probeClientVersion, platform, deadline, knownClientPid, pins = true }) {
|
|
393
406
|
// Where this session's pin lives, resolved once and reused at every write
|
|
394
407
|
// below. Beside `paths.root`, never inside it: see `managerRootFor`.
|
|
395
408
|
const pinRoot = managerRootFor(context.dataHome);
|
|
409
|
+
const writePin = pins === false ? async () => {} : writeRuntimePin;
|
|
396
410
|
// A repeated start refreshes the client's version/platform. Remove the old
|
|
397
411
|
// certified facts before any probe, PID lookup, resume, or open can fail;
|
|
398
412
|
// keep only the generation identity needed for a successful resume.
|
|
@@ -414,7 +428,8 @@ const HANDLERS = {
|
|
|
414
428
|
// null, and the session is then judged by age alone - which is exactly the
|
|
415
429
|
// behaviour every session had before this existed.
|
|
416
430
|
const command = adapter.client?.command ?? null;
|
|
417
|
-
const pid =
|
|
431
|
+
const pid = Number.isInteger(knownClientPid) && knownClientPid > 0 ? knownClientPid
|
|
432
|
+
: command === null ? null
|
|
418
433
|
: resolveClientPid({ table: await readProcessTable({
|
|
419
434
|
timeoutMs: Math.max(1, Math.min(1_000, deadline - Date.now())) }), from: process.pid, command });
|
|
420
435
|
assertHookBudget(deadline);
|
|
@@ -767,3 +782,101 @@ export async function runHook({ adapterId, payload, adapters, dataHome, env,
|
|
|
767
782
|
clearTimeout(timer);
|
|
768
783
|
}
|
|
769
784
|
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Register a client session before its first turn (#167).
|
|
788
|
+
*
|
|
789
|
+
* Exactly what SessionStart writes - binding, session, native binding and its
|
|
790
|
+
* diagnostic - under the same per-session lifecycle lock, so the chat's first
|
|
791
|
+
* real hook finds the binding and resumes this session instead of opening a
|
|
792
|
+
* second one. The caller is a peer's process, so the client's pid is handed
|
|
793
|
+
* over, the caller's own ACC_PARTICIPANT never names the chat, and nothing is
|
|
794
|
+
* pinned or printed. A session that already has a binding is left alone.
|
|
795
|
+
* Never throws: status must not fail because of it.
|
|
796
|
+
*/
|
|
797
|
+
export async function registerNativeSession({ adapterId, adapters, event: target, clientPid,
|
|
798
|
+
workspaceId, dataHome, env = {}, runtime = defaultRuntime(), budgetMs = DEFAULT_BUDGET_MS,
|
|
799
|
+
probeClientVersion = defaultProbeClientVersion, platform = `${process.platform}-${process.arch}` }) {
|
|
800
|
+
const deadline = Date.now() + budgetMs;
|
|
801
|
+
const skipped = reason => ({ registered: false, reason });
|
|
802
|
+
const register = async () => {
|
|
803
|
+
const adapter = adapters?.[adapterId];
|
|
804
|
+
if (adapter === undefined || !Number.isInteger(clientPid) || clientPid <= 0) return skipped("unsupported");
|
|
805
|
+
const event = normalizedEvent({ kind: "sessionStart", sessionId: target?.sessionId, cwd: target?.cwd });
|
|
806
|
+
const { ACC_PARTICIPANT: _callerName, ...hostEnv } = env;
|
|
807
|
+
const context = await openContext({ event, adapterId, dataHome, runtime, env: hostEnv, deadline });
|
|
808
|
+
if (workspaceId !== undefined && context.descriptor.id !== workspaceId) return skipped("other_workspace");
|
|
809
|
+
return withSessionLifecycle({ root: context.paths.root, sessionId: event.sessionId,
|
|
810
|
+
clock: runtime.clock, deadlineAt: deadline }, async () => {
|
|
811
|
+
// As in runHook: recover a write a previous holder journalled before
|
|
812
|
+
// interpreting the binding.
|
|
813
|
+
const store = await openFilesystemStore({ root: context.paths.root, clock: runtime.clock,
|
|
814
|
+
ids: runtime.ids, workspaceId: context.descriptor.id, deadlineAt: deadline });
|
|
815
|
+
context.service = createCoordinationService({ store, clock: runtime.clock, ids: runtime.ids });
|
|
816
|
+
if (await loadSessionBinding({ runtimeDir: context.paths.root,
|
|
817
|
+
harnessSessionId: event.sessionId }) !== null) return skipped("known");
|
|
818
|
+
const started = await HANDLERS.sessionStart({ event, context, adapter, adapterId, binding: null,
|
|
819
|
+
paths: context.paths, readProcessTable: async () => new Map(), probeClientVersion,
|
|
820
|
+
platform, deadline, knownClientPid: clientPid, pins: false });
|
|
821
|
+
const session = await context.service.locateSession(started.accSessionId);
|
|
822
|
+
return { registered: true, sessionId: started.accSessionId,
|
|
823
|
+
participantId: session?.record.participantId ?? null, workspaceId: context.descriptor.id,
|
|
824
|
+
nativeBinding: started.nativeBinding };
|
|
825
|
+
});
|
|
826
|
+
};
|
|
827
|
+
let timer;
|
|
828
|
+
try {
|
|
829
|
+
return await Promise.race([register(), new Promise(resolve => {
|
|
830
|
+
timer = setTimeout(() => resolve(skipped("timed_out")), Math.max(0, deadline - Date.now()));
|
|
831
|
+
})]);
|
|
832
|
+
} catch {
|
|
833
|
+
return skipped("failed");
|
|
834
|
+
} finally {
|
|
835
|
+
clearTimeout(timer);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* What a peer runs before it reads status (#167): ask every installed adapter
|
|
841
|
+
* that can see its client's sessions for the ones ACC could register, and
|
|
842
|
+
* register those in this workspace. Bounded by one budget and fail-open; the
|
|
843
|
+
* answer lists what was registered and is otherwise informational.
|
|
844
|
+
*/
|
|
845
|
+
export async function registerDiscoveredSessions({ adapters, workspaceId, dataHome, env = {},
|
|
846
|
+
budgetMs = 2_500, runtime, probeClientVersion, platform }) {
|
|
847
|
+
const deadline = Date.now() + budgetMs;
|
|
848
|
+
const remaining = () => Math.max(0, deadline - Date.now());
|
|
849
|
+
// An adapter that ignores its timeout must not hold status: its answer is
|
|
850
|
+
// raced against what is left of the budget, and a late one is dropped.
|
|
851
|
+
const withinBudget = work => {
|
|
852
|
+
let timer;
|
|
853
|
+
return Promise.race([Promise.resolve().then(work).catch(() => []),
|
|
854
|
+
new Promise(resolve => { timer = setTimeout(resolve, remaining(), []); })])
|
|
855
|
+
.finally(() => clearTimeout(timer));
|
|
856
|
+
};
|
|
857
|
+
const registered = [];
|
|
858
|
+
let installed;
|
|
859
|
+
try {
|
|
860
|
+
installed = new Set((await loadOwnership({ dataHome })).installs.map(entry => entry.adapterId));
|
|
861
|
+
} catch {
|
|
862
|
+
return registered;
|
|
863
|
+
}
|
|
864
|
+
for (const adapter of Object.values(adapters ?? {})) {
|
|
865
|
+
if (typeof adapter?.discoverNativeSessions !== "function" || !installed.has(adapter.id)) continue;
|
|
866
|
+
const found = await withinBudget(() => adapter.discoverNativeSessions({ env,
|
|
867
|
+
timeoutMs: remaining() }));
|
|
868
|
+
for (const candidate of Array.isArray(found) ? found : []) {
|
|
869
|
+
if (remaining() === 0) return registered;
|
|
870
|
+
const result = await registerNativeSession({ adapterId: adapter.id, adapters,
|
|
871
|
+
event: { sessionId: candidate?.sessionId, cwd: candidate?.cwd }, clientPid: candidate?.clientPid,
|
|
872
|
+
workspaceId, dataHome, env, budgetMs: remaining(),
|
|
873
|
+
...(runtime ? { runtime } : {}), ...(probeClientVersion ? { probeClientVersion } : {}),
|
|
874
|
+
...(platform ? { platform } : {}) });
|
|
875
|
+
if (result.registered) {
|
|
876
|
+
registered.push({ adapterId: adapter.id, participantId: result.participantId,
|
|
877
|
+
sessionId: result.sessionId });
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return registered;
|
|
882
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isLaunchOption } from "@agents-can-communicate/adapter-sdk";
|
|
2
|
+
|
|
1
3
|
// Shared presentation of observed delivery facts. Vendor probes provide closed
|
|
2
4
|
// reasons; installation and doctor must not turn a version floor into readiness.
|
|
3
5
|
const REASONS = Object.freeze({
|
|
@@ -12,12 +14,20 @@ const REASONS = Object.freeze({
|
|
|
12
14
|
probe_version_mismatch: "the local service and CLI versions differ",
|
|
13
15
|
protocol_mismatch: "the local service did not confirm the required protocol",
|
|
14
16
|
unsupported_shell: "automatic launch setup requires zsh",
|
|
17
|
+
client_session_embedded: "the client runs this session on its own embedded service, which peers "
|
|
18
|
+
+ "cannot reach",
|
|
15
19
|
});
|
|
16
20
|
|
|
17
|
-
export function describeNativeReason(reason, { clientVersion, minimumVersion } = {}) {
|
|
21
|
+
export function describeNativeReason(reason, { clientVersion, minimumVersion, launchOption } = {}) {
|
|
18
22
|
if (reason === "below_minimum_version" && clientVersion && minimumVersion) {
|
|
19
23
|
return `client ${clientVersion} needs version ${minimumVersion} or newer for native delivery`;
|
|
20
24
|
}
|
|
25
|
+
// A client can run embedded because of how it was started - Codex does for
|
|
26
|
+
// `--search`, `-c`, `--profile` and others while its daemon runs.
|
|
27
|
+
if (reason === "client_session_embedded" && isLaunchOption(launchOption)) {
|
|
28
|
+
return `the client was started with ${launchOption}, which runs this session on its own `
|
|
29
|
+
+ "embedded service that peers cannot reach";
|
|
30
|
+
}
|
|
21
31
|
return REASONS[reason] ?? reason ?? "readiness is unverified";
|
|
22
32
|
}
|
|
23
33
|
export const describeDeliveryFallback = entry => entry.capabilities?.delivery?.nextTurn === true
|
|
@@ -118,6 +118,9 @@ async function callTool(name, args, context) {
|
|
|
118
118
|
|
|
119
119
|
switch (name) {
|
|
120
120
|
case "acc_status":
|
|
121
|
+
// Chats a client holds before their first turn, registered so this
|
|
122
|
+
// caller can address them (#167). Fail-open: status is read regardless.
|
|
123
|
+
await Promise.resolve().then(() => context.registerNativeSessions?.()).catch(() => null);
|
|
121
124
|
return service.collectStatus({});
|
|
122
125
|
case "acc_sync":
|
|
123
126
|
return service.sync({ ...owner, cursor: args.cursor ?? null,
|