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.
Files changed (58) hide show
  1. package/bin/acc-update-worker.mjs +4 -1
  2. package/bin/entrypoints/acc-mcp.mjs +3 -0
  3. package/bin/entrypoints/acc.mjs +5 -0
  4. package/docs/ADAPTER_AUTHORING.md +3 -0
  5. package/docs/ARCHITECTURE.md +7 -2
  6. package/docs/CLI.md +7 -1
  7. package/docs/CONCEPTS.md +2 -2
  8. package/docs/CONFIGURATION.md +3 -2
  9. package/docs/GETTING_STARTED.md +12 -9
  10. package/docs/HOW_IT_WORKS.md +2 -1
  11. package/docs/TROUBLESHOOTING.md +18 -2
  12. package/docs/UPGRADING.md +35 -0
  13. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
  14. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  15. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  16. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  17. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +7 -1
  18. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +24 -1
  19. package/node_modules/@agents-can-communicate/adapter-codex/src/embedded-host.mjs +108 -0
  20. package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +16 -6
  21. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +2 -0
  22. package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +33 -9
  23. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +61 -2
  24. package/node_modules/@agents-can-communicate/adapter-codex/src/native-discovery.mjs +82 -0
  25. package/node_modules/@agents-can-communicate/adapter-codex/src/service-setup.mjs +59 -5
  26. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  27. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  28. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  29. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  30. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  31. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +2 -1
  32. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt.mjs +6 -2
  33. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +17 -7
  34. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +12 -2
  35. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  36. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +2 -1
  37. package/node_modules/@agents-can-communicate/cli/src/main.mjs +19 -2
  38. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +25 -8
  39. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/install.mjs +13 -3
  40. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +19 -1
  41. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +53 -3
  42. package/node_modules/@agents-can-communicate/cli/src/native-delivery-status.mjs +5 -1
  43. package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +7 -1
  44. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  45. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  46. package/node_modules/@agents-can-communicate/delivery-router/src/native-reason.mjs +22 -0
  47. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +19 -3
  48. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  49. package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +6 -1
  50. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +6 -3
  51. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +123 -10
  52. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  53. package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +11 -1
  54. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  55. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +3 -0
  56. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  57. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  58. 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
- return await withManagerLock(root, async () => {
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
- const ready = await writeControl(root, { ...control, phase: "ready", pending: null });
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?.auto || networkDisabled(env) || !control.pending && !checkDue(control)) return false;
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 { activatePending } from "./activation.mjs";
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" && native.configured) {
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 detail = failures[attempt.reasonCode] ?? describeNativeReason(attempt.reasonCode);
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
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/delivery-router",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 } from "@agents-can-communicate/adapter-sdk";
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 durable(participantId, "no_live_transport");
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) };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/hook-runner",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -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) && Buffer.byteLength(line, "utf8") <= HINT_MAX_BYTES;
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
- const outcome = (state, reasonCode, modes = []) =>
41
- Object.freeze({ state, reasonCode, modes: Object.freeze([...modes]) });
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, writePin }
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
- render = text => adapter.injectOutcome?.(text), activationHint = null, repeats = true }) {
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(text => adapter.injectOutcome?.(text), owner, totalBudget),
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(text => adapter.injectOutcome?.(text), owner, totalBudget),
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: "", ...render(projected) };
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 = command === null ? null
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/installer",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/mcp-server",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -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,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/protocol",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/storage-filesystem",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "accManagedUpdateProtocol": 2,
5
5
  "accStoreVersion": 6,
6
6
  "type": "module",