agents-can-communicate 0.7.0 → 0.8.0

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 (97) hide show
  1. package/README.md +3 -1
  2. package/bin/entrypoints/acc-bootstrap.mjs +6 -48
  3. package/bin/entrypoints/acc-claude-channel.mjs +47 -157
  4. package/docs/ADAPTER_AUTHORING.md +41 -10
  5. package/docs/ARCHITECTURE.md +28 -13
  6. package/docs/CAPABILITIES.md +19 -16
  7. package/docs/CLI.md +60 -14
  8. package/docs/CONCEPTS.md +12 -4
  9. package/docs/CONFIGURATION.md +18 -11
  10. package/docs/GETTING_STARTED.md +15 -12
  11. package/docs/GLOSSARY.md +2 -1
  12. package/docs/HOW_IT_WORKS.md +8 -4
  13. package/docs/PROTOCOL.md +56 -6
  14. package/docs/RELEASING.md +3 -2
  15. package/docs/SECURITY_MODEL.md +14 -1
  16. package/docs/TROUBLESHOOTING.md +64 -27
  17. package/docs/UPGRADING.md +49 -8
  18. package/docs/WHY_ACC.md +5 -3
  19. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
  20. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  21. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/skills/acc/SKILL.md +8 -0
  22. package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +14 -73
  23. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +29 -71
  24. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.282-product-evidence.json +161 -0
  25. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.282.json +28 -0
  26. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/inbox-wake-2.1.282.json +44 -0
  27. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +5 -2
  28. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +8 -0
  29. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +25 -30
  30. package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +6 -0
  31. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-contract.mjs +10 -0
  32. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-delivery.mjs +254 -0
  33. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-endpoint.mjs +166 -0
  34. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-registry.mjs +71 -0
  35. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-settings.mjs +109 -0
  36. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +25 -31
  37. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  38. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +8 -0
  39. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  40. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +8 -0
  41. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  42. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  43. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +8 -0
  44. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  45. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +8 -0
  46. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  47. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +15 -2
  48. package/node_modules/@agents-can-communicate/adapter-sdk/src/events.mjs +16 -3
  49. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +4 -3
  50. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -1
  51. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-activation.mjs +4 -14
  52. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +23 -11
  53. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +5 -1
  54. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  55. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +8 -6
  56. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +8 -23
  57. package/node_modules/@agents-can-communicate/cli/src/install-delivery-consent.mjs +3 -4
  58. package/node_modules/@agents-can-communicate/cli/src/main.mjs +39 -10
  59. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +7 -43
  60. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +5 -3
  61. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +3 -4
  62. package/node_modules/@agents-can-communicate/cli/src/native-delivery-status.mjs +2 -2
  63. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  64. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +2 -2
  65. package/node_modules/@agents-can-communicate/core/src/next-turn-delivery.mjs +21 -4
  66. package/node_modules/@agents-can-communicate/core/src/receipts.mjs +66 -10
  67. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +25 -2
  68. package/node_modules/@agents-can-communicate/core/src/status.mjs +38 -1
  69. package/node_modules/@agents-can-communicate/core/src/sync.mjs +11 -2
  70. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  71. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +24 -7
  72. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  73. package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +6 -8
  74. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +24 -11
  75. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +21 -12
  76. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  77. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +19 -5
  78. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +3 -7
  79. package/node_modules/@agents-can-communicate/installer/src/index.mjs +4 -7
  80. package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +23 -61
  81. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +7 -1
  82. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +15 -5
  83. package/node_modules/@agents-can-communicate/installer/src/shell-bootstrap.mjs +10 -133
  84. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  85. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +5 -3
  86. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  87. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  88. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +6 -1
  89. package/package.json +1 -1
  90. package/bin/acc-bootstrap.mjs +0 -7
  91. package/bin/acc-claude-channel.mjs +0 -10
  92. package/bin/entrypoints/claude-channel-binding.mjs +0 -46
  93. package/bin/entrypoints/claude-channel-stdio.mjs +0 -29
  94. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.mcp.json +0 -8
  95. package/node_modules/@agents-can-communicate/adapter-claude-code/src/channel.mjs +0 -381
  96. package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +0 -232
  97. package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +0 -150
@@ -2,7 +2,7 @@ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import path from "node:path";
4
4
 
5
- import { describeDeliveryFallback, detectInstallation, livePolicyOf, loadOwnership, shellOf, verifyOwned }
5
+ import { describeDeliveryFallback, detectInstallation, livePolicyOf, loadOwnership, verifyOwned }
6
6
  from "@agents-can-communicate/installer";
7
7
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
8
8
 
@@ -114,12 +114,9 @@ export async function diagnoseAdapters({ options, runtime, detect = detectInstal
114
114
  const home = options?.home ?? runtime?.env?.HOME ?? homedir();
115
115
  const { data: dataHome } = platformPaths({ platform: runtime?.platform,
116
116
  env: runtime?.env ?? {} });
117
- // The same shell and environment install reads, so detection plans the same
118
- // shell bootstrap it would apply. Omitting them left detection with a null
119
- // shell, which degrades every shell-bootstrap client to `unsupported_shell` -
120
- // so a zsh machine with a working shim read as degraded on every run.
117
+ // The same environment install reads, so detection probes the same client.
121
118
  const clients = clientContext(home, path.join(dataHome, "acc"),
122
- { shell: shellOf(runtime?.env ?? {}), env: runtime?.env ?? {} });
119
+ { env: runtime?.env ?? {} });
123
120
  const adapters = ALL_ADAPTERS();
124
121
  const detected = await detect({ adapters, context: clients,
125
122
  probeTimeoutMs: probeTimeout(runtime?.env) });
@@ -297,6 +294,11 @@ export async function runDoctor({ options, context, runtime }) {
297
294
  ...nativeSessionLines(adapters),
298
295
  ...adapters.filter(adapter => adapter.present && adapter.outgoingDelivery?.state === "configured")
299
296
  .map(adapter => ` ${adapter.displayName} ${adapter.outgoingDelivery.diagnostic}`),
297
+ // The client's own inbound control decides whether a wake reaches the model.
298
+ // Only someone who turned live delivery on needs to hear about it.
299
+ ...adapters.filter(adapter => adapter.present && adapter.nativeDelivery.configured
300
+ && typeof adapter.inboundDelivery?.diagnostic === "string")
301
+ .map(adapter => ` ${adapter.displayName} inbound: ${adapter.inboundDelivery.diagnostic}`),
300
302
  ...(manager === null ? [] : [` automatic updates ${manager.auto ? "on" : "off"}; ACC ${manager.active.version}`
301
303
  + (manager.pin ? `; pinned to ${manager.pin}` : ""), ...(update.notice ? [` ${update.notice}`] : [])]),
302
304
  ...data.remediation.map(line => ` ${line}`),
@@ -7,8 +7,7 @@ import { createCodexAdapter } from "@agents-can-communicate/adapter-codex";
7
7
  import { createGeminiCliAdapter } from "@agents-can-communicate/adapter-gemini-cli";
8
8
  import { createGrokAdapter } from "@agents-can-communicate/adapter-grok";
9
9
  import { createKimiAdapter } from "@agents-can-communicate/adapter-kimi";
10
- import { applyPlan, detectInstallation,
11
- loadOwnership, planInstallation, rcFileFor, shellOf, shimDirFor }
10
+ import { applyPlan, detectInstallation, loadOwnership, planInstallation }
12
11
  from "@agents-can-communicate/installer";
13
12
  import { AccError, EXIT } from "@agents-can-communicate/protocol";
14
13
 
@@ -29,7 +28,7 @@ export { decideDelivery } from "./install-delivery-consent.mjs";
29
28
  // pointed at the home itself writes beside them rather than inside them. That
30
29
  // install reports success and the client never reads a byte of it.
31
30
  export const clientContext = (home, stateRoot,
32
- { shell = null, env = {}, dataHome, cwd = process.cwd() } = {}) => ({
31
+ { env = {}, dataHome, cwd = process.cwd() } = {}) => ({
33
32
  home,
34
33
  ...(dataHome === undefined ? {} : { dataHome }),
35
34
  configDir: path.join(home, ".claude"),
@@ -39,10 +38,8 @@ export const clientContext = (home, stateRoot,
39
38
  kimiHome: path.join(home, ".kimi-code"),
40
39
  grokHome: typeof env.GROK_HOME === "string" && env.GROK_HOME !== ""
41
40
  ? env.GROK_HOME : path.join(home, ".grok"),
42
- // The user's login shell and PATH, for the optional native shell bootstrap:
43
- // which rc file could carry an ACC PATH block and which real executable a
44
- // shim would exec. Detection reads them; nothing here writes.
45
- shell,
41
+ // The user's PATH, for finding the real client executable to probe.
42
+ // Detection reads it; nothing here writes.
46
43
  env,
47
44
  // Antigravity CLI keeps its hooks in the `~/.gemini` tree Gemini CLI also
48
45
  // uses, and reads none of the same files. Two locations load - the
@@ -200,8 +197,7 @@ export function describeOutcome({ action, acted, failed = [], skipped = [],
200
197
  export function failureOf({ action, acted, failed = [], operations = [], skipped = [], home }) {
201
198
  if (failed.length === 0) return null;
202
199
  return new AccError(EXIT.DATA,
203
- [describeOutcome({ action, acted, failed, operations, skipped, home }),
204
- ...reloadAdvice(operations)].join("\n"), { failed });
200
+ describeOutcome({ action, acted, failed, operations, skipped, home }), { failed });
205
201
  }
206
202
 
207
203
  /**
@@ -220,23 +216,13 @@ export function actedOn(result) {
220
216
  + (operation.changes?.length ?? 0) > 0)).length;
221
217
  }
222
218
 
223
- // Said once, after the first PATH block is written: a running shell and a
224
- // running client know nothing about it.
225
- function reloadAdvice(operations) {
226
- const appended = operations.filter(operation => operation.appendedRcBlock === true);
227
- if (appended.length === 0) return [];
228
- const rc = appended[0].nativeActivation?.rcFile ?? "your shell rc file";
229
- return ["", `native delivery is wired for new sessions: open a new terminal (or reload ${rc}), `
230
- + "then start the client normally; a session already running keeps durable delivery"];
231
- }
232
-
233
219
  export async function runInstallCommand({ options, runtime, action = "install" }) {
234
220
  const adapters = selectAdapters(options.adapter);
235
221
  const home = options.home ?? runtime.env?.HOME ?? homedir();
236
222
  const { data: dataHome } = platformPaths({ platform: runtime.platform,
237
223
  env: runtime.env ?? {} });
238
224
  const context = clientContext(home, path.join(dataHome, "acc"),
239
- { shell: shellOf(runtime.env), env: runtime.env ?? {}, dataHome });
225
+ { env: runtime.env ?? {}, dataHome });
240
226
 
241
227
  const detected = await detectInstallation({ adapters, context,
242
228
  probeTimeoutMs: probeTimeout(runtime.env) });
@@ -273,7 +259,7 @@ export async function runInstallCommand({ options, runtime, action = "install" }
273
259
  deliveryDecisionByAdapter: decided.deliveryDecisionByAdapter,
274
260
  allowServiceSetup: action === "install" });
275
261
  const apply = (paths = {}) => applyPlan({ plan, adapters, context: { ...context, ...paths },
276
- dataHome, dryRun, accVersion, activation: { bootstrap: paths.bootstrap } });
262
+ dataHome, dryRun, accVersion });
277
263
  const result = action === "install" && !dryRun && runtime.packageRoot
278
264
  ? await installManaged({ packageRoot: runtime.packageRoot,
279
265
  managerRoot: path.join(dataHome, "acc", "runtime"), dataHome, home, cwd: options.cwd ?? runtime.cwd,
@@ -293,9 +279,8 @@ export async function runInstallCommand({ options, runtime, action = "install" }
293
279
 
294
280
  return { data: { ...result, plan, dataHome, deliveryByAdapter: decided.deliveryByAdapter,
295
281
  deliveryDecisionByAdapter: decided.deliveryDecisionByAdapter, asked: decided.asked },
296
- text: [describeOutcome({ action, acted, failed: result.failed,
282
+ text: describeOutcome({ action, acted, failed: result.failed,
297
283
  skipped: plan.skipped, operations: result.operations, home }),
298
- ...reloadAdvice(result.operations)].join("\n"),
299
284
  error: failureOf({ action, acted, failed: result.failed, operations: result.operations,
300
285
  skipped: plan.skipped, home }) };
301
286
  }
@@ -57,9 +57,6 @@ function questionFor(entries) {
57
57
  const names = entries.map(entry => entry.displayName ?? entry.adapterId).join(", ");
58
58
  const setup = [...new Set(entries.map(entry => entry.outgoingDelivery?.setup)
59
59
  .filter(value => typeof value === "string"))];
60
- const channels = entries.some(entry => (entry.nativeDelivery?.activationPlan?.mechanisms ?? [])
61
- .some(mechanism => mechanism.kind === "shell-bootstrap"
62
- && (mechanism.prefixArgs ?? []).some(argument => argument.startsWith("--"))));
63
60
  return [
64
61
  `Complete automatic peer-request setup for ${names}? (experimental)`,
65
62
  " Yes: automatic turns can spend tokens without waiting for you.",
@@ -69,7 +66,9 @@ function questionFor(entries) {
69
66
  ...entries.filter(needsPrerequisite).map(entry =>
70
67
  ` Download the official Codex ${entry.nativeServiceSetup.cliVersion} standalone package for its service; retain your existing codex command.`),
71
68
  ...setup.map(note => ` ${note}`),
72
- ...(channels ? [" Allow Claude Code development Channels when prompted at startup."] : []),
69
+ ...(entries.some(entry => entry.adapterId === "claude_code")
70
+ ? [" Claude Code sessions that bypass permission prompts ask before each ACC wake;",
71
+ " set Claude Code's crossSessionInbound to accept to take wakes without asking."] : []),
73
72
  " No: keep current delivery policies and use available next-turn hooks or acc inbox.",
74
73
  ].join("\n");
75
74
  }
@@ -138,13 +138,26 @@ function obligationFor(kind, explicit, addressed) {
138
138
  return obligation;
139
139
  }
140
140
 
141
- function recordedText(message, delivery) {
141
+ export function recordedText(message, delivery) {
142
142
  const diagnostics = delivery.map(item => item.outcome === "offered"
143
143
  ? `live offered to ${item.recipientParticipantId} via ${item.transport}`
144
+ : item.outcome === "woken" && item.pendingApproval === true
145
+ ? `sent a wake to ${item.recipientParticipantId} via ${item.transport}, which its session holds `
146
+ + "for approval because it bypasses permission prompts; the message arrives with its next turn"
147
+ : item.outcome === "woken"
148
+ ? `woke ${item.recipientParticipantId} via ${item.transport}; the message arrives with its next turn`
144
149
  : item.outcome === "queued"
145
150
  ? item.errorCode === "transport_permission_denied"
146
151
  ? "live offer blocked by sender permissions; message remains queued; run acc doctor in the sending session"
147
- : `live offer unavailable (${item.errorCode ?? "durable_fallback"})`
152
+ : item.errorCode === "no_live_transport"
153
+ ? `${item.recipientParticipantId} has no live transport; the message waits in its inbox`
154
+ : item.errorCode === "recipient_offline" && item.endReason === "clear"
155
+ ? `${item.recipientParticipantId}'s conversation was cleared (/clear); the message waits `
156
+ + "until that conversation resumes"
157
+ : item.errorCode === "recipient_offline"
158
+ ? `${item.recipientParticipantId} has no open session; the message waits until it starts `
159
+ + "or resumes one"
160
+ : `live offer unavailable (${item.errorCode ?? "durable_fallback"})`
148
161
  : `delivery already ${item.outcome}`);
149
162
  return [`recorded ${message.messageId}`, ...diagnostics].join("; ");
150
163
  }
@@ -174,6 +187,29 @@ export function describePresence({ live = 0, stale = 0 } = {}) {
174
187
  return `${live} live (${stale} not answering)`;
175
188
  }
176
189
 
190
+ export function describeStatus(status) {
191
+ // `live` counts everyone present, which includes sessions gone stale - a
192
+ // client that exited without its session being closed keeps a record that
193
+ // stops being answered but does not disappear. Printing the number alone
194
+ // said "1 live" about a workspace where the last agent had left minutes
195
+ // before, which is the one thing a person reads this line to find out.
196
+ const line = `${describePresence(status.counts)}; ${status.counts.claims} claim(s); `
197
+ + `protection ${status.protection}`;
198
+ // A transport accepting a message is not a model reading it. Said only when
199
+ // it is true of something, so the everyday line stays the everyday line.
200
+ const offered = status.counts.unretrieved?.offered ?? 0;
201
+ const withOffers = offered === 0 ? line : `${line}; ${offered} offered, not retrieved`;
202
+ // Status lists a participant with no open session only while messages wait
203
+ // for it; each is named here, with the client's reason when it gave one.
204
+ const waiting = (status.participants ?? []).filter(item => item.presence === "offline")
205
+ .map(item => {
206
+ const count = (item.unretrieved?.queued ?? 0) + (item.unretrieved?.offered ?? 0);
207
+ return `${item.participantId} (${item.endReason === "clear" ? "cleared, " : ""}${count})`;
208
+ });
209
+ return waiting.length === 0 ? withOffers
210
+ : `${withOffers}; waiting for a closed session: ${waiting.join(", ")}`;
211
+ }
212
+
177
213
  const HANDLERS = Object.freeze({
178
214
  attach: async ({ options, context }) => {
179
215
  const session = await context.service.openSession({
@@ -336,14 +372,7 @@ const HANDLERS = Object.freeze({
336
372
  status: async ({ options, context }) => {
337
373
  const status = await context.service.collectStatus({
338
374
  participantId: options.participant, all: options.all === true });
339
- // `live` counts everyone present, which includes sessions gone stale - a
340
- // client that exited without its session being closed keeps a record that
341
- // stops being answered but does not disappear. Printing the number alone
342
- // said "1 live" about a workspace where the last agent had left minutes
343
- // before, which is the one thing a person reads this line to find out.
344
- const text = `${describePresence(status.counts)}; ${status.counts.claims} claim(s); `
345
- + `protection ${status.protection}`;
346
- return { data: status, text };
375
+ return { data: status, text: describeStatus(status) };
347
376
  },
348
377
 
349
378
  config: async ({ options, runtime }) => {
@@ -7,24 +7,13 @@ import { canonicalManagerRoot, readControl, readManagedJson } from "./state.mjs"
7
7
  import { commandPrefix } from "./command-prefix.mjs";
8
8
 
9
9
  export const ENTRY_KINDS = Object.freeze([
10
- "acc", "acc-hook", "acc-mcp", "acc-bootstrap", "acc-claude-channel", "acc-antigravity-relay",
10
+ "acc", "acc-hook", "acc-mcp", "acc-antigravity-relay",
11
11
  ]);
12
12
 
13
- const BOOTSTRAP_FLAGS = new Map([["--adapter", "adapter"], ["--real-executable", "realExecutable"],
14
- ["--data-home", "dataHome"]]);
15
-
16
- /** Shared with the runtime; parse before admission without importing installer code. */
17
- export function parseBootstrapOptions(args) {
18
- const options = {};
19
- for (let index = 0; index < args.length; index += 2) {
20
- const key = BOOTSTRAP_FLAGS.get(args[index]);
21
- const value = args[index + 1];
22
- if (key === undefined || typeof value !== "string" || value === "" || key in options) return null;
23
- options[key] = value;
24
- }
25
- return BOOTSTRAP_FLAGS.values().every(key => key in options) && path.isAbsolute(options.dataHome)
26
- ? options : null;
27
- }
13
+ // Kinds a 0.7.x install wrote a launcher for. The package still ships their
14
+ // entrypoints for 0.7.x updaters and launchers; a current install removes the
15
+ // launchers themselves.
16
+ export const RETIRED_ENTRY_KINDS = Object.freeze(["acc-bootstrap", "acc-claude-channel"]);
28
17
 
29
18
  // Bootstrap resolution deliberately needs only Node built-ins: no selected
30
19
  // generation is imported until admission and its actual PID lease are durable.
@@ -43,11 +32,9 @@ export function invokedDirectly(url) {
43
32
 
44
33
  function unavailable(kind) {
45
34
  // Never reflect control file content or payloads into a client diagnostic.
46
- if (kind === "acc-hook" || kind === "acc-claude-channel" || kind === "acc-antigravity-relay") {
35
+ if (kind === "acc-hook" || kind === "acc-antigravity-relay") {
47
36
  process.stderr.write("acc: coordination unavailable during runtime update; session may continue\n");
48
37
  process.exitCode = 0;
49
- } else if (kind === "acc-bootstrap") {
50
- process.exitCode = 1; // The vendor shell shim launches its original command.
51
38
  } else {
52
39
  process.stderr.write("acc: runtime unavailable; retry after the update or run acc update to recover\n");
53
40
  process.exitCode = 4;
@@ -83,28 +70,12 @@ async function updateImplementation(packageRoot, control) {
83
70
  export async function runEntry({ kind, packageRoot, managerRoot, managedRequired = false }) {
84
71
  if (!ENTRY_KINDS.includes(kind)) throw new Error("unknown ACC entry point");
85
72
  const command = kind === "acc" ? commandPrefix(process.argv.slice(2)).command : null;
86
- const bootstrapOptions = kind === "acc-bootstrap" ? parseBootstrapOptions(process.argv.slice(2)) : null;
87
- if (kind === "acc-bootstrap" && bootstrapOptions === null) {
88
- if (process.env.ACC_BOOTSTRAP_DEBUG === "1") {
89
- process.stderr.write("acc-bootstrap: usage: --adapter <id> --real-executable <path> --data-home <path>\n");
90
- }
91
- process.exitCode = 2;
92
- return;
93
- }
94
73
  let selected = packageRoot;
95
74
  let managed = null;
96
75
  let managementOnly = false;
97
76
  let root;
98
77
  try {
99
- const requestedDataHome = bootstrapOptions === null ? null : await canonicalManagerRoot(bootstrapOptions.dataHome);
100
- const requestedRoot = requestedDataHome === null ? null
101
- : await canonicalManagerRoot(path.join(requestedDataHome, "acc", "runtime"));
102
- if (managerRoot && requestedDataHome !== null
103
- && requestedDataHome !== await canonicalManagerRoot(path.dirname(path.dirname(managerRoot)))) {
104
- throw new Error("bootstrap cache home differs from its launcher");
105
- }
106
- root = await canonicalManagerRoot(managerRoot ?? requestedRoot ?? managerLocation());
107
- if (requestedRoot !== null && root !== requestedRoot) throw new Error("bootstrap data home differs from its launcher");
78
+ root = await canonicalManagerRoot(managerRoot ?? managerLocation());
108
79
  const control = await readControl(root);
109
80
  if (control !== null) {
110
81
  selected = control.active.root;
@@ -138,13 +109,6 @@ export async function runEntry({ kind, packageRoot, managerRoot, managedRequired
138
109
  } catch { /* Fall through to the same graceful unavailability below. */ }
139
110
  }
140
111
  unavailable(kind);
141
- if (kind === "acc-claude-channel" && selected) {
142
- try {
143
- const { startInertChannel } = await import(pathToFileURL(path.join(selected,
144
- "bin", "entrypoints", "claude-channel-stdio.mjs")).href);
145
- startInertChannel();
146
- } catch { /* The stderr notice above already fired either way. */ }
147
- }
148
112
  return;
149
113
  }
150
114
  const runtime = await import(pathToFileURL(path.join(selected, "bin", "entrypoints", `${kind}.mjs`)).href);
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { lstat, mkdtemp, open, readFile, rename, rm } from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { ENTRY_KINDS } from "./entry.mjs";
4
+ import { ENTRY_KINDS, RETIRED_ENTRY_KINDS } from "./entry.mjs";
5
5
  import { managedDirectory, syncDirectory } from "./state.mjs";
6
6
 
7
7
  const MODULES = ["entry.mjs", "command-prefix.mjs", "state.mjs", "generation-files.mjs",
@@ -14,8 +14,6 @@ async function durableFile(file, bytes, mode = 0o600) {
14
14
 
15
15
  export const stablePaths = root => ({
16
16
  cli: path.join(root, "bin", "acc.mjs"), runner: path.join(root, "bin", "acc-hook.mjs"),
17
- bootstrap: path.join(root, "bin", "acc-bootstrap.mjs"),
18
- channel: path.join(root, "bin", "acc-claude-channel.mjs"),
19
17
  antigravityRelay: path.join(root, "bin", "acc-antigravity-relay.mjs"),
20
18
  });
21
19
 
@@ -57,6 +55,10 @@ export async function writeLaunchers(root, packageRoot) {
57
55
  await rename(temporary, path.join(bin, `${kind}.mjs`));
58
56
  } finally { await rm(temporary, { force: true }); }
59
57
  }
58
+ // A 0.7.x `claude` shim or plugin copy that still reaches for one of these
59
+ // finds nothing: the shim then launches the vendor command untouched, and a
60
+ // re-laid plugin names none of them.
61
+ for (const kind of RETIRED_ENTRY_KINDS) await rm(path.join(bin, `${kind}.mjs`), { force: true });
60
62
  await syncDirectory(bin);
61
63
  return stablePaths(root);
62
64
  }
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import { applyPlan, detectInstallation, livePolicyOf, loadOwnership, planInstallation, shellOf }
2
+ import { applyPlan, detectInstallation, livePolicyOf, loadOwnership, planInstallation }
3
3
  from "@agents-can-communicate/installer";
4
4
  import { ALL_ADAPTERS, clientContext, probeTimeout } from "../install-command.mjs";
5
5
  import { stablePaths, writeLaunchers } from "./launchers.mjs";
@@ -17,7 +17,7 @@ export async function prepareRefresh({ control, root, env = process.env, callerP
17
17
  // session open across the update keeps running from the outgoing copy until it
18
18
  // restarts. Naming it here keeps that copy; everything older than it goes.
19
19
  const context = { ...clientContext(control.home, path.join(dataHome, "acc"),
20
- { env, shell: shellOf(env), dataHome }), ...stablePaths(root),
20
+ { env, dataHome }), ...stablePaths(root),
21
21
  keepPreviousVersion: control.active.version };
22
22
  const recorded = (await loadOwnership({ dataHome })).installs;
23
23
  const detected = await detectInstallation({ adapters, context, probeTimeoutMs: probeTimeout(env) });
@@ -38,8 +38,7 @@ export async function prepareRefresh({ control, root, env = process.env, callerP
38
38
  }
39
39
  return async () => {
40
40
  await writeLaunchers(root, control.pending.root);
41
- const result = await applyPlan({ plan, adapters, context, dataHome, accVersion: control.pending.version,
42
- activation: { bootstrap: context.bootstrap } });
41
+ const result = await applyPlan({ plan, adapters, context, dataHome, accVersion: control.pending.version });
43
42
  return { ...result, failed: result.failed.map(failure => ({ ...failure,
44
43
  paths: plan.operations.find(operation => operation.adapterId === failure.adapterId)
45
44
  ?.artifacts?.map(artifact => artifact.path).filter(file => typeof file === "string") ?? [],
@@ -34,11 +34,11 @@ export function nativeRemediation(entry) {
34
34
  + " # opt in to automatic peer requests; may spend tokens");
35
35
  } else if (native.configured && native.activation === "missing") {
36
36
  steps.push(`acc install --adapter ${entry.adapterId}`
37
- + " # complete the missing native launch setup from a supported shell");
37
+ + " # complete the missing native delivery setup");
38
38
  } else if (native.configured && native.runtime !== "active"
39
39
  && !["needed", "blocked", "unsupported"].includes(service?.state)) {
40
40
  steps.push(`${entry.displayName}: no verified live channel in this workspace; `
41
- + "open a new terminal, start a new client session and check its integration/channel prompts; "
41
+ + "start a new client session and check its integration prompts; "
42
42
  + "then run acc doctor here");
43
43
  }
44
44
  if (native.reasonCode === "native_endpoint_unavailable") {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/core",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -292,8 +292,8 @@ export function createConversationService(ports, sessions) {
292
292
  return (await nextTurnDelivery(input)).queuedMessages;
293
293
  }
294
294
 
295
- function nextTurnDelivery(input) {
296
- return selectNextTurnDelivery(store, input);
295
+ function nextTurnDelivery(input = {}) {
296
+ return selectNextTurnDelivery(store, { ...input, now: clock.now() });
297
297
  }
298
298
 
299
299
  return { sendMessage, finishSession, pendingMessages, nextTurnDelivery };
@@ -1,7 +1,10 @@
1
+ import { decisionView, isCurrentDecision } from "./decision-state.mjs";
2
+ import { dueForRepeat, offerFacts } from "./receipts.mjs";
3
+
1
4
  export async function nextTurnDelivery(store, input = {}) {
2
5
  if (typeof input.participantId !== "string" || input.participantId === "") {
3
6
  return { queuedMessages: [], liveOfferedMessageIds: [], reminderMessageIds: [],
4
- roomMessageIds: [] };
7
+ roomMessageIds: [], repeatMessages: [], repeatOffers: [] };
5
8
  }
6
9
  const snapshot = await store.snapshot(input.workspaceId ?? store.workspaceId,
7
10
  { kinds: ["message", "receipt"] });
@@ -13,19 +16,33 @@ export async function nextTurnDelivery(store, input = {}) {
13
16
  && message.fromSessionId !== input.exceptSessionId)
14
17
  .sort((left, right) => left.sentAt.localeCompare(right.sentAt)
15
18
  || left.messageId.localeCompare(right.messageId));
19
+ // Only a caller that will show them asks. A repeat taken out of the reminder
20
+ // lists and then never shown would be the one message this turn forgot.
21
+ const repeatMessages = input.repeats === true
22
+ ? eligible.filter(message => dueForRepeat(message,
23
+ receiptByMessage.get(message.messageId), input.now))
24
+ : [];
25
+ const repeating = new Set(repeatMessages.map(message => message.messageId));
16
26
  return {
17
27
  queuedMessages: eligible.filter(message =>
18
28
  receiptByMessage.get(message.messageId).state === "queued"),
19
29
  liveOfferedMessageIds: eligible.filter(message => message.toParticipantIds.length > 0
20
- && receiptByMessage.get(message.messageId).state === "offered")
30
+ && receiptByMessage.get(message.messageId).state === "offered"
31
+ && !repeating.has(message.messageId))
21
32
  .map(message => message.messageId),
22
33
  // A missing body is not evidence of delivery: a degraded adapter may have
23
34
  // withheld it. Only this recipient's durable receipt permits a reminder.
24
35
  reminderMessageIds: eligible.filter(message => message.obligation !== "none"
25
- && ["offered", "retrieved"].includes(receiptByMessage.get(message.messageId).state))
36
+ && ["offered", "retrieved"].includes(receiptByMessage.get(message.messageId).state)
37
+ && !repeating.has(message.messageId))
26
38
  .map(message => message.messageId),
27
39
  roomMessageIds: eligible.filter(message => message.toParticipantIds.length === 0)
28
40
  .map(message => message.messageId),
41
+ // After the queued bodies, so a new message wins the byte budget first.
42
+ repeatMessages,
43
+ repeatOffers: repeatMessages.map(message => {
44
+ const { transport, at } = offerFacts(receiptByMessage.get(message.messageId));
45
+ return { messageId: message.messageId, transport, at };
46
+ }),
29
47
  };
30
48
  }
31
- import { decisionView, isCurrentDecision } from "./decision-state.mjs";
@@ -3,6 +3,42 @@ import { AccError, EXIT, SCHEMA_VERSION, advanceReceipt }
3
3
 
4
4
  import { receiptId } from "./conversations.mjs";
5
5
 
6
+ // How long a live offer may stand with nothing after it before the next turn
7
+ // shows the body once more. Above the 90th percentile of the time models took
8
+ // to retrieve, acknowledge or answer an offered message on real stores.
9
+ export const REPEAT_OFFER_AFTER_MS = 15 * 60 * 1000;
10
+
11
+ /**
12
+ * How and when a transport accepted this receipt's message, if anyone recorded it.
13
+ *
14
+ * Kept in `extensions` because a new receipt field would fail validation in an
15
+ * older ACC reading the same store. A receipt that an older ACC offered carries
16
+ * nothing here, and reads as `null`: unknown, never guessed.
17
+ */
18
+ export function offerFacts(receipt) {
19
+ const offer = receipt?.extensions?.offer;
20
+ if (offer === null || typeof offer !== "object" || Array.isArray(offer)
21
+ || typeof offer.transport !== "string" || typeof offer.at !== "string"
22
+ || !Number.isFinite(Date.parse(offer.at))) return null;
23
+ return { transport: offer.transport, at: offer.at,
24
+ repeatedAt: offer.repeatedAt == null ? null : String(offer.repeatedAt) };
25
+ }
26
+
27
+ /**
28
+ * A live transport accepted this message, and nothing followed for long enough.
29
+ *
30
+ * A next-turn offer is recorded only after the hook's stdout carried the body,
31
+ * which is as close to the model as ACC can see, so it is never repeated. A live
32
+ * offer proves a transport took the bytes. Once, the next turn shows it again.
33
+ */
34
+ export function dueForRepeat(message, receipt, now) {
35
+ const offer = offerFacts(receipt);
36
+ return message != null && message.toParticipantIds.length > 0
37
+ && receipt?.state === "offered" && offer !== null
38
+ && offer.transport !== "next-turn" && offer.repeatedAt === null
39
+ && Date.parse(now) - Date.parse(offer.at) >= REPEAT_OFFER_AFTER_MS;
40
+ }
41
+
6
42
  export const SAFE_OFFER_ERROR_CODES = Object.freeze([
7
43
  "ambiguous_recipient_sessions",
8
44
  "delivery_disabled",
@@ -59,28 +95,48 @@ export function createReceiptService(ports) {
59
95
  }
60
96
 
61
97
  async function recordOfferSucceeded(input) {
98
+ const repeat = input.repeat === true;
99
+ if (repeat && input.transport !== "next-turn") {
100
+ throw new AccError(EXIT.DATA, "a repeated offer travels only through the next turn",
101
+ { messageId: input.messageId, transport: input.transport });
102
+ }
62
103
  const now = clock.now();
63
104
  return store.transaction(tx => {
64
105
  const id = receiptId(input.messageId, input.recipientParticipantId);
65
106
  const receipt = requireOfferableReceipt(tx, input, { allowRoomNextTurn: true });
66
107
  const target = requireTarget(tx, input, { mustBeOpen: true });
108
+ const success = extra => tx.append({ schemaVersion: SCHEMA_VERSION,
109
+ eventId: ids.next("event"), workspaceId: receipt.workspaceId,
110
+ actorSessionId: target.sessionId, type: "message.offer_succeeded", occurredAt: now,
111
+ payload: { messageId: input.messageId,
112
+ recipientParticipantId: input.recipientParticipantId,
113
+ targetSessionId: input.targetSessionId,
114
+ targetGeneration: input.targetGeneration,
115
+ transport: input.transport, adapterId: input.adapterId,
116
+ clientVersion: input.clientVersion, ...extra } });
117
+ if (repeat) {
118
+ // Judged again under the writer: a retrieval, an acknowledgement or
119
+ // another hook's repeat may have landed since this one was selected.
120
+ // The receipt keeps its state and its time; only the attempt is new.
121
+ if (!dueForRepeat(tx.get("message", input.messageId), receipt, now)) return receipt;
122
+ // Spread from the stored object, never from offerFacts: a later ACC
123
+ // may keep more about an offer, and this write must not drop it.
124
+ const repeated = { ...receipt, extensions: { ...receipt.extensions,
125
+ offer: { ...receipt.extensions.offer, repeatedAt: now } } };
126
+ tx.put("receipt", id, repeated, tx.generationOf("receipt", id));
127
+ success({ repeat: true });
128
+ return repeated;
129
+ }
67
130
  // A retrieval or acknowledgement may win after a transport accepted but
68
131
  // before this transaction acquired the writer. That stronger truth must
69
132
  // remain in place. The same rule makes a repeated successful commit a
70
133
  // read-only operation rather than another success event.
71
134
  if (receipt.state !== "queued") return receipt;
72
135
  const offered = { ...receipt, state: advanceReceipt(receipt.state, "offered"),
73
- updatedAt: now };
136
+ updatedAt: now, extensions: { ...receipt.extensions,
137
+ offer: { transport: input.transport, at: now, repeatedAt: null } } };
74
138
  tx.put("receipt", id, offered, tx.generationOf("receipt", id));
75
- tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
76
- workspaceId: receipt.workspaceId, actorSessionId: target.sessionId,
77
- type: "message.offer_succeeded", occurredAt: now,
78
- payload: { messageId: input.messageId,
79
- recipientParticipantId: input.recipientParticipantId,
80
- targetSessionId: input.targetSessionId,
81
- targetGeneration: input.targetGeneration,
82
- transport: input.transport, adapterId: input.adapterId,
83
- clientVersion: input.clientVersion } });
139
+ success({});
84
140
  return offered;
85
141
  }, { kinds: ["session", "message", "receipt"], deadlineAt: input.deadlineAt });
86
142
  }
@@ -33,6 +33,9 @@ const ageBand = (session, age) =>
33
33
  * @returns {"online" | "stale" | "offline"}
34
34
  * @throws {AccError} EXIT.USAGE when pidIsAlive is not a function.
35
35
  */
36
+ // A client's end reason is a short word of its own ("clear", "logout").
37
+ const END_REASON = /^[a-z][a-z0-9_-]{0,63}$/;
38
+
36
39
  export function classifySessionPresence(session, now, pidIsAlive) {
37
40
  // Required rather than defaulted. A probe that defaults to "everyone is
38
41
  // alive" turns a forgotten call site into a check that silently passes, which
@@ -149,10 +152,14 @@ export function createSessionService(ports) {
149
152
  }, { kinds: ["session"] });
150
153
  }
151
154
 
152
- async function closeSession({ sessionId, workspaceId, generation }) {
155
+ async function closeSession({ sessionId, workspaceId, generation, endReason = null }) {
156
+ // The client's own word for why, kept in extensions: an older ACC reading
157
+ // the same store skips them, and the session.closed event is unchanged.
158
+ const reason = typeof endReason === "string" && END_REASON.test(endReason) ? endReason : null;
153
159
  const close = current => {
154
160
  assertGeneration({ record: current }, generation, "close", workspaceId ?? store.workspaceId);
155
- return { ...current, state: "closed", heartbeatAt: clock.now() };
161
+ return { ...current, state: "closed", heartbeatAt: clock.now(),
162
+ ...(reason === null ? {} : { extensions: { ...(current.extensions ?? {}), endReason: reason } }) };
156
163
  };
157
164
  let ephemeral = null;
158
165
  await store.ephemeral.delete("session", sessionId, async current => {
@@ -179,6 +186,21 @@ export function createSessionService(ports) {
179
186
  }, { kinds: ["session"] });
180
187
  }
181
188
 
189
+ /** The participant's most recent session, open or closed, or null. */
190
+ async function lastSessionOf({ participantId, workspaceId }) {
191
+ const resolved = workspaceId ?? store.workspaceId;
192
+ const snapshot = resolved === undefined ? null
193
+ : await store.snapshot(resolved, { kinds: ["workspace", "session"] });
194
+ const records = snapshot?.workspace === null
195
+ ? await store.ephemeral.list("session")
196
+ : snapshot?.sessions ?? await store.ephemeral.list("session");
197
+ const [last] = records.filter(session => session.participantId === participantId)
198
+ .sort((left, right) => Date.parse(right.heartbeatAt) - Date.parse(left.heartbeatAt)
199
+ || right.sessionId.localeCompare(left.sessionId));
200
+ return last === undefined ? null : { sessionId: last.sessionId, generation: last.generation,
201
+ state: last.state, endReason: last.extensions?.endReason ?? null };
202
+ }
203
+
182
204
  async function listLiveSessions({ participantId, workspaceId, now = clock.now() }) {
183
205
  const resolved = workspaceId ?? store.workspaceId;
184
206
  const snapshot = resolved === undefined ? null
@@ -198,6 +220,7 @@ export function createSessionService(ports) {
198
220
  resumeSession,
199
221
  heartbeatSession,
200
222
  closeSession,
223
+ lastSessionOf,
201
224
  listLiveSessions,
202
225
  locateSession: locate,
203
226
  ensureMaterialised: options => ensureMaterialised(ports, options),