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.
- package/README.md +3 -1
- package/bin/entrypoints/acc-bootstrap.mjs +6 -48
- package/bin/entrypoints/acc-claude-channel.mjs +47 -157
- package/docs/ADAPTER_AUTHORING.md +41 -10
- package/docs/ARCHITECTURE.md +28 -13
- package/docs/CAPABILITIES.md +19 -16
- package/docs/CLI.md +60 -14
- package/docs/CONCEPTS.md +12 -4
- package/docs/CONFIGURATION.md +18 -11
- package/docs/GETTING_STARTED.md +15 -12
- package/docs/GLOSSARY.md +2 -1
- package/docs/HOW_IT_WORKS.md +8 -4
- package/docs/PROTOCOL.md +56 -6
- package/docs/RELEASING.md +3 -2
- package/docs/SECURITY_MODEL.md +14 -1
- package/docs/TROUBLESHOOTING.md +64 -27
- package/docs/UPGRADING.md +49 -8
- package/docs/WHY_ACC.md +5 -3
- package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/skills/acc/SKILL.md +8 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +14 -73
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +29 -71
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.282-product-evidence.json +161 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.282.json +28 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/inbox-wake-2.1.282.json +44 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +5 -2
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +8 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +25 -30
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +6 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-contract.mjs +10 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-delivery.mjs +254 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-endpoint.mjs +166 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-registry.mjs +71 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-settings.mjs +109 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +25 -31
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +8 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +8 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +8 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +8 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +15 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/src/events.mjs +16 -3
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +4 -3
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-activation.mjs +4 -14
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +23 -11
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +5 -1
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +8 -6
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +8 -23
- package/node_modules/@agents-can-communicate/cli/src/install-delivery-consent.mjs +3 -4
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +39 -10
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +7 -43
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +5 -3
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +3 -4
- package/node_modules/@agents-can-communicate/cli/src/native-delivery-status.mjs +2 -2
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/conversations.mjs +2 -2
- package/node_modules/@agents-can-communicate/core/src/next-turn-delivery.mjs +21 -4
- package/node_modules/@agents-can-communicate/core/src/receipts.mjs +66 -10
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +25 -2
- package/node_modules/@agents-can-communicate/core/src/status.mjs +38 -1
- package/node_modules/@agents-can-communicate/core/src/sync.mjs +11 -2
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +24 -7
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +6 -8
- package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +24 -11
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +21 -12
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +19 -5
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +3 -7
- package/node_modules/@agents-can-communicate/installer/src/index.mjs +4 -7
- package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +23 -61
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +7 -1
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +15 -5
- package/node_modules/@agents-can-communicate/installer/src/shell-bootstrap.mjs +10 -133
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +5 -3
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +6 -1
- package/package.json +1 -1
- package/bin/acc-bootstrap.mjs +0 -7
- package/bin/acc-claude-channel.mjs +0 -10
- package/bin/entrypoints/claude-channel-binding.mjs +0 -46
- package/bin/entrypoints/claude-channel-stdio.mjs +0 -29
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.mcp.json +0 -8
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/channel.mjs +0 -381
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +0 -232
- 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,
|
|
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
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
|
43
|
-
//
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
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:
|
|
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
|
-
...(
|
|
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
|
-
:
|
|
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
|
-
|
|
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-
|
|
10
|
+
"acc", "acc-hook", "acc-mcp", "acc-antigravity-relay",
|
|
11
11
|
]);
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
+ "
|
|
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") {
|
|
@@ -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
|
-
|
|
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),
|