@50arms/platform-mcp 0.0.0-stage → 0.1.436
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 +114 -2
- package/dist/access-request.js +101 -0
- package/dist/agent-gate.js +69 -0
- package/dist/board-id.js +27 -0
- package/dist/bounded-retry.js +25 -0
- package/dist/checkout-docs-root.js +54 -0
- package/dist/clear-control.js +77 -0
- package/dist/compaction-control.js +142 -0
- package/dist/credential.js +104 -0
- package/dist/danxbot-api-examples.js +97 -0
- package/dist/dashboard-identity.js +14 -0
- package/dist/design-screenshot.js +138 -0
- package/dist/digest-hold.js +67 -0
- package/dist/docs-tools.js +140 -0
- package/dist/durable-write.js +56 -0
- package/dist/entrypoint.js +35 -0
- package/dist/forbidden-hint.js +23 -0
- package/dist/guarded-calls.js +21 -0
- package/dist/handlers.js +385 -0
- package/dist/heartbeat-reporter.js +156 -0
- package/dist/http-client.js +190 -0
- package/dist/index.js +1128 -0
- package/dist/json-body.js +12 -0
- package/dist/key-revoked-halt.js +30 -0
- package/dist/lifecycle.js +113 -0
- package/dist/listen.js +999 -0
- package/dist/listener-reminder.js +119 -0
- package/dist/machine-daemon/autostart.js +160 -0
- package/dist/machine-daemon/beat.js +77 -0
- package/dist/machine-daemon/cli.js +42 -0
- package/dist/machine-daemon/install.js +168 -0
- package/dist/machine-daemon/state.js +64 -0
- package/dist/machine-daemon/version.js +13 -0
- package/dist/machine-daemon.js +553 -0
- package/dist/machine-toolkit.js +460 -0
- package/dist/mini-app-build.js +159 -0
- package/dist/mini-app-checkout.js +215 -0
- package/dist/mini-app-output.js +111 -0
- package/dist/mini-app-publish.js +154 -0
- package/dist/mini-app-restore.js +175 -0
- package/dist/one-line.js +25 -0
- package/dist/pacing-relay.js +16 -0
- package/dist/permission-request.js +134 -0
- package/dist/permission-watch.js +145 -0
- package/dist/plan-events-relay.js +572 -0
- package/dist/plan-events-tool.js +9 -0
- package/dist/plugin-publish.js +38 -0
- package/dist/plugin-screenshot.js +81 -0
- package/dist/request-bound.js +21 -0
- package/dist/restart-notice.js +271 -0
- package/dist/result-limit.js +31 -0
- package/dist/role-instruction-file.js +36 -0
- package/dist/route-board-scope.generated.js +93 -0
- package/dist/route-board-scope.js +71 -0
- package/dist/session-access.js +322 -0
- package/dist/session-connection.js +264 -0
- package/dist/session-control-file.js +58 -0
- package/dist/session-key.js +125 -0
- package/dist/session-options.js +60 -0
- package/dist/session-stats.js +235 -0
- package/dist/session-stream.js +345 -0
- package/dist/session-title.js +129 -0
- package/dist/shutdown.js +38 -0
- package/dist/stop-fixes.js +50 -0
- package/dist/subagents-live.js +162 -0
- package/dist/terms-halt.js +39 -0
- package/dist/text-limit.js +24 -0
- package/dist/tool-access.js +114 -0
- package/dist/tool-defs.js +58 -0
- package/dist/tool-surface.js +59 -0
- package/dist/transcript-lineage.js +105 -0
- package/dist/types.js +45 -0
- package/dist/urgent-records.js +47 -0
- package/dist/version-floor.js +146 -0
- package/package.json +51 -4
package/dist/one-line.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-2735 — the ONE way this package renders human text into an agent-facing
|
|
3
|
+
* line: the dashboard-event stream's notification line and the problems
|
|
4
|
+
* reminder both go through it, so they can never disagree about how a
|
|
5
|
+
* statement looks.
|
|
6
|
+
*
|
|
7
|
+
* Every whitespace run (newlines included) collapses to a single space, because
|
|
8
|
+
* a notification is one line and a reminder quotes statements inline. The
|
|
9
|
+
* optional cap counts CODE POINTS, never UTF-16 units: `String#slice` can cut
|
|
10
|
+
* an emoji or any astral character in half and leave a lone surrogate, which
|
|
11
|
+
* renders as garbage. A capped string ends with "…" so truncation is visible.
|
|
12
|
+
*/
|
|
13
|
+
export function oneLine(text, maxCodePoints) {
|
|
14
|
+
const collapsed = text.replace(/\s+/g, " ");
|
|
15
|
+
if (maxCodePoints === undefined)
|
|
16
|
+
return collapsed;
|
|
17
|
+
const points = Array.from(collapsed);
|
|
18
|
+
return points.length > maxCodePoints ? `${points.slice(0, maxCodePoints).join("")}…` : collapsed;
|
|
19
|
+
}
|
|
20
|
+
/** The prefix of every line the stream listener emits for the session to read. */
|
|
21
|
+
export const LINE_PREFIX = "[platform stream]";
|
|
22
|
+
/** `text` as one prefixed listener line, optionally cut (the one template of a listener line, shared with whoever must know its length). */
|
|
23
|
+
export function prefixedLine(text, maxCodePoints) {
|
|
24
|
+
return oneLine(`${LINE_PREFIX} ${text}`, maxCodePoints);
|
|
25
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { prefixedLine } from "./one-line.js";
|
|
2
|
+
/**
|
|
3
|
+
* DX-4341 - the longest pacing line the stream listener relays whole. `formatPacingLine` (`listen.ts`) cuts a pacing line, PREFIX INCLUDED and
|
|
4
|
+
* after whitespace collapses, to this many code points and ends it with an ellipsis, which would drop the tail of a message first (an
|
|
5
|
+
* orchestrator's duties, the end of the pause note format). The dashboard renders the message and refuses one that would be cut
|
|
6
|
+
* (`pacingRelayLength`, used by `src/team-pacing/pacing-message.ts`), so the cut and the refusal measure the very same line.
|
|
7
|
+
*/
|
|
8
|
+
export const PACING_RELAY_MAX_CODE_POINTS = 1000;
|
|
9
|
+
/** The line the listener emits for pacing text `text`: prefixed, on one line, cut at the relay maximum. */
|
|
10
|
+
export function pacingRelayLine(text) {
|
|
11
|
+
return prefixedLine(text, PACING_RELAY_MAX_CODE_POINTS);
|
|
12
|
+
}
|
|
13
|
+
/** How many code points the listener's line for `text` has before the cut. */
|
|
14
|
+
export function pacingRelayLength(text) {
|
|
15
|
+
return Array.from(prefixedLine(text)).length;
|
|
16
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { defaultSleep } from "./session-access.js";
|
|
2
|
+
import { McpOutdatedError } from "./version-floor.js";
|
|
3
|
+
const PERMISSION_REQUESTS_BASE_PATH = "/api/permission-requests";
|
|
4
|
+
const APPROVAL_REQUIRED_INSTRUCTION = "Show the person the approval URL and the confirm code (they check the code matches, then approve). Keep working on anything " +
|
|
5
|
+
"else: this call has returned and nothing waits. The request stays open until this session ends, however long they take. If " +
|
|
6
|
+
"they approve, this process picks the grant up in the background and a later call simply succeeds with the same key (no new " +
|
|
7
|
+
"key, no re-auth). If you are blocked without it, raise a problem on the card; a person sees problems in Needs You.";
|
|
8
|
+
const APPROVAL_PENDING_INSTRUCTION = "This permission is already asked for and not yet decided; the request stays open until this session ends. Remind the person " +
|
|
9
|
+
"of the confirm code and the approval URL; keep working on anything else. A grant is picked up in the background and a later " +
|
|
10
|
+
"call simply succeeds with the same key. If you are blocked without it, raise a problem on the card.";
|
|
11
|
+
/** The dedupe set: sorted and de-duplicated, so the order the agent listed them in never matters. */
|
|
12
|
+
function setOf(permissions) {
|
|
13
|
+
return [...new Set(permissions)].sort();
|
|
14
|
+
}
|
|
15
|
+
function isCreated(value) {
|
|
16
|
+
if (typeof value !== "object" || value === null)
|
|
17
|
+
return false;
|
|
18
|
+
const v = value;
|
|
19
|
+
return (typeof v.publicId === "string" &&
|
|
20
|
+
typeof v.confirmCode === "string" &&
|
|
21
|
+
typeof v.approvalUrl === "string" &&
|
|
22
|
+
typeof v.pollMs === "number");
|
|
23
|
+
}
|
|
24
|
+
/** A 429's own `retryAfterMs`, else 0 (the poll then waits its normal interval). */
|
|
25
|
+
function retryAfterMsOf(body) {
|
|
26
|
+
const ms = body?.retryAfterMs;
|
|
27
|
+
return typeof ms === "number" && Number.isFinite(ms) && ms > 0 ? ms : 0;
|
|
28
|
+
}
|
|
29
|
+
export class PermissionRequests {
|
|
30
|
+
deps;
|
|
31
|
+
pending = new Map();
|
|
32
|
+
queue = Promise.resolve();
|
|
33
|
+
sleep;
|
|
34
|
+
constructor(deps) {
|
|
35
|
+
this.deps = deps;
|
|
36
|
+
this.sleep = deps.sleep ?? defaultSleep;
|
|
37
|
+
}
|
|
38
|
+
ask(args) {
|
|
39
|
+
const run = this.queue.then(() => this.askNow(args));
|
|
40
|
+
this.queue = run.catch(() => undefined);
|
|
41
|
+
return run;
|
|
42
|
+
}
|
|
43
|
+
async askNow(args) {
|
|
44
|
+
const permissions = setOf(args.permissions);
|
|
45
|
+
const key = JSON.stringify(permissions);
|
|
46
|
+
const existing = this.pending.get(key);
|
|
47
|
+
// DX-4418: a revoked or signed-out session never asks and never reads as pending; `guard` below answers its halt.
|
|
48
|
+
if (existing !== undefined && this.deps.access.isSignedIn()) {
|
|
49
|
+
return { answer: this.answer("approval_pending", existing) };
|
|
50
|
+
}
|
|
51
|
+
const guarded = await this.deps.access.guard(() => this.deps.request({ method: "POST", basePath: PERMISSION_REQUESTS_BASE_PATH, path: "", body: { permissions, reason: args.reason } }));
|
|
52
|
+
if ("halt" in guarded)
|
|
53
|
+
return { halt: guarded.halt };
|
|
54
|
+
const result = guarded.result;
|
|
55
|
+
// The dashboard's own 4xx (400, 409, 429 ...) goes back as it came: no retry, no default.
|
|
56
|
+
if (!result.ok)
|
|
57
|
+
return { result };
|
|
58
|
+
if (!isCreated(result.body)) {
|
|
59
|
+
throw new Error(`[platform-mcp] POST ${PERMISSION_REQUESTS_BASE_PATH} answered without the request fields: ${JSON.stringify(result.body)}`);
|
|
60
|
+
}
|
|
61
|
+
const created = result.body;
|
|
62
|
+
this.pending.set(key, created);
|
|
63
|
+
void this.pollUntilDecided(key, created);
|
|
64
|
+
return { answer: this.answer("approval_required", created) };
|
|
65
|
+
}
|
|
66
|
+
answer(state, request) {
|
|
67
|
+
return {
|
|
68
|
+
state,
|
|
69
|
+
approvalUrl: request.approvalUrl,
|
|
70
|
+
confirmCode: request.confirmCode,
|
|
71
|
+
instruction: state === "approval_required" ? APPROVAL_REQUIRED_INSTRUCTION : APPROVAL_PENDING_INSTRUCTION,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Polls until the request is decided or expired, then frees its set so a later call may ask again. Every ending is final: a
|
|
76
|
+
* halt (revoked, signed out, key lapsed) stops it silently, since the session no longer holds a key that could be granted.
|
|
77
|
+
*/
|
|
78
|
+
async pollUntilDecided(key, request) {
|
|
79
|
+
try {
|
|
80
|
+
await this.poll(request);
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
this.deps.log?.(`[platform-mcp] permission request poll ended: ${err.message}`);
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
if (this.pending.get(key) === request)
|
|
87
|
+
this.pending.delete(key);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// DX-4530: no give-up time - the request is open until it is decided or the key dies (the `guard` halt).
|
|
91
|
+
async poll(request) {
|
|
92
|
+
let waitMs = request.pollMs;
|
|
93
|
+
for (;;) {
|
|
94
|
+
await this.sleep(waitMs);
|
|
95
|
+
waitMs = request.pollMs;
|
|
96
|
+
let guarded;
|
|
97
|
+
try {
|
|
98
|
+
// `guard` runs before EVERY claim (DX-4418): a revoked or signed-out session answers its halt without a request.
|
|
99
|
+
guarded = await this.deps.access.guard(() => this.deps.requestOwn({ method: "POST", basePath: PERMISSION_REQUESTS_BASE_PATH, path: `/${request.publicId}/claim` }));
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
// An outdated MCP will never get a different answer; any other failure is retried until the request ends.
|
|
103
|
+
if (err instanceof McpOutdatedError)
|
|
104
|
+
throw err;
|
|
105
|
+
this.deps.log?.(`[platform-mcp] permission request poll failed, retrying: ${err.message}`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if ("halt" in guarded)
|
|
109
|
+
return;
|
|
110
|
+
const claim = guarded.result;
|
|
111
|
+
if (claim.status === 429) {
|
|
112
|
+
waitMs = Math.max(request.pollMs, retryAfterMsOf(claim.body));
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (claim.status === 404)
|
|
116
|
+
return; // unknown or long since deleted: nothing left to wait for
|
|
117
|
+
if (!claim.ok) {
|
|
118
|
+
throw new Error(`the dashboard refused the claim (${claim.status}): ${JSON.stringify(claim.body)}`);
|
|
119
|
+
}
|
|
120
|
+
const status = claim.body?.status;
|
|
121
|
+
switch (status) {
|
|
122
|
+
case "pending":
|
|
123
|
+
continue;
|
|
124
|
+
case "approved":
|
|
125
|
+
case "claimed":
|
|
126
|
+
case "denied":
|
|
127
|
+
case "expired":
|
|
128
|
+
return;
|
|
129
|
+
default:
|
|
130
|
+
throw new Error(`the dashboard answered the claim with an unknown status: ${JSON.stringify(claim.body)}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-4523 - how this process learns that its key's permissions changed while it runs: the dashboard's live stream.
|
|
3
|
+
*
|
|
4
|
+
* `/api/stream` ends every open stream of a key with `event: end {reason: "permissions_changed"}` the moment a person approves a
|
|
5
|
+
* grant for it, revokes one, or revokes the key (`closeSessionStreams`, `src/dashboard/stream-routes.ts`). This watcher holds one
|
|
6
|
+
* stream open on the key-self topic `key:self` (`src/dashboard/sse-topic-policy.ts`), which any key may subscribe to with no
|
|
7
|
+
* permission: a dispatch's agent role holds none of the team read-floor keys, yet is exactly the key that is granted one mid-session.
|
|
8
|
+
*
|
|
9
|
+
* Reconnect FIRST, then re-read the permissions (`sync`), after EVERY (re)connect, so a change that landed while the stream was down
|
|
10
|
+
* is applied and a change after the connect ends the stream. A failed sync is retried with bounded backoff, then logged: the
|
|
11
|
+
* stream stays up and the next connect syncs again. No polling: the only timers are the reconnect backoff after a drop and the sync
|
|
12
|
+
* retry. The key is read on every connect, so a replaced key is the one used.
|
|
13
|
+
*
|
|
14
|
+
* A refusal (a 4xx other than 408 and 429, which are retried like a drop) ends the watch: a 401 means the key is gone (the next dashboard call halts the session as usual) and a 403
|
|
15
|
+
* means the dashboard predates the topic or refuses this principal. A 5xx or a dropped connection is retried with backoff, which
|
|
16
|
+
* starts over once a connection proved healthy (stayed open a while, or ended by a permission change).
|
|
17
|
+
*/
|
|
18
|
+
import { SYNC_ATTEMPTS, retryBounded } from "./bounded-retry.js";
|
|
19
|
+
import { SseParser } from "./listen.js";
|
|
20
|
+
import { PLUGIN_BACKGROUND_HEADER } from "./session-options.js";
|
|
21
|
+
const STREAM_PATH = "/api/stream?topics=key:self";
|
|
22
|
+
const PERMISSIONS_CHANGED = "permissions_changed";
|
|
23
|
+
export const WATCH_INITIAL_BACKOFF_MS = 1_000;
|
|
24
|
+
export const WATCH_MAX_BACKOFF_MS = 30_000;
|
|
25
|
+
/** A connection that stayed open this long was healthy, so the next drop starts the backoff over. */
|
|
26
|
+
export const WATCH_HEALTHY_CONNECTION_MS = 20_000;
|
|
27
|
+
/** A 4xx that means "try again later", not "no": the stream is retried with backoff like a drop. */
|
|
28
|
+
const RETRYABLE_REFUSALS = new Set([408, 429]);
|
|
29
|
+
const defaultSleep = (ms) => new Promise((resolve) => {
|
|
30
|
+
setTimeout(resolve, ms).unref();
|
|
31
|
+
});
|
|
32
|
+
export class PermissionWatch {
|
|
33
|
+
deps;
|
|
34
|
+
running = false;
|
|
35
|
+
stopRequested = false;
|
|
36
|
+
controller = null;
|
|
37
|
+
fetchImpl;
|
|
38
|
+
sleep;
|
|
39
|
+
now;
|
|
40
|
+
constructor(deps) {
|
|
41
|
+
this.deps = deps;
|
|
42
|
+
this.fetchImpl = deps.fetch ?? fetch;
|
|
43
|
+
this.sleep = deps.sleep ?? defaultSleep;
|
|
44
|
+
this.now = deps.now ?? Date.now;
|
|
45
|
+
}
|
|
46
|
+
/** Start watching. A watch already running is left alone, so every sign-in may call this. */
|
|
47
|
+
start() {
|
|
48
|
+
if (this.running)
|
|
49
|
+
return;
|
|
50
|
+
this.running = true;
|
|
51
|
+
this.stopRequested = false;
|
|
52
|
+
void this.run().finally(() => {
|
|
53
|
+
this.running = false;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/** Stop watching and close the open stream, so the process can exit. */
|
|
57
|
+
stop() {
|
|
58
|
+
this.stopRequested = true;
|
|
59
|
+
this.controller?.abort();
|
|
60
|
+
}
|
|
61
|
+
async run() {
|
|
62
|
+
let attempt = 0;
|
|
63
|
+
while (!this.stopRequested) {
|
|
64
|
+
const token = this.deps.token();
|
|
65
|
+
if (token === "")
|
|
66
|
+
return;
|
|
67
|
+
const outcome = await this.connectOnce(token);
|
|
68
|
+
if (outcome.kind === "refused")
|
|
69
|
+
return;
|
|
70
|
+
if (outcome.kind === "changed" || outcome.healthy)
|
|
71
|
+
attempt = 0;
|
|
72
|
+
// A permission change reconnects at once; a drop waits out the backoff first.
|
|
73
|
+
if (outcome.kind === "dropped" && !this.stopRequested) {
|
|
74
|
+
await this.sleep(Math.min(WATCH_MAX_BACKOFF_MS, WATCH_INITIAL_BACKOFF_MS * 2 ** attempt));
|
|
75
|
+
attempt += 1;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async connectOnce(token) {
|
|
80
|
+
const controller = new AbortController();
|
|
81
|
+
this.controller = controller;
|
|
82
|
+
const openedAt = this.now();
|
|
83
|
+
let opened = false;
|
|
84
|
+
const healthy = () => opened && this.now() - openedAt >= WATCH_HEALTHY_CONNECTION_MS;
|
|
85
|
+
try {
|
|
86
|
+
const response = await this.fetchImpl(`${this.deps.dashboardUrl.replace(/\/+$/, "")}${STREAM_PATH}`, {
|
|
87
|
+
// DX-5354: the watch is this process's own work, never the agent acting, so neither its connect nor any reconnect extends the session's key.
|
|
88
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream", [PLUGIN_BACKGROUND_HEADER]: "1" },
|
|
89
|
+
signal: controller.signal,
|
|
90
|
+
});
|
|
91
|
+
if (response.status >= 400 && response.status < 500 && !RETRYABLE_REFUSALS.has(response.status)) {
|
|
92
|
+
this.deps.log?.(`[platform-mcp] the dashboard refused the permission stream (HTTP ${response.status}): a permission granted ` +
|
|
93
|
+
`mid-session shows its tools at the next plan_connect instead`);
|
|
94
|
+
await response.body?.cancel();
|
|
95
|
+
return { kind: "refused" };
|
|
96
|
+
}
|
|
97
|
+
if (!response.ok || response.body === null) {
|
|
98
|
+
await response.body?.cancel();
|
|
99
|
+
return { kind: "dropped", healthy: false };
|
|
100
|
+
}
|
|
101
|
+
opened = true;
|
|
102
|
+
await this.syncWithRetry();
|
|
103
|
+
const parser = new SseParser();
|
|
104
|
+
const decoder = new TextDecoder();
|
|
105
|
+
for await (const chunk of response.body) {
|
|
106
|
+
for (const message of parser.push(decoder.decode(chunk, { stream: true }))) {
|
|
107
|
+
if (message.event === "end" && endReason(message.data) === PERMISSIONS_CHANGED)
|
|
108
|
+
return { kind: "changed" };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { kind: "dropped", healthy: healthy() };
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
if (!this.stopRequested)
|
|
115
|
+
this.deps.log?.(`[platform-mcp] permission stream dropped: ${err.message}`);
|
|
116
|
+
return { kind: "dropped", healthy: healthy() };
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
controller.abort();
|
|
120
|
+
if (this.controller === controller)
|
|
121
|
+
this.controller = null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** One sync, retried with bounded backoff; a sync that keeps failing is logged and the stream stays up. */
|
|
125
|
+
async syncWithRetry() {
|
|
126
|
+
try {
|
|
127
|
+
await retryBounded(() => this.deps.sync(), {
|
|
128
|
+
attempts: SYNC_ATTEMPTS,
|
|
129
|
+
sleep: this.sleep,
|
|
130
|
+
shouldStop: () => this.stopRequested,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
this.deps.log?.(`[platform-mcp] tool list refresh failed after ${SYNC_ATTEMPTS} attempts, tools stay as they are until the next connect: ${err.message}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function endReason(data) {
|
|
139
|
+
try {
|
|
140
|
+
return JSON.parse(data).reason;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|