agents-can-communicate 0.8.2 → 0.8.4

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 (52) 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/ARCHITECTURE.md +3 -1
  5. package/docs/CLI.md +7 -1
  6. package/docs/CONCEPTS.md +2 -2
  7. package/docs/CONFIGURATION.md +3 -2
  8. package/docs/GETTING_STARTED.md +12 -9
  9. package/docs/HOW_IT_WORKS.md +2 -1
  10. package/docs/TROUBLESHOOTING.md +11 -2
  11. package/docs/UPGRADING.md +29 -0
  12. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
  13. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  14. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  15. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  16. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +7 -1
  17. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +24 -1
  18. package/node_modules/@agents-can-communicate/adapter-codex/src/embedded-host.mjs +65 -0
  19. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +2 -0
  20. package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +33 -9
  21. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +38 -1
  22. package/node_modules/@agents-can-communicate/adapter-codex/src/native-discovery.mjs +82 -0
  23. package/node_modules/@agents-can-communicate/adapter-codex/src/service-setup.mjs +59 -5
  24. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  25. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  26. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  27. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  28. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  29. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +3 -0
  30. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  31. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +2 -1
  32. package/node_modules/@agents-can-communicate/cli/src/main.mjs +18 -2
  33. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +25 -8
  34. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +3 -1
  35. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/install.mjs +13 -3
  36. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +19 -1
  37. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +38 -3
  38. package/node_modules/@agents-can-communicate/cli/src/native-delivery-status.mjs +5 -1
  39. package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +3 -0
  40. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  41. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  42. package/node_modules/@agents-can-communicate/delivery-router/src/native-reason.mjs +18 -0
  43. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +15 -3
  44. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  45. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +110 -4
  46. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  47. package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +2 -0
  48. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  49. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +3 -0
  50. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  51. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  52. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { lstat, realpath } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { compareVersions } from "./app-server-client.mjs";
3
4
  import { readySocketPath, socketIsReady } from "./native-endpoint.mjs";
4
5
  import { probeNativeDelivery } from "./native-delivery.mjs";
5
6
  import { installCodexStandalone } from "./standalone-install.mjs";
@@ -13,6 +14,9 @@ const keys = ["home", "codexHome", "cliPath", "cliVersion", "managedPath", "mana
13
14
  const prerequisiteKeys = ["home", "codexHome", "cliPath", "cliVersion", "cliIdentity", "socketPath", "pidPath", "platform"];
14
15
  const same = (a, b, fields = keys) => fields.every(key => a?.[key] === b?.[key]);
15
16
  const own = info => typeof process.getuid !== "function" || info.uid === process.getuid();
17
+ // The first CLI measured installing the daemon's own package in a home that
18
+ // has none (2026-09-26; again on 0.159.0, 2026-09-29).
19
+ const SELF_INSTALL_MINIMUM = "0.157.1";
16
20
  const sessionNeeded = "Codex service ready; open a new Codex session to establish its delivery binding and review client hooks and permissions";
17
21
  const diagnostics = {
18
22
  native_endpoint_unavailable: "Prepare the missing Codex service with codex app-server daemon start, then open a new Codex session",
@@ -26,13 +30,25 @@ const diagnostics = {
26
30
  managed_binary_mismatch: "Codex CLI and managed standalone versions differ; repair the vendor installation, then retry acc install",
27
31
  maintenance_cli_unsupported: "Codex cold service preparation requires codex-cli 0.154.0 or newer with daemon commands; update the vendor installation, then retry acc install",
28
32
  };
33
+ const startsOnLaunch = "The Codex service is not running; Codex starts it with the next Codex session "
34
+ + "(daemon_auto_start), so open Codex, or run acc install to start it now";
29
35
  function report(state, reasonCode, facts = {}) {
30
36
  return { ...facts, state, reasonCode, diagnostic: state === "ready"
31
37
  ? reasonCode === "native_session_unavailable" ? sessionNeeded
32
38
  : "Codex service infrastructure is ready; current session binding is reported separately"
39
+ : facts.startsOnLaunch === true ? startsOnLaunch
33
40
  : diagnostics[reasonCode] ?? `Codex service preparation could not verify ${reasonCode}; inspect the vendor service and retry acc install` };
34
41
  }
35
42
 
43
+ // Since 0.157.1 a TUI that finds no service starts one (`daemon_auto_start`,
44
+ // stable and on by default). `codex features list` reports the effective value
45
+ // after config and profiles; any other answer leaves the start to acc install.
46
+ const AUTO_START = /^daemon_auto_start\s+\S+\s+true\s*$/m;
47
+ async function launchStart(run, paths, facts) {
48
+ const listed = await run(facts.cliPath, ["features", "list"], paths.options).catch(() => null);
49
+ return listed?.status === 0 && AUTO_START.test(listed.stdout) ? { startsOnLaunch: true } : {};
50
+ }
51
+
36
52
  export function createCodexServiceSetup({ run = runMaintenanceCommand, probe = probeNativeDelivery,
37
53
  fs = { lstat, realpath }, contextPaths = maintenanceContext,
38
54
  installStandalone = installCodexStandalone } = {}) {
@@ -90,9 +106,43 @@ export function createCodexServiceSetup({ run = runMaintenanceCommand, probe = p
90
106
  const installation = await probeMaintenanceCli({ ...paths,
91
107
  options: { ...paths.options, cwd: paths.options.env.HOME } }, run);
92
108
  const cli = await executableIdentity(installation.cliPath);
93
- return report("needed", "managed_install_missing", { requiresInstall: true,
94
- home: paths.options.env.HOME, codexHome: paths.codexHome, cliIdentity: cli.identity,
95
- socketPath: paths.socketPath, pidPath: paths.pidPath, platform: paths.platform, ...installation });
109
+ const facts = { home: paths.options.env.HOME, codexHome: paths.codexHome, cliIdentity: cli.identity,
110
+ socketPath: paths.socketPath, pidPath: paths.pidPath, platform: paths.platform, ...installation };
111
+ // A CLI that installs the daemon's own package at its first start needs
112
+ // no standalone download: starting the service is the whole setup (#205).
113
+ if (compareVersions(installation.cliVersion, SELF_INSTALL_MINIMUM) >= 0) {
114
+ return report("needed", "native_endpoint_unavailable", { ...facts, selfInstall: true,
115
+ ...await launchStart(run, paths, facts) });
116
+ }
117
+ return report("needed", "managed_install_missing", { requiresInstall: true, ...facts });
118
+ }
119
+ // The service a self-installing CLI prepares: start it, then verify the
120
+ // package and daemon it installed exactly as an existing one is verified.
121
+ async function startSelfInstalling(context, plan) {
122
+ let started = false;
123
+ try {
124
+ if (plan.state !== "needed" || !prerequisiteKeys.every(key => typeof plan[key] === "string")) {
125
+ failMaintenance("invalid_service_setup_plan");
126
+ }
127
+ const current = await inspect(context, { strict: true });
128
+ if (current.state !== "needed" || current.selfInstall !== true || !same(plan, current, prerequisiteKeys)) {
129
+ failMaintenance("service_identity_changed");
130
+ }
131
+ started = true;
132
+ const result = await run(plan.cliPath, ["app-server", "daemon", "start"], {
133
+ cwd: plan.codexHome, env: { ...(context?.env ?? process.env), HOME: plan.home, CODEX_HOME: plan.codexHome },
134
+ timeout: 15_000,
135
+ });
136
+ if (result.status !== 0) failMaintenance("daemon_start_failed");
137
+ const paths = await contextPaths(context);
138
+ const facts = await installed(paths);
139
+ if (!same(plan, facts, ["codexHome", "cliPath", "cliVersion", "cliIdentity", "socketPath"])) {
140
+ failMaintenance("service_identity_changed");
141
+ }
142
+ return { ...await verify(paths, facts), started };
143
+ } catch (error) {
144
+ return { ...report(started ? "failed" : "blocked", error.reasonCode ?? "daemon_start_failed"), started };
145
+ }
96
146
  }
97
147
  async function verify(paths, facts) {
98
148
  if (!await readySocketPath(paths.socketPath)) failMaintenance("daemon_socket_unproven");
@@ -129,9 +179,12 @@ export function createCodexServiceSetup({ run = runMaintenanceCommand, probe = p
129
179
  if (!await info(paths.managedPath)) return await missingPrerequisite(paths);
130
180
  const facts = await installed(paths);
131
181
  if (!await info(paths.socketPath) && !await info(paths.pidPath)) {
132
- return report("needed", "native_endpoint_unavailable", facts);
182
+ return report("needed", "native_endpoint_unavailable",
183
+ { ...facts, ...await launchStart(run, paths, facts) });
184
+ }
185
+ if (await serviceStopped(paths)) {
186
+ return report("needed", "service_stopped", { ...facts, ...await launchStart(run, paths, facts) });
133
187
  }
134
- if (await serviceStopped(paths)) return report("needed", "service_stopped", facts);
135
188
  return await verify(paths, facts);
136
189
  } catch (error) {
137
190
  const reason = error.reasonCode ?? "service_inspection_failed";
@@ -165,6 +218,7 @@ export function createCodexServiceSetup({ run = runMaintenanceCommand, probe = p
165
218
  }
166
219
  async function prepareNativeServiceSetup({ context, plan, installPrerequisites, download } = {}) {
167
220
  if (plan?.requiresInstall === true) return preparePrerequisite(context, plan, installPrerequisites, download);
221
+ if (plan?.selfInstall === true) return startSelfInstalling(context, plan);
168
222
  let started = false;
169
223
  try {
170
224
  if (plan?.state === "ready") {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "description": "Coordinate this Gemini CLI session with other AI agent sessions working in the same workspace.",
5
5
  "contextFileName": "skills/acc/SKILL.md"
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-gemini-cli",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-grok",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-kimi",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-sdk",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -34,6 +34,9 @@ export const NATIVE_REASON_CODES = Object.freeze([
34
34
  // The current session's canonical workspace identity could not be verified.
35
35
  // This is a per-session refusal, not a universal vendor limitation.
36
36
  "workspace_identity_unavailable",
37
+ // The client runs this session on its own embedded service, which no sender
38
+ // can reach, instead of the shared local one; it stays so until it closes.
39
+ "client_session_embedded",
37
40
  ]);
38
41
 
39
42
  export const STABLE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z.-]+)?$/;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/cli",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,7 +12,7 @@ export { describeNative } from "./native-delivery-status.mjs";
12
12
  import { nativeSessionLines, updateNativeSessions } from "./native-session-diagnostics.mjs";
13
13
  import { ALL_ADAPTERS, clientContext, probeTimeout } from "./install-command.mjs";
14
14
  import { decisionOf } from "./install-delivery-consent.mjs";
15
- import { describePresence, presenceBreakdown } from "./main.mjs";
15
+ import { describePresence, presenceBreakdown, registerNativeSessions } from "./main.mjs";
16
16
  import { platformPaths } from "./platform-paths.mjs";
17
17
  import { noticeUpdate } from "./update-check.mjs";
18
18
  import { managedUpdateDiagnostic } from "./managed-runtime/diagnostics.mjs";
@@ -233,6 +233,7 @@ export async function runDoctor({ options, context, runtime }) {
233
233
  // context built for this command opens the store on request rather than up
234
234
  // front.
235
235
  const service = context.service ?? await context.openService();
236
+ await registerNativeSessions(runtime, context);
236
237
  const status = await service.collectStatus({});
237
238
  updateNativeRuntime(adapters, status.deliveryBindings);
238
239
  // The runtime state above is read from the binding records alone. The
@@ -6,6 +6,7 @@ import { AccError, CONFIG_FILENAME, EXIT, GENERIC_MESSAGE_KINDS, VALID_OBLIGATIO
6
6
  failure, ok }
7
7
  from "@agents-can-communicate/protocol";
8
8
  import { createCoordinationService } from "@agents-can-communicate/core";
9
+ import { describeNativeReason } from "@agents-can-communicate/installer";
9
10
  import { openFilesystemStore } from "@agents-can-communicate/storage-filesystem";
10
11
 
11
12
  import { parseArgs, positiveNumber } from "./args.mjs";
@@ -102,6 +103,18 @@ async function openContext(options, runtime) {
102
103
  return withService(await locateContext(options, runtime), runtime);
103
104
  }
104
105
 
106
+ /**
107
+ * Before a peer reads status, chats its clients hold but ACC has not seen yet
108
+ * are registered, so the peer can address them before their first turn
109
+ * (#167). Bounded and fail-open inside the port; status never waits on it
110
+ * beyond its budget or fails because of it.
111
+ */
112
+ export async function registerNativeSessions(runtime, context) {
113
+ if (typeof runtime?.registerNativeSessions !== "function") return [];
114
+ return Promise.resolve().then(() => runtime.registerNativeSessions({
115
+ workspaceId: context.descriptor.id, dataHome: context.dataHome })).catch(() => []);
116
+ }
117
+
105
118
  /**
106
119
  * A context for the one command that has to survive an unopenable store.
107
120
  *
@@ -150,7 +163,9 @@ export function recordedText(message, delivery) {
150
163
  ? item.errorCode === "transport_permission_denied"
151
164
  ? "live offer blocked by sender permissions; message remains queued; run acc doctor in the sending session"
152
165
  : item.errorCode === "no_live_transport"
153
- ? `${item.recipientParticipantId} has no live transport; the message waits in its inbox`
166
+ ? `${item.recipientParticipantId} has no live transport`
167
+ + `${item.nativeReasonCode ? ` (${describeNativeReason(item.nativeReasonCode)})` : ""}`
168
+ + "; the message waits in its inbox"
154
169
  : item.errorCode === "recipient_offline" && item.endReason === "clear"
155
170
  ? `${item.recipientParticipantId}'s conversation was cleared (/clear); the message waits `
156
171
  + "until that conversation resumes"
@@ -406,7 +421,8 @@ const HANDLERS = Object.freeze({
406
421
  text: recordedText(handoff.message, routed.delivery) };
407
422
  },
408
423
 
409
- status: async ({ options, context }) => {
424
+ status: async ({ options, context, runtime }) => {
425
+ await registerNativeSessions(runtime, context);
410
426
  const status = await context.service.collectStatus({
411
427
  participantId: options.participant, all: options.all === true });
412
428
  return { data: status, text: describeStatus(status) };
@@ -154,6 +154,10 @@ async function resolveCandidate(value) {
154
154
  * Takes its own manager lock, so a caller already holding one (activation)
155
155
  * must call this only after releasing it.
156
156
  */
157
+ // An unknown holder postpones the whole pass; callers that record having
158
+ // reclaimed need to tell that from a pass that found nothing to remove.
159
+ const POSTPONED = Object.freeze({ removed: [], postponed: true });
160
+
157
161
  export async function reclaimGenerations({ root, active = null, pidIsAlive = defaultPidIsAlive } = {}) {
158
162
  root = await canonicalManagerRoot(root);
159
163
  return withManagerLock(root, async () => {
@@ -186,19 +190,19 @@ export async function reclaimGenerations({ root, active = null, pidIsAlive = def
186
190
  // readControl already throws on, and every real caller of this function
187
191
  // treats that throw as reason to postpone. Match that here explicitly:
188
192
  // an absent control.json is not proof there is nothing to protect.
189
- if (control === null) return { removed: [] };
193
+ if (control === null) return POSTPONED;
190
194
  const referenced = new Set();
191
195
  for (const candidate of [active, control.active.root, control.pending?.root]) {
192
196
  const resolved = await resolveCandidate(candidate);
193
- if (!resolved.ok) return { removed: [] };
197
+ if (!resolved.ok) return POSTPONED;
194
198
  if (resolved.root) referenced.add(resolved.root);
195
199
  }
196
200
  let holds;
197
201
  try { holds = await listRuntimeHolds(root, { pidIsAlive }); }
198
- catch { return { removed: [] }; } // An unreadable lease is an unknown holder.
202
+ catch { return POSTPONED; } // An unreadable lease is an unknown holder.
199
203
  for (const lease of holds) {
200
204
  const resolved = await resolveCandidate(lease.runtime?.root);
201
- if (!resolved.ok) return { removed: [] };
205
+ if (!resolved.ok) return POSTPONED;
202
206
  if (resolved.root) referenced.add(resolved.root);
203
207
  }
204
208
  // A client that exits without a clean session end leaves its pin behind;
@@ -212,9 +216,9 @@ export async function reclaimGenerations({ root, active = null, pidIsAlive = def
212
216
  const record = await readManagedJson(path.join(pins, name)).catch(() => null);
213
217
  if (record === undefined) continue; // Concurrent pin removal, not a holder.
214
218
  if (!record || record.schemaVersion !== 1 || typeof record.runtimeRoot !== "string"
215
- || record.runtimeRoot === "") return { removed: [] }; // Malformed pin: unknown holder.
219
+ || record.runtimeRoot === "") return POSTPONED; // Malformed pin: unknown holder.
216
220
  const resolved = await resolveCandidate(record.runtimeRoot);
217
- if (!resolved.ok) return { removed: [] };
221
+ if (!resolved.ok) return POSTPONED;
218
222
  referenced.add(resolved.root);
219
223
  }
220
224
  }
@@ -231,9 +235,9 @@ export async function reclaimGenerations({ root, active = null, pidIsAlive = def
231
235
  const record = await readManagedJson(path.join(staging, name)).catch(() => null);
232
236
  if (record === undefined) continue; // Concurrent hold release, not a holder.
233
237
  if (!record || record.schemaVersion !== 1 || typeof record.generationRoot !== "string"
234
- || record.generationRoot === "") return { removed: [] }; // Malformed hold: unknown holder.
238
+ || record.generationRoot === "") return POSTPONED; // Malformed hold: unknown holder.
235
239
  const resolved = await resolveCandidate(record.generationRoot);
236
- if (!resolved.ok) return { removed: [] };
240
+ if (!resolved.ok) return POSTPONED;
237
241
  referenced.add(resolved.root);
238
242
  }
239
243
  }
@@ -319,6 +323,19 @@ export async function activatePending(root, { prepare = prepareCandidate, env =
319
323
  if (outcome.activated) {
320
324
  try { await reclaimGenerations({ root, active: activatedRoot, pidIsAlive }); }
321
325
  catch { /* Best effort; never fails an activation that already succeeded. */ }
326
+ // The reclaim above is this, older, code's rule (#208). The activated
327
+ // generation's own rule runs next, from its own files as its refresh did,
328
+ // and records that it ran. A target without it (an older release) is left
329
+ // to its own worker; nothing here can fail the activation.
330
+ try { await reclaimAsActivated(root, activatedRoot); }
331
+ catch { /* Best effort, as above. */ }
322
332
  }
323
333
  return outcome;
324
334
  }
335
+
336
+ async function reclaimAsActivated(root, activatedRoot) {
337
+ const file = path.join(activatedRoot, "node_modules", "@agents-can-communicate", "cli", "src",
338
+ "managed-runtime", "worker.mjs");
339
+ const { reclaimAsActive } = await import(pathToFileURL(file).href);
340
+ if (typeof reclaimAsActive === "function") await reclaimAsActive(root, activatedRoot);
341
+ }
@@ -52,7 +52,9 @@ export async function downloadRelease(root, release, { env = process.env } = {})
52
52
  const temporary = await mkdtemp(path.join(downloads, ".download-"));
53
53
  try {
54
54
  await writeFile(path.join(temporary, "package.json"), JSON.stringify({ private: true, name: "acc-runtime-download", version: "1.0.0" }));
55
- await exec(process.execPath, [await npmCli(env), "install", "--ignore-scripts", "--omit=dev",
55
+ // Discovery asked the registry itself; --prefer-online makes npm ask too,
56
+ // instead of trusting a package document it cached before the release (#221).
57
+ await exec(process.execPath, [await npmCli(env), "install", "--prefer-online", "--ignore-scripts", "--omit=dev",
56
58
  "--global=false", "--prefix", temporary, "--package-lock=true",
57
59
  "--package-lock-only=false", "--dry-run=false", "--workspaces=false",
58
60
  "--include-workspace-root=false", "--no-audit", "--no-fund", "--save=true", "--save-exact", "--registry", release.registry,
@@ -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,10 @@
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";
2
5
  import { downloadRelease, fetchRelease, newerVersion } from "./download.mjs";
3
6
  import { withManagerLock } from "./mutex.mjs";
4
- import { readControl, writeControl } from "./state.mjs";
7
+ import { readControl, writeControl, writeManagedJson } from "./state.mjs";
5
8
  import { releaseStagingHold } from "./staging.mjs";
6
9
  import { MAINTENANCE_ACTIVE, maintenanceNotice, maintenanceReport, readMaintenance } from "./maintenance-state.mjs";
7
10
  import { requestMaintenance } from "./maintenance.mjs";
@@ -62,8 +65,40 @@ export async function performUpdate(root, { force = false, check = false, env =
62
65
  return { ...checked, pending: generation.version, ...await activate(root, { env, ignorePid }) };
63
66
  }
64
67
 
68
+ /**
69
+ * Reclaim with this generation's own rule, when it is the active one (#208).
70
+ *
71
+ * An update is run by the older installed code, so the reclaim right after an
72
+ * activation applies the older rule, and a fix to that rule used to take
73
+ * effect only one release later. The active generation's worker runs this on
74
+ * its first pass, which the scheduler starts for a generation that has not
75
+ * recorded it yet, and on every later pass. A reclaim that finds an unknown
76
+ * holder still postpones itself; the next pass tries again.
77
+ */
78
+ export const RECLAIM_MARKER = "reclaim.json";
79
+ export async function reclaimAsActive(root, generationRoot) {
80
+ if (typeof generationRoot !== "string") return;
81
+ try {
82
+ const control = await readControl(root);
83
+ if (control === null || await realpath(generationRoot) !== await realpath(control.active.root)) return;
84
+ const result = await reclaimGenerations({ root });
85
+ // A postponed pass is recorded as unfinished, so the scheduler tries again
86
+ // (hourly) even with automatic updates off.
87
+ await writeManagedJson(path.join(root, RECLAIM_MARKER), { schemaVersion: 1,
88
+ activeRoot: control.active.root, complete: result?.postponed !== true,
89
+ attemptedAt: new Date().toISOString() });
90
+ } catch { /* Best effort; the next pass reclaims again. */ }
91
+ }
92
+
65
93
  /** 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 } = {}) {
94
+ export async function runWorker(root, { force = false, env = process.env, wait = false, ignorePid = null,
95
+ generationRoot = null } = {}) {
96
+ const result = await pass(root, { force, env, wait, ignorePid });
97
+ await reclaimAsActive(root, generationRoot);
98
+ return result;
99
+ }
100
+
101
+ async function pass(root, { force, env, wait, ignorePid }) {
67
102
  const poll = async () => {
68
103
  for (;;) {
69
104
  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) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
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.2",
3
+ "version": "0.8.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,18 @@
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
+ *
6
+ * The same record `acc doctor` reads, found through the hook-owner file that
7
+ * names this exact session generation. Null when none says: an attempt that
8
+ * succeeded, a record for another generation, or no runtime to read.
9
+ */
10
+ export async function lastNativeReason({ runtimeDir, sessionId, generation }) {
11
+ if (typeof runtimeDir !== "string") return null;
12
+ const owners = (await listSessionBindings({ runtimeDir }))
13
+ .filter(owner => owner.accSessionId === sessionId && owner.generation === generation);
14
+ if (owners.length !== 1) return null;
15
+ const attempt = await loadNativeAttempt({ runtimeDir,
16
+ harnessSessionId: owners[0].harnessSessionId, accSessionId: sessionId, generation });
17
+ return attempt !== null && attempt.state !== "active" ? attempt.reasonCode : null;
18
+ }
@@ -1,5 +1,6 @@
1
- import { evaluateVersionContract } from "@agents-can-communicate/adapter-sdk";
1
+ import { NATIVE_REASON_CODES, evaluateVersionContract } from "@agents-can-communicate/adapter-sdk";
2
2
 
3
+ import { lastNativeReason } from "./native-reason.mjs";
3
4
  import { refreshExpiredBinding } from "./refresh-binding.mjs";
4
5
 
5
6
  const SAFE_ERRORS = new Set(["ambiguous_recipient_sessions", "delivery_disabled",
@@ -45,9 +46,20 @@ function safeTransport(value, opaqueEndpointRef) {
45
46
  return opaqueEndpointRef === "live-adapter" ? "native-live" : "live-adapter";
46
47
  }
47
48
 
48
- export function createDeliveryRouter({ service, adapters, clock, readLivePolicy }) {
49
+ export function createDeliveryRouter({ service, adapters, clock, readLivePolicy,
50
+ readNativeReason = input => lastNativeReason({ runtimeDir: service.store?.root, ...input }) }) {
49
51
  const registry = adaptersById(adapters);
50
52
 
53
+ // A session that bound no live transport may have said why: the reason its
54
+ // own last native attempt recorded, from the closed vocabulary only.
55
+ async function withoutLiveTransport(participantId, target) {
56
+ const reason = await Promise.resolve()
57
+ .then(() => readNativeReason({ sessionId: target.sessionId, generation: target.generation }))
58
+ .catch(() => null);
59
+ return { ...durable(participantId, "no_live_transport"),
60
+ ...(NATIVE_REASON_CODES.includes(reason) ? { nativeReasonCode: reason } : {}) };
61
+ }
62
+
51
63
  // Read at offer time, not from the binding: consent withdrawn after a session
52
64
  // bound must stop the next offer, not the next rebind.
53
65
  async function policyFor(adapter, binding) {
@@ -94,7 +106,7 @@ export function createDeliveryRouter({ service, adapters, clock, readLivePolicy
94
106
  && binding.generation === target.generation);
95
107
  // Online, with nothing to push through: a CLI participant, or a client
96
108
  // 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");
109
+ if (bindings.length === 0) return withoutLiveTransport(participantId, target);
98
110
  const evaluated = await Promise.all(bindings.map(async binding => {
99
111
  const adapter = registry.get(binding.adapterId);
100
112
  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.2",
3
+ "version": "0.8.4",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {