agents-can-communicate 0.10.4 → 0.11.1
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 +29 -21
- package/bin/acc-indicator.mjs +5 -0
- package/bin/entrypoints/acc-antigravity-relay.mjs +2 -2
- package/bin/entrypoints/acc-hook.mjs +4 -24
- package/bin/entrypoints/acc-mcp.mjs +8 -6
- package/bin/entrypoints/acc.mjs +22 -11
- package/bin/entrypoints/antigravity-relay-binding.mjs +1 -1
- package/docs/CAPABILITIES.md +3 -0
- package/docs/CLI.md +34 -7
- package/docs/MCP.md +42 -2
- package/docs/PROTOCOL.md +19 -0
- package/docs/STATUS_INDICATOR.md +133 -0
- package/docs/TROUBLESHOOTING.md +2 -2
- package/docs/WHY_ACC.md +14 -8
- 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 +29 -5
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/adapter.mjs +3 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/indicator-install.mjs +80 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/indicator-render.mjs +8 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/install.mjs +7 -4
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/hooks/indicator.mjs +83 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +29 -5
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +1 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +16 -5
- 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 +29 -5
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +29 -5
- 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 +29 -5
- package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +4 -0
- package/node_modules/@agents-can-communicate/adapter-grok/src/indicator-install.mjs +30 -0
- package/node_modules/@agents-can-communicate/adapter-grok/src/indicator.mjs +23 -0
- package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +9 -5
- 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 +29 -5
- package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +4 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/indicator-install.mjs +30 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/indicator.mjs +17 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +12 -6
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +1 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +5 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/indicator-command.mjs +14 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/indicator-text.mjs +13 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt-writer.mjs +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/offer-observation-order.mjs +12 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/offer-observation-writer.mjs +37 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/offer-observation.mjs +47 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/process-table.mjs +40 -27
- package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-indicator.mjs +141 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-scan.mjs +99 -0
- package/node_modules/@agents-can-communicate/cli/package.json +8 -1
- package/node_modules/@agents-can-communicate/cli/src/adapter-catalog.mjs +33 -0
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +5 -3
- package/node_modules/@agents-can-communicate/cli/src/claim-command.mjs +64 -0
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +17 -11
- package/node_modules/@agents-can-communicate/cli/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/cli/src/indicator-command.mjs +65 -0
- package/node_modules/@agents-can-communicate/cli/src/indicator-previous-command.mjs +84 -0
- package/node_modules/@agents-can-communicate/cli/src/indicator-previous-windows.mjs +8 -0
- package/node_modules/@agents-can-communicate/cli/src/indicator.mjs +112 -0
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +4 -0
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +38 -110
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +19 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +5 -3
- package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +1 -1
- package/node_modules/@agents-can-communicate/cli/src/recorded-text.mjs +32 -0
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/claim-selectors.mjs +62 -0
- package/node_modules/@agents-can-communicate/core/src/claims.mjs +88 -81
- package/node_modules/@agents-can-communicate/core/src/next-turn-delivery.mjs +2 -1
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +5 -2
- package/node_modules/@agents-can-communicate/core/src/sync.mjs +2 -1
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +19 -7
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/client-pid.mjs +23 -11
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +66 -26
- package/node_modules/@agents-can-communicate/installer/package.json +3 -2
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +3 -0
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +2 -1
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +11 -2
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/mcp-server/src/claim-tools.mjs +56 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/input-validator.mjs +9 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +7 -8
- package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +27 -9
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +3 -2
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/session-read.mjs +13 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/state-reads.mjs +24 -7
- package/package.json +3 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { assertPortableId } from "@agents-can-communicate/protocol";
|
|
4
|
+
import { effectiveCapabilities, loadNativeAttempt, loadSessionBinding, loadOfferObservation }
|
|
5
|
+
from "@agents-can-communicate/adapter-sdk";
|
|
6
|
+
import { readDeliveryBindingRecord, readSessionRecord } from "@agents-can-communicate/storage-filesystem";
|
|
7
|
+
import { readInstalledLivePolicyState } from "@agents-can-communicate/installer/live-policy";
|
|
8
|
+
import { loadAdapter } from "./adapter-catalog.mjs";
|
|
9
|
+
import { canonicalManagerRoot, managedDirectory, readManagedJson } from "./managed-runtime/state.mjs";
|
|
10
|
+
import { platformDataHome, runtimePaths } from "./runtime-paths.mjs";
|
|
11
|
+
|
|
12
|
+
const receptionText = {
|
|
13
|
+
automatic: "This chat can receive addressed requests without another user prompt. A busy client may queue them.",
|
|
14
|
+
turn: "Messages reach this chat on its next normal turn.",
|
|
15
|
+
inbox: "Messages are available when the agent reads acc inbox; no polling schedule is implied.",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function result({ health = "ready", reception = null, reasonCode = null,
|
|
19
|
+
detail, action = null, leaseCurrent = null }) {
|
|
20
|
+
const qualifier = reception && reception !== "automatic" ? ` · ${reception}` : "";
|
|
21
|
+
return { health, reception, reasonCode, leaseCurrent,
|
|
22
|
+
label: `ACC ${health === "ready" ? "●" : "!"}${qualifier}`,
|
|
23
|
+
detail: [detail, receptionText[reception]].filter(Boolean).join(" "), action };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const indicatorFailure = (reasonCode = "state_unreadable") => result({
|
|
27
|
+
health: "problem", reasonCode,
|
|
28
|
+
detail: reasonCode === "not_registered" ? "This chat is not registered with ACC."
|
|
29
|
+
: "ACC could not read this chat's local integration state.",
|
|
30
|
+
action: reasonCode === "not_registered"
|
|
31
|
+
? "Send a prompt to run the session hooks. If ACC is still absent, run acc doctor and check the plugin is enabled."
|
|
32
|
+
: "Run acc doctor in this project to identify and repair the local state problem.",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const actions = {
|
|
36
|
+
client_session_embedded: "This session has no shared delivery service. Run acc doctor to see the launch option and how to open a supported session.",
|
|
37
|
+
client_process_unknown: "Send a prompt: each prompt looks for the client process again. If this stays, run acc doctor.",
|
|
38
|
+
transport_permission_denied: "Allow the sending command to access the local delivery channel; acc doctor shows the affected session.",
|
|
39
|
+
handshake_version_mismatch: "Update the client's delivery service to a supported version; acc doctor shows the version in use.",
|
|
40
|
+
native_policy_off: "Automatic delivery is disabled in ACC settings. Run acc install --delivery actionable to enable it.",
|
|
41
|
+
inbound_approval_required: "Approve the pending inbound request in the receiving client, or review its incoming-message setting.",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** A display observation, never CLI ownership. Native ids select one saved room
|
|
45
|
+
* and one binding; there is no roster scan, Git probe, store open, socket probe,
|
|
46
|
+
* lease refresh, heartbeat, update worker or managed-runtime process lease. */
|
|
47
|
+
export async function readIndicator({ adapterId, nativeSessionId,
|
|
48
|
+
dataHome = platformDataHome(), now = new Date().toISOString() }) {
|
|
49
|
+
try {
|
|
50
|
+
const adapter = await loadAdapter(adapterId);
|
|
51
|
+
if (!adapter || typeof nativeSessionId !== "string" || !nativeSessionId
|
|
52
|
+
|| nativeSessionId.length > 4096) return indicatorFailure("not_registered");
|
|
53
|
+
const key = createHash("sha256").update(JSON.stringify([adapterId, nativeSessionId])).digest("hex");
|
|
54
|
+
const accRoot = await canonicalManagerRoot(path.join(dataHome, "acc"));
|
|
55
|
+
const rooms = path.join(accRoot, "native-workspaces");
|
|
56
|
+
if (!await managedDirectory(rooms)) return indicatorFailure("not_registered");
|
|
57
|
+
const room = await readManagedJson(path.join(rooms, `${key}.json`));
|
|
58
|
+
if (!room) return indicatorFailure("not_registered");
|
|
59
|
+
if (room.schemaVersion !== 1 || room.adapterId !== adapterId
|
|
60
|
+
|| room.nativeSessionId !== nativeSessionId) throw new Error("invalid room");
|
|
61
|
+
assertPortableId(room.workspaceId, "workspace id");
|
|
62
|
+
const root = runtimePaths({ dataHome: path.dirname(accRoot), workspaceId: room.workspaceId }).root;
|
|
63
|
+
const owner = await loadSessionBinding({ runtimeDir: root, harnessSessionId: nativeSessionId });
|
|
64
|
+
if (!owner) return indicatorFailure("not_registered");
|
|
65
|
+
const input = { root, workspaceId: room.workspaceId, sessionId: owner.accSessionId };
|
|
66
|
+
const session = await readSessionRecord(input);
|
|
67
|
+
if (session?.state !== "open" || session.generation !== owner.generation
|
|
68
|
+
|| session.harness !== adapterId) return indicatorFailure("not_registered");
|
|
69
|
+
const [binding, handshake, policy, offer] = await Promise.all([
|
|
70
|
+
readDeliveryBindingRecord(input),
|
|
71
|
+
loadNativeAttempt({ runtimeDir: root, harnessSessionId: nativeSessionId, ...owner }),
|
|
72
|
+
readInstalledLivePolicyState({ dataHome: path.dirname(accRoot), adapterId }),
|
|
73
|
+
loadOfferObservation({ runtimeDir: root, sessionId: owner.accSessionId, generation: owner.generation }),
|
|
74
|
+
]);
|
|
75
|
+
const attempt = offer && (!handshake || Date.parse(offer.at) >= Date.parse(handshake.at)) ? offer : handshake;
|
|
76
|
+
const current = await readSessionRecord(input);
|
|
77
|
+
if (current?.state !== "open" || current.generation !== owner.generation) {
|
|
78
|
+
return indicatorFailure("not_registered");
|
|
79
|
+
}
|
|
80
|
+
const capabilities = effectiveCapabilities(adapter, {
|
|
81
|
+
clientName: owner.clientName ?? adapter.client.certificationName,
|
|
82
|
+
clientVersion: owner.clientVersion, platform: owner.platform,
|
|
83
|
+
});
|
|
84
|
+
const fallback = capabilities.delivery.nextTurn ? "turn" : "inbox";
|
|
85
|
+
const bound = binding?.generation === owner.generation && binding.adapterId === adapterId
|
|
86
|
+
&& binding.retiredAt == null && binding.availableModes.includes("livePush");
|
|
87
|
+
const leaseCurrent = bound ? Date.parse(binding.leaseUntil) > Date.parse(now) : null;
|
|
88
|
+
if (["missing", "invalid", "unavailable"].includes(policy.policyStatus)) {
|
|
89
|
+
return result({ health: "problem", reception: fallback, reasonCode: "installation_unavailable",
|
|
90
|
+
detail: "ACC cannot find a valid delivery setting for this integration.",
|
|
91
|
+
action: `Run acc install --adapter ${adapterId} to repair the integration.`, leaseCurrent });
|
|
92
|
+
}
|
|
93
|
+
if (policy.policy === "off") return result({ reception: fallback, reasonCode: "native_policy_off",
|
|
94
|
+
detail: "Automatic delivery is disabled in ACC settings.", leaseCurrent });
|
|
95
|
+
if (attempt?.state === "degraded") return result({ health: "problem", reception: fallback,
|
|
96
|
+
reasonCode: attempt.reasonCode ?? "handshake_failed", leaseCurrent,
|
|
97
|
+
detail: "ACC could not establish automatic delivery for this chat.",
|
|
98
|
+
action: actions[attempt.reasonCode]
|
|
99
|
+
?? "Run acc doctor in this project for the channel failure and the client's recovery steps.",
|
|
100
|
+
});
|
|
101
|
+
// Lease expiry alone is neither disconnection nor an operation in progress.
|
|
102
|
+
// The delivery router verifies the endpoint and renews it on the next send.
|
|
103
|
+
if (bound && binding.livePolicy !== "off") return result({ reception: "automatic", leaseCurrent });
|
|
104
|
+
if (attempt?.state === "unsupported" || !capabilities.delivery.livePush) {
|
|
105
|
+
return result({ reception: fallback, reasonCode: "native_delivery_unsupported",
|
|
106
|
+
detail: "This client session does not support automatic wake-up." });
|
|
107
|
+
}
|
|
108
|
+
return result({ health: "problem", reception: fallback, reasonCode: "native_unbound",
|
|
109
|
+
detail: "This chat has no automatic delivery channel.",
|
|
110
|
+
action: "Send a prompt to let ACC establish the channel; if it remains unavailable, run acc doctor." });
|
|
111
|
+
} catch { return indicatorFailure(); }
|
|
112
|
+
}
|
|
@@ -224,6 +224,9 @@ export function actedOn(result) {
|
|
|
224
224
|
}
|
|
225
225
|
|
|
226
226
|
export async function runInstallCommand({ options, runtime, action = "install" }) {
|
|
227
|
+
if (options.indicator !== undefined && !["on", "off"].includes(options.indicator)) {
|
|
228
|
+
throw new AccError(EXIT.USAGE, "--indicator must be on or off");
|
|
229
|
+
}
|
|
227
230
|
const adapters = selectAdapters(options.adapter);
|
|
228
231
|
const home = options.home ?? runtime.env?.HOME ?? homedir();
|
|
229
232
|
const { data: dataHome } = platformPaths({ platform: runtime.platform,
|
|
@@ -261,6 +264,7 @@ export async function runInstallCommand({ options, runtime, action = "install" }
|
|
|
261
264
|
? await decideDelivery({ options, detected, recorded, runtime, dryRun })
|
|
262
265
|
: { deliveryByAdapter: {}, asked: [], notes: [] };
|
|
263
266
|
const plan = planInstallation({ adapters, detected, context, action, recorded,
|
|
267
|
+
indicator: options.indicator,
|
|
264
268
|
accVersion, allowDowngrade: options.downgrade === true, requested,
|
|
265
269
|
deliveryByAdapter: decided.deliveryByAdapter,
|
|
266
270
|
deliveryDecisionByAdapter: decided.deliveryDecisionByAdapter,
|
|
@@ -5,10 +5,6 @@
|
|
|
5
5
|
import { AccError, CONFIG_FILENAME, EXIT, GENERIC_MESSAGE_KINDS, VALID_OBLIGATIONS,
|
|
6
6
|
failure, ok }
|
|
7
7
|
from "@agents-can-communicate/protocol";
|
|
8
|
-
import { createCoordinationService } from "@agents-can-communicate/core";
|
|
9
|
-
import { shellQuote } from "@agents-can-communicate/adapter-sdk";
|
|
10
|
-
import { describeNativeReason } from "@agents-can-communicate/installer";
|
|
11
|
-
import { openFilesystemStore } from "@agents-can-communicate/storage-filesystem";
|
|
12
8
|
|
|
13
9
|
import { parseArgs, positiveNumber } from "./args.mjs";
|
|
14
10
|
|
|
@@ -16,54 +12,13 @@ import { parseArgs, positiveNumber } from "./args.mjs";
|
|
|
16
12
|
// with the argument already half-applied.
|
|
17
13
|
const usage = message => new AccError(EXIT.USAGE, message);
|
|
18
14
|
import { commandHelpText, describeCommand, describeCommands, helpText } from "./help.mjs";
|
|
19
|
-
import {
|
|
20
|
-
import { runConfigCommand } from "./config-command.mjs";
|
|
21
|
-
import { ALL_ADAPTERS, runInstallCommand } from "./install-command.mjs";
|
|
22
|
-
import { runDoctor } from "./doctor-command.mjs";
|
|
23
|
-
import { runManagementDoctor } from "./managed-runtime/diagnostics.mjs";
|
|
24
|
-
import { runStoreMigration } from "./store-migration-command.mjs";
|
|
15
|
+
import { loadAdapters } from "./adapter-catalog.mjs";
|
|
25
16
|
import { createGitProbe } from "./git-probe.mjs";
|
|
26
|
-
import { canonicalClaim } from "./claim-spelling.mjs";
|
|
27
17
|
import { platformDataHome, runtimePaths } from "./runtime-paths.mjs";
|
|
28
18
|
import { resolveOwner } from "./session-owner.mjs";
|
|
29
|
-
import { discoverWorkspace } from "./workspace-discovery.mjs";
|
|
30
|
-
import { resolveSavedHookWorkspace } from "./hook-workspace.mjs";
|
|
31
19
|
|
|
32
20
|
const DEFAULT_CADENCE_MS = 30_000;
|
|
33
21
|
|
|
34
|
-
/**
|
|
35
|
-
* The claim a caller means when they name a resource instead of an id.
|
|
36
|
-
*
|
|
37
|
-
* `acc claim` takes `--resource`; `acc release` took `--claim`. The asymmetry
|
|
38
|
-
* cost every caller - a person and an agent alike - a round trip through
|
|
39
|
-
* `acc status --json` to find an id they never chose. The id is still accepted,
|
|
40
|
-
* and is still the precise answer when two sessions hold the same resource and
|
|
41
|
-
* an authority is releasing someone else's.
|
|
42
|
-
*/
|
|
43
|
-
async function claimOn(options, context) {
|
|
44
|
-
if (options.resource === undefined) {
|
|
45
|
-
throw new AccError(EXIT.USAGE,
|
|
46
|
-
"release needs the claim to give back: --resource is what you claimed, "
|
|
47
|
-
+ "--claim is its id");
|
|
48
|
-
}
|
|
49
|
-
const resource = await canonicalClaim(options.resource, context.descriptor);
|
|
50
|
-
const { claims } = await context.service.collectStatus({});
|
|
51
|
-
// An authority releasing another session's claim is not looking for its own.
|
|
52
|
-
const mine = claims.filter(claim => claim.resource === resource
|
|
53
|
-
&& (options.authority !== undefined || claim.ownerSessionId === options.session));
|
|
54
|
-
|
|
55
|
-
if (mine.length === 0) {
|
|
56
|
-
throw new AccError(EXIT.DATA, `no claim on ${resource} to release`,
|
|
57
|
-
{ resource, held: claims.map(claim => claim.resource) });
|
|
58
|
-
}
|
|
59
|
-
if (mine.length > 1) {
|
|
60
|
-
throw new AccError(EXIT.DATA,
|
|
61
|
-
`${mine.length} claims on ${resource}; name the one you mean with --claim`,
|
|
62
|
-
{ resource, claims: mine.map(claim => claim.claimId) });
|
|
63
|
-
}
|
|
64
|
-
return mine[0].claimId;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
22
|
/**
|
|
68
23
|
* Where this workspace's state lives, without reading a byte of it.
|
|
69
24
|
*
|
|
@@ -72,6 +27,10 @@ async function claimOn(options, context) {
|
|
|
72
27
|
* looking is safe.
|
|
73
28
|
*/
|
|
74
29
|
async function locateContext(options, runtime) {
|
|
30
|
+
// Workspace discovery reads through the store's no-follow opens, so it is
|
|
31
|
+
// loaded by the commands that use a workspace, never by `version` (#270).
|
|
32
|
+
const [{ discoverWorkspace }, { resolveSavedHookWorkspace }] = await Promise.all([
|
|
33
|
+
import("./workspace-discovery.mjs"), import("./hook-workspace.mjs")]);
|
|
75
34
|
const dataHome = runtime.dataHome ?? platformDataHome({ platform: runtime.platform,
|
|
76
35
|
env: runtime.env });
|
|
77
36
|
const discovery = {
|
|
@@ -92,6 +51,10 @@ async function locateContext(options, runtime) {
|
|
|
92
51
|
}
|
|
93
52
|
|
|
94
53
|
async function withService({ descriptor, paths, dataHome }, runtime) {
|
|
54
|
+
// Loaded here rather than at the top: `version`, `help` and the commands
|
|
55
|
+
// that work on a machine never open a store (#270).
|
|
56
|
+
const [{ openFilesystemStore }, { createCoordinationService }] = await Promise.all([
|
|
57
|
+
import("@agents-can-communicate/storage-filesystem"), import("@agents-can-communicate/core")]);
|
|
95
58
|
const store = await openFilesystemStore({ root: paths.root, clock: runtime.clock,
|
|
96
59
|
ids: runtime.ids, workspaceId: descriptor.id });
|
|
97
60
|
const service = createCoordinationService({ store, clock: runtime.clock, ids: runtime.ids });
|
|
@@ -153,35 +116,10 @@ function obligationFor(kind, explicit, addressed) {
|
|
|
153
116
|
return obligation;
|
|
154
117
|
}
|
|
155
118
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
? `sent a wake to ${item.recipientParticipantId} via ${item.transport}, which its session holds `
|
|
161
|
-
+ "for approval because it bypasses permission prompts; the message arrives with its next turn"
|
|
162
|
-
: item.outcome === "woken"
|
|
163
|
-
? `woke ${item.recipientParticipantId} via ${item.transport}; the message arrives with its next turn`
|
|
164
|
-
: item.outcome === "queued"
|
|
165
|
-
? item.errorCode === "transport_permission_denied"
|
|
166
|
-
? "live offer blocked by sender permissions; message remains queued; retry the same command "
|
|
167
|
-
+ "through approved execution"
|
|
168
|
-
+ (typeof message.clientMessageId === "string"
|
|
169
|
-
? ` with --client-message-id ${shellQuote(message.clientMessageId)}` : " with its original retry key")
|
|
170
|
-
: item.errorCode === "no_live_transport"
|
|
171
|
-
? `${item.recipientParticipantId} has no live transport`
|
|
172
|
-
+ `${item.nativeReasonCode ? ` (${describeNativeReason(item.nativeReasonCode,
|
|
173
|
-
{ launchOption: item.nativeLaunchOption })})` : ""}`
|
|
174
|
-
+ "; the message waits in its inbox"
|
|
175
|
-
: item.errorCode === "recipient_offline" && item.endReason === "clear"
|
|
176
|
-
? `${item.recipientParticipantId}'s conversation was cleared (/clear); the message waits `
|
|
177
|
-
+ "until that conversation resumes"
|
|
178
|
-
: item.errorCode === "recipient_offline"
|
|
179
|
-
? `${item.recipientParticipantId} has no open session; the message waits until it starts `
|
|
180
|
-
+ "or resumes one"
|
|
181
|
-
: `live offer unavailable (${item.errorCode ?? "durable_fallback"})`
|
|
182
|
-
: `delivery already ${item.outcome}`);
|
|
183
|
-
return [`recorded ${message.messageId}`, ...diagnostics].join("; ");
|
|
184
|
-
}
|
|
119
|
+
// What a sent message met, in words. Its diagnostics load with the first
|
|
120
|
+
// message a command sends, not with `acc version` (#270).
|
|
121
|
+
const describeRecorded = async (message, delivery) =>
|
|
122
|
+
(await import("./recorded-text.mjs")).recordedText(message, delivery);
|
|
185
123
|
|
|
186
124
|
export async function recordAndOffer({ record, router, selectMessage = value => value }) {
|
|
187
125
|
const recorded = await record();
|
|
@@ -212,7 +150,7 @@ export async function recordAndOffer({ record, router, selectMessage = value =>
|
|
|
212
150
|
* A live binding wakes on the router's own rule: a current lease, or a lapsed
|
|
213
151
|
* one the adapter can re-verify. Without that the next send is durable.
|
|
214
152
|
*/
|
|
215
|
-
export function presenceBreakdown(participants = [], adapters =
|
|
153
|
+
export function presenceBreakdown(participants = [], adapters = []) {
|
|
216
154
|
const nextTurn = new Set(adapters.filter(adapter => adapter.capabilities?.delivery?.nextTurn === true)
|
|
217
155
|
.map(adapter => adapter.id));
|
|
218
156
|
const refreshes = new Set(adapters.filter(adapter => typeof adapter.refreshNativeSession === "function")
|
|
@@ -245,13 +183,13 @@ export function describePresence({ live = 0, stale = 0 } = {},
|
|
|
245
183
|
return `${live} live (${parts.join("; ")})`;
|
|
246
184
|
}
|
|
247
185
|
|
|
248
|
-
export function describeStatus(status) {
|
|
186
|
+
export function describeStatus(status, adapters = []) {
|
|
249
187
|
// `live` counts everyone present, which includes sessions gone stale - a
|
|
250
188
|
// client that exited without its session being closed keeps a record that
|
|
251
189
|
// stops being answered but does not disappear. Printing the number alone
|
|
252
190
|
// said "1 live" about a workspace where the last agent had left minutes
|
|
253
191
|
// before, which is the one thing a person reads this line to find out.
|
|
254
|
-
const line = `${describePresence(status.counts, presenceBreakdown(status.participants))}; `
|
|
192
|
+
const line = `${describePresence(status.counts, presenceBreakdown(status.participants, adapters))}; `
|
|
255
193
|
+ `${status.counts.claims} claim(s); `
|
|
256
194
|
+ `protection ${status.protection}`;
|
|
257
195
|
// A transport accepting a message is not a model reading it. Said only when
|
|
@@ -327,25 +265,8 @@ const HANDLERS = Object.freeze({
|
|
|
327
265
|
return { data: intent, text: `intent: ${intent.summary}` };
|
|
328
266
|
},
|
|
329
267
|
|
|
330
|
-
claim: async
|
|
331
|
-
|
|
332
|
-
generation: options.generation,
|
|
333
|
-
resource: await canonicalClaim(options.resource, context.descriptor),
|
|
334
|
-
mode: options.mode ?? "exclusive", enforcement: options.enforcement ?? "advisory",
|
|
335
|
-
reason: options.reason ?? "unspecified",
|
|
336
|
-
leaseSeconds: options.lease ? positiveNumber(options.lease, "lease") : undefined,
|
|
337
|
-
descriptor: context.descriptor });
|
|
338
|
-
return { data: claim, text: `claimed ${claim.resource}` };
|
|
339
|
-
},
|
|
340
|
-
|
|
341
|
-
release: async ({ options, context }) => {
|
|
342
|
-
const claimId = options.claim ?? await claimOn(options, context);
|
|
343
|
-
const request = { claimId, sessionId: options.session,
|
|
344
|
-
generation: options.generation, authority: options.authority, reason: options.reason };
|
|
345
|
-
if (options.authority === undefined) await context.service.releaseClaim(request);
|
|
346
|
-
else await context.service.forceReleaseClaim(request);
|
|
347
|
-
return { data: { claimId }, text: `released ${claimId}` };
|
|
348
|
-
},
|
|
268
|
+
claim: async input => (await import("./claim-command.mjs")).runClaimCommand(input),
|
|
269
|
+
release: async input => (await import("./claim-command.mjs")).runReleaseCommand(input),
|
|
349
270
|
|
|
350
271
|
message: async ({ options, context }) => {
|
|
351
272
|
const kind = options.type ?? "note";
|
|
@@ -360,7 +281,7 @@ const HANDLERS = Object.freeze({
|
|
|
360
281
|
subject: options.subject, body: options.body, descriptor: context.descriptor }) });
|
|
361
282
|
const message = routed.recorded;
|
|
362
283
|
return { data: { message, delivery: routed.delivery },
|
|
363
|
-
text:
|
|
284
|
+
text: await describeRecorded(message, routed.delivery) };
|
|
364
285
|
},
|
|
365
286
|
|
|
366
287
|
inbox: async ({ options, context }) => {
|
|
@@ -387,7 +308,7 @@ const HANDLERS = Object.freeze({
|
|
|
387
308
|
subject: options.subject, clientMessageId: clientMessageId(options, context) }) });
|
|
388
309
|
const result = routed.recorded;
|
|
389
310
|
return { data: { message: result.reply, receipt: result.receipt, delivery: routed.delivery },
|
|
390
|
-
text: `${
|
|
311
|
+
text: `${await describeRecorded(result.reply, routed.delivery)}; acknowledged ${result.receipt.messageId}` };
|
|
391
312
|
},
|
|
392
313
|
|
|
393
314
|
request: async ({ options, context }) => {
|
|
@@ -405,7 +326,7 @@ const HANDLERS = Object.freeze({
|
|
|
405
326
|
}) });
|
|
406
327
|
const message = routed.recorded;
|
|
407
328
|
return { data: { message, delivery: routed.delivery },
|
|
408
|
-
text:
|
|
329
|
+
text: await describeRecorded(message, routed.delivery) };
|
|
409
330
|
},
|
|
410
331
|
|
|
411
332
|
ack: async ({ options, context }) => {
|
|
@@ -424,17 +345,20 @@ const HANDLERS = Object.freeze({
|
|
|
424
345
|
remaining: options.remaining ?? [], blockers: options.blocker ?? [] }) });
|
|
425
346
|
const handoff = routed.recorded;
|
|
426
347
|
return { data: { message: handoff.message, delivery: routed.delivery },
|
|
427
|
-
text:
|
|
348
|
+
text: await describeRecorded(handoff.message, routed.delivery) };
|
|
428
349
|
},
|
|
429
350
|
|
|
430
351
|
status: async ({ options, context, runtime }) => {
|
|
431
352
|
await registerNativeSessions(runtime, context);
|
|
432
353
|
const status = await context.service.collectStatus({
|
|
433
354
|
participantId: options.participant, all: options.all === true });
|
|
434
|
-
|
|
355
|
+
// Only the clients present here say how their idle sessions are reached.
|
|
356
|
+
const adapters = await loadAdapters((status.participants ?? []).map(item => item.harness));
|
|
357
|
+
return { data: status, text: describeStatus(status, Object.values(adapters)) };
|
|
435
358
|
},
|
|
436
359
|
|
|
437
360
|
config: async ({ options, runtime }) => {
|
|
361
|
+
const { runConfigCommand } = await import("./config-command.mjs");
|
|
438
362
|
const result = await runConfigCommand({ subcommand: options.subcommand,
|
|
439
363
|
cwd: options.cwd ?? runtime.cwd,
|
|
440
364
|
// A pipe is not a person. Without a TTY there is nobody to answer, so the
|
|
@@ -461,11 +385,11 @@ const HANDLERS = Object.freeze({
|
|
|
461
385
|
text: result.written ? `wrote ${result.file}` : `not written: ${result.file}` };
|
|
462
386
|
},
|
|
463
387
|
|
|
464
|
-
install: async ({ options, runtime }) =>
|
|
465
|
-
runInstallCommand({ options, runtime, action: "install" }),
|
|
388
|
+
install: async ({ options, runtime }) => (await import("./install-command.mjs"))
|
|
389
|
+
.runInstallCommand({ options, runtime, action: "install" }),
|
|
466
390
|
|
|
467
|
-
uninstall: async ({ options, runtime }) =>
|
|
468
|
-
runInstallCommand({ options, runtime, action: "uninstall" }),
|
|
391
|
+
uninstall: async ({ options, runtime }) => (await import("./install-command.mjs"))
|
|
392
|
+
.runInstallCommand({ options, runtime, action: "uninstall" }),
|
|
469
393
|
|
|
470
394
|
prune: async ({ options, context }) => {
|
|
471
395
|
const result = await context.service.prune({
|
|
@@ -485,9 +409,12 @@ const HANDLERS = Object.freeze({
|
|
|
485
409
|
return { data: result, text };
|
|
486
410
|
},
|
|
487
411
|
|
|
488
|
-
doctor: async input => input.options.migrateStore
|
|
412
|
+
doctor: async input => input.options.migrateStore
|
|
413
|
+
? (await import("./store-migration-command.mjs")).runStoreMigration(input)
|
|
414
|
+
: (await import("./doctor-command.mjs")).runDoctor(input),
|
|
489
415
|
|
|
490
|
-
update: async ({ options, runtime }) =>
|
|
416
|
+
update: async ({ options, runtime }) => (await import("./update-command.mjs"))
|
|
417
|
+
.runUpdateCommand({ options, runtime }),
|
|
491
418
|
|
|
492
419
|
help: async ({ options }) => {
|
|
493
420
|
if (options.helpCommand === undefined) {
|
|
@@ -549,8 +476,9 @@ export async function main(argv, runtime) {
|
|
|
549
476
|
const options = context === null ? parsed.options
|
|
550
477
|
: await resolveOwner({ command: parsed.command, options: parsed.options,
|
|
551
478
|
context, env: runtime.env });
|
|
552
|
-
const
|
|
553
|
-
|
|
479
|
+
const handler = managementDoctor
|
|
480
|
+
? (await import("./managed-runtime/diagnostics.mjs")).runManagementDoctor : HANDLERS[parsed.command];
|
|
481
|
+
const { data, text, error: outcome } = await handler({ options, context, runtime });
|
|
554
482
|
// A handler may have done real work and still failed: `acc install` writes
|
|
555
483
|
// for the clients it could and reports the one it could not. The data is
|
|
556
484
|
// printed either way, and the command still fails.
|
|
@@ -32,6 +32,25 @@ export function invokedDirectly(url) {
|
|
|
32
32
|
catch { return false; }
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** The display is an observation, not an admitted runtime user. A stable
|
|
36
|
+
* launcher resolves the active generation on every refresh, so an old plugin
|
|
37
|
+
* never retains a path that a same-contract update may reclaim. A concurrent
|
|
38
|
+
* replacement fails open and the next display refresh tries the new pointer. */
|
|
39
|
+
export async function runIndicatorEntry({ managerRoot }) {
|
|
40
|
+
try {
|
|
41
|
+
const root = await canonicalManagerRoot(managerRoot);
|
|
42
|
+
const control = await readControl(root);
|
|
43
|
+
if (!control) throw new Error("indicator runtime missing");
|
|
44
|
+
await import(pathToFileURL(path.join(control.active.root, "bin", "acc-indicator.mjs")).href);
|
|
45
|
+
} catch {
|
|
46
|
+
const report = { health: "problem", reception: null, reasonCode: "indicator_unavailable",
|
|
47
|
+
label: "ACC !", detail: "ACC's installed indicator is unavailable.",
|
|
48
|
+
action: "Run acc update to finish runtime activation, or acc install --indicator on to repair it." };
|
|
49
|
+
process.stdout.write(process.argv.includes("--json") ? JSON.stringify(report) + "\n"
|
|
50
|
+
: "ACC ! · acc doctor\n");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
35
54
|
function unavailable(kind) {
|
|
36
55
|
// Never reflect control file content or payloads into a client diagnostic.
|
|
37
56
|
if (kind === "acc-hook" || kind === "acc-antigravity-relay") {
|
|
@@ -16,6 +16,7 @@ async function durableFile(file, bytes, mode = 0o600) {
|
|
|
16
16
|
export const stablePaths = root => ({
|
|
17
17
|
cli: path.join(root, "bin", "acc.mjs"), runner: path.join(root, "bin", "acc-hook.mjs"),
|
|
18
18
|
antigravityRelay: path.join(root, "bin", "acc-antigravity-relay.mjs"),
|
|
19
|
+
indicatorRunner: path.join(root, "bin", "acc-indicator.mjs"),
|
|
19
20
|
});
|
|
20
21
|
|
|
21
22
|
/** Publish immutable launcher modules, then atomically replace each tiny entry.
|
|
@@ -47,9 +48,10 @@ export async function writeLaunchers(root, packageRoot) {
|
|
|
47
48
|
} finally { await removeTree(staging); }
|
|
48
49
|
const bin = path.join(root, "bin");
|
|
49
50
|
await managedDirectory(bin, { create: true });
|
|
50
|
-
for (const kind of ENTRY_KINDS) {
|
|
51
|
-
const
|
|
52
|
-
|
|
51
|
+
for (const kind of [...ENTRY_KINDS, "acc-indicator"]) {
|
|
52
|
+
const entry = kind === "acc-indicator" ? "runIndicatorEntry" : "runEntry";
|
|
53
|
+
const bytes = `#!/usr/bin/env node\nimport { ${entry} } from "../launchers/${id}/entry.mjs";\n`
|
|
54
|
+
+ `await ${entry}(${JSON.stringify({ kind, packageRoot, managerRoot: root, managedRequired: true })});\n`;
|
|
53
55
|
const temporary = path.join(bin, `.${randomUUID()}.tmp`);
|
|
54
56
|
try {
|
|
55
57
|
await durableFile(temporary, bytes, 0o700);
|
|
@@ -99,7 +99,7 @@ export async function updateNativeSessions(adapters, { service, status, root, no
|
|
|
99
99
|
const failures = {
|
|
100
100
|
transport_permission_denied: "this command cannot access the local delivery channel; "
|
|
101
101
|
+ "check the sender's command permissions",
|
|
102
|
-
client_process_unknown: "the client process could not be identified;
|
|
102
|
+
client_process_unknown: "the client process could not be identified; the next prompt looks for it again",
|
|
103
103
|
handshake_failed: "the session handshake failed; check the client's integration/channel setup",
|
|
104
104
|
handshake_timeout: "the session handshake timed out; check the local channel and retry on the next turn",
|
|
105
105
|
// Since the contract gate shipped, this reason means the version now serving
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { shellQuote } from "@agents-can-communicate/adapter-sdk";
|
|
2
|
+
import { describeNativeReason } from "@agents-can-communicate/installer";
|
|
3
|
+
|
|
4
|
+
export function recordedText(message, delivery) {
|
|
5
|
+
const diagnostics = delivery.map(item => item.outcome === "offered"
|
|
6
|
+
? `live offered to ${item.recipientParticipantId} via ${item.transport}`
|
|
7
|
+
: item.outcome === "woken" && item.pendingApproval === true
|
|
8
|
+
? `sent a wake to ${item.recipientParticipantId} via ${item.transport}, which its session holds `
|
|
9
|
+
+ "for approval because it bypasses permission prompts; the message arrives with its next turn"
|
|
10
|
+
: item.outcome === "woken"
|
|
11
|
+
? `woke ${item.recipientParticipantId} via ${item.transport}; the message arrives with its next turn`
|
|
12
|
+
: item.outcome === "queued"
|
|
13
|
+
? item.errorCode === "transport_permission_denied"
|
|
14
|
+
? "live offer blocked by sender permissions; message remains queued; retry the same command "
|
|
15
|
+
+ "through approved execution"
|
|
16
|
+
+ (typeof message.clientMessageId === "string"
|
|
17
|
+
? ` with --client-message-id ${shellQuote(message.clientMessageId)}` : " with its original retry key")
|
|
18
|
+
: item.errorCode === "no_live_transport"
|
|
19
|
+
? `${item.recipientParticipantId} has no live transport`
|
|
20
|
+
+ `${item.nativeReasonCode ? ` (${describeNativeReason(item.nativeReasonCode,
|
|
21
|
+
{ launchOption: item.nativeLaunchOption })})` : ""}`
|
|
22
|
+
+ "; the message waits in its inbox"
|
|
23
|
+
: item.errorCode === "recipient_offline" && item.endReason === "clear"
|
|
24
|
+
? `${item.recipientParticipantId}'s conversation was cleared (/clear); the message waits `
|
|
25
|
+
+ "until that conversation resumes"
|
|
26
|
+
: item.errorCode === "recipient_offline"
|
|
27
|
+
? `${item.recipientParticipantId} has no open session; the message waits until it starts `
|
|
28
|
+
+ "or resumes one"
|
|
29
|
+
: `live offer unavailable (${item.errorCode ?? "durable_fallback"})`
|
|
30
|
+
: `delivery already ${item.outcome}`);
|
|
31
|
+
return [`recorded ${message.messageId}`, ...diagnostics].join("; ");
|
|
32
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { AccError, EXIT, assertMatchableResource, assertPortableId,
|
|
2
|
+
assertResourceUri, normaliseResource } from "@agents-can-communicate/protocol";
|
|
3
|
+
|
|
4
|
+
const usage = message => { throw new AccError(EXIT.USAGE, message); };
|
|
5
|
+
|
|
6
|
+
export function uniqueList(value, name, normalise = item => item) {
|
|
7
|
+
if (!Array.isArray(value) || value.length === 0) usage(`${name} must be a nonempty array`);
|
|
8
|
+
const result = value.map(item => {
|
|
9
|
+
if (typeof item !== "string" || item.length === 0) usage(`${name} must contain nonempty strings`);
|
|
10
|
+
return normalise(item);
|
|
11
|
+
});
|
|
12
|
+
if (new Set(result).size !== result.length) usage(`${name} contains duplicate selectors`);
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function claimResources(value) {
|
|
17
|
+
return uniqueList(value, "resources", resource => {
|
|
18
|
+
assertMatchableResource(resource, usage);
|
|
19
|
+
const normalised = normaliseResource(resource);
|
|
20
|
+
try { return assertResourceUri(normalised, "resource"); }
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (!(error instanceof AccError)) throw error;
|
|
23
|
+
throw new AccError(EXIT.USAGE, error.message, error.details);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function releaseSelectors(input) {
|
|
29
|
+
const present = ["claimIds", "resources", "all"].filter(key => input[key] !== undefined);
|
|
30
|
+
if (present.length !== 1 || (present[0] === "all" && input.all !== true)) {
|
|
31
|
+
usage("release needs exactly one of claimIds, resources, or all: true");
|
|
32
|
+
}
|
|
33
|
+
if (input.authority !== undefined) usage("batch release does not accept force-release authority");
|
|
34
|
+
if (input.all === true) return { all: true };
|
|
35
|
+
if (input.resources !== undefined) return { resources: claimResources(input.resources) };
|
|
36
|
+
return { claimIds: uniqueList(input.claimIds, "claimIds", id => {
|
|
37
|
+
try { return assertPortableId(id, "claim id"); }
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (!(error instanceof AccError)) throw error;
|
|
40
|
+
throw new AccError(EXIT.USAGE, error.message, error.details);
|
|
41
|
+
}
|
|
42
|
+
}) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function selectReleaseClaims(tx, selectors, sessionId) {
|
|
46
|
+
if (selectors.all === true) return tx.list("claim", claim => claim.ownerSessionId === sessionId);
|
|
47
|
+
if (selectors.claimIds !== undefined) return selectors.claimIds.map(claimId => {
|
|
48
|
+
const claim = tx.get("claim", claimId);
|
|
49
|
+
if (claim === null) throw new AccError(EXIT.CONFLICT, "the claim does not exist", { claimId });
|
|
50
|
+
return claim;
|
|
51
|
+
});
|
|
52
|
+
const claims = tx.list("claim");
|
|
53
|
+
return selectors.resources.map(resource => {
|
|
54
|
+
const mine = claims.filter(claim => claim.resource === resource && claim.ownerSessionId === sessionId);
|
|
55
|
+
if (mine.length === 0) throw new AccError(EXIT.DATA, `no claim on ${resource} to release`,
|
|
56
|
+
{ resource, held: claims.map(claim => claim.resource) });
|
|
57
|
+
if (mine.length > 1) throw new AccError(EXIT.DATA,
|
|
58
|
+
`${mine.length} claims on ${resource}; name the one you mean with --claim`,
|
|
59
|
+
{ resource, claims: mine.map(claim => claim.claimId) });
|
|
60
|
+
return mine[0];
|
|
61
|
+
});
|
|
62
|
+
}
|