dsh-cherry-bridge 0.1.7 → 0.1.9
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.en.md +31 -17
- package/README.md +20 -12
- package/dist/index.mjs +201 -43
- package/docs/e2e-checklist.md +1 -1
- package/lib/client.js +2 -2
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +1 -1
- package/src/connection.ts +61 -0
- package/src/defaults.ts +23 -0
- package/src/tools.ts +264 -78
- package/ui/index.html +1 -1
- package/ui/src/i18n.ts +2 -2
package/dist/index.mjs
CHANGED
|
@@ -2274,6 +2274,11 @@ const INTERACTIONS_LIST_PAGE_CLAMP = Math.floor(RECORD_BUDGET_BYTES / 25088);
|
|
|
2274
2274
|
* BOUND instead of being split, hashed and carried to the peer, and so both
|
|
2275
2275
|
* parameters can declare `maxLength` honestly. ONE number for both faces: it
|
|
2276
2276
|
* bounds caller INPUT, not what a token means.
|
|
2277
|
+
*
|
|
2278
|
+
* The emit side enforces it too (#76): a stop point whose token would exceed
|
|
2279
|
+
* this is not named at all, because a token this tool refuses to replay is
|
|
2280
|
+
* worse than no token — `truncated` still says the walk was cut. One number,
|
|
2281
|
+
* judged in both directions.
|
|
2277
2282
|
*/
|
|
2278
2283
|
const MAX_RESUME_TOKEN_CHARS = 4096;
|
|
2279
2284
|
//#endregion
|
|
@@ -2690,6 +2695,13 @@ var DeviceConnection = class {
|
|
|
2690
2695
|
#transportRetryWaitMs;
|
|
2691
2696
|
#stateListeners = /* @__PURE__ */ new Set();
|
|
2692
2697
|
/**
|
|
2698
|
+
* Callers parked in {@link waitForSettled}. Separate from the state listeners
|
|
2699
|
+
* because their lifetime is ONE wait: a listener stays for the lifecycle, a
|
|
2700
|
+
* settle waiter removes itself the moment it is answered (or times out), and
|
|
2701
|
+
* mixing them would leave every answered wait in the multicast set.
|
|
2702
|
+
*/
|
|
2703
|
+
#settleWaiters = /* @__PURE__ */ new Set();
|
|
2704
|
+
/**
|
|
2693
2705
|
* Waits parked inside `#awaitReconnect`. close() drains them: a shutdown has
|
|
2694
2706
|
* no reconnect to wait for, and — the case that matters — a close() followed
|
|
2695
2707
|
* by a fresh connect() must not let an OLD lifecycle's replay ride the new
|
|
@@ -2814,6 +2826,46 @@ var DeviceConnection = class {
|
|
|
2814
2826
|
this.#stateListeners.add(cb);
|
|
2815
2827
|
}
|
|
2816
2828
|
/**
|
|
2829
|
+
* Waits for an attempt that is ALREADY IN FLIGHT to settle, bounded by
|
|
2830
|
+
* `timeoutMs`, and answers with the state it ends on.
|
|
2831
|
+
*
|
|
2832
|
+
* The only state this waits on is `connecting`: an attempt is running, and
|
|
2833
|
+
* its outcome is what the caller asked about. `connected` is answered at
|
|
2834
|
+
* once, and so are `offline` (parked in backoff), `disabled` and
|
|
2835
|
+
* `identity-mismatch` — no wait can change those, and waiting on them would
|
|
2836
|
+
* merely delay the caller's answer by the whole bound. `offline` is the
|
|
2837
|
+
* common case for a genuinely powered-off desktop, so it is the one that
|
|
2838
|
+
* matters: the tools stay fail-fast there.
|
|
2839
|
+
*
|
|
2840
|
+
* This NEVER starts an attempt — it observes one the pool already started.
|
|
2841
|
+
* That is the line the tool face has always drawn (a tool must not dial); the
|
|
2842
|
+
* wait only stops the tool from calling a live dial a failure.
|
|
2843
|
+
*
|
|
2844
|
+
* The bound expiring is not an error: the current state (`connecting`) is
|
|
2845
|
+
* returned, and the caller judges it exactly as it would have without the
|
|
2846
|
+
* wait.
|
|
2847
|
+
*/
|
|
2848
|
+
async waitForSettled(timeoutMs) {
|
|
2849
|
+
if (this.#state !== "connecting" || timeoutMs <= 0) return this.#state;
|
|
2850
|
+
return new Promise((resolve) => {
|
|
2851
|
+
let settled = false;
|
|
2852
|
+
let timer;
|
|
2853
|
+
const finish = (state) => {
|
|
2854
|
+
if (settled) return;
|
|
2855
|
+
settled = true;
|
|
2856
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
2857
|
+
this.#settleWaiters.delete(onState);
|
|
2858
|
+
resolve(state);
|
|
2859
|
+
};
|
|
2860
|
+
const onState = (state) => {
|
|
2861
|
+
if (state !== "connecting") finish(state);
|
|
2862
|
+
};
|
|
2863
|
+
timer = setTimeout(() => finish(this.#state), timeoutMs);
|
|
2864
|
+
this.#settleWaiters.add(onState);
|
|
2865
|
+
if (this.#state !== "connecting") finish(this.#state);
|
|
2866
|
+
});
|
|
2867
|
+
}
|
|
2868
|
+
/**
|
|
2817
2869
|
* Starts (or joins) the supervised lifecycle. Resolves once this cycle
|
|
2818
2870
|
* reaches `connected`; rejects with the cycle's failure. Transient
|
|
2819
2871
|
* failures keep being retried in the background by the backoff loop even
|
|
@@ -3303,6 +3355,11 @@ var DeviceConnection = class {
|
|
|
3303
3355
|
} catch (error) {
|
|
3304
3356
|
console.warn(`[cherry-remote] state listener threw for ${this.record.deviceId}`, error);
|
|
3305
3357
|
}
|
|
3358
|
+
for (const cb of [...this.#settleWaiters]) try {
|
|
3359
|
+
cb(next);
|
|
3360
|
+
} catch (error) {
|
|
3361
|
+
console.warn(`[cherry-remote] settle waiter threw for ${this.record.deviceId}`, error);
|
|
3362
|
+
}
|
|
3306
3363
|
}
|
|
3307
3364
|
};
|
|
3308
3365
|
//#endregion
|
|
@@ -5195,8 +5252,12 @@ var CherryToolEngine = class {
|
|
|
5195
5252
|
* ⑤): the candidate list is the answer. A MISS is a verdict too — see
|
|
5196
5253
|
* {@link CherryDeviceNotFound} — and it carries the ids the caller could have
|
|
5197
5254
|
* meant so the answer stays actionable.
|
|
5255
|
+
*
|
|
5256
|
+
* ASYNC because of the `connecting` wait below: the pool dials on first touch
|
|
5257
|
+
* and on every reconnect, so a tool called in that window used to be refused
|
|
5258
|
+
* for a connection that comes up a moment later (issue #99).
|
|
5198
5259
|
*/
|
|
5199
|
-
#resolve(device) {
|
|
5260
|
+
async #resolve(device) {
|
|
5200
5261
|
const resolution = this.#deps.store.resolve(device);
|
|
5201
5262
|
if ("ambiguous" in resolution) return {
|
|
5202
5263
|
error: "ambiguous-device",
|
|
@@ -5206,10 +5267,11 @@ var CherryToolEngine = class {
|
|
|
5206
5267
|
if ("notFound" in resolution) return this.#notFoundVerdict(device);
|
|
5207
5268
|
const connection = this.#deps.connect(resolution.deviceId);
|
|
5208
5269
|
this.#watchConnection(resolution.deviceId, connection);
|
|
5209
|
-
|
|
5270
|
+
const state = connection.state === "connecting" ? await connection.waitForSettled(this.#deps.deviceSettleWaitMs ?? 5e3) : connection.state;
|
|
5271
|
+
if (state !== "connected") return {
|
|
5210
5272
|
error: "device-unavailable",
|
|
5211
5273
|
device: resolution.deviceId,
|
|
5212
|
-
state
|
|
5274
|
+
state
|
|
5213
5275
|
};
|
|
5214
5276
|
return {
|
|
5215
5277
|
record: resolution,
|
|
@@ -5245,6 +5307,10 @@ var CherryToolEngine = class {
|
|
|
5245
5307
|
/**
|
|
5246
5308
|
* Resolve-or-verdict: every non-`cherry_devices` tool needs a live link.
|
|
5247
5309
|
*
|
|
5310
|
+
* `connecting` is waited out inside {@link #resolve} first, so the
|
|
5311
|
+
* `DeviceUnavailableError` below is only ever raised for a state the wait
|
|
5312
|
+
* could not improve on (bounded by `deviceSettleWaitMs`).
|
|
5313
|
+
*
|
|
5248
5314
|
* The failure faces are deliberately DIFFERENT (controller ruling ⑤): an
|
|
5249
5315
|
* ambiguous selector and a MISS are ANSWERS — the candidate list, or the
|
|
5250
5316
|
* known ids, returned in band, because the caller can act on them — while a
|
|
@@ -5257,8 +5323,8 @@ var CherryToolEngine = class {
|
|
|
5257
5323
|
* `cherry_devices` does not come through this method — it goes through
|
|
5258
5324
|
* {@link #selectRecords}, which returns the verdict in band.
|
|
5259
5325
|
*/
|
|
5260
|
-
#requireConnection(device) {
|
|
5261
|
-
const resolved = this.#resolve(device);
|
|
5326
|
+
async #requireConnection(device) {
|
|
5327
|
+
const resolved = await this.#resolve(device);
|
|
5262
5328
|
if (!this.#isVerdict(resolved)) return resolved;
|
|
5263
5329
|
if (resolved.error === "ambiguous-device") return resolved;
|
|
5264
5330
|
if (resolved.error === "not-found") {
|
|
@@ -5277,10 +5343,10 @@ var CherryToolEngine = class {
|
|
|
5277
5343
|
* replace it with a weaker, wrong one — a miss under a READ-ONLY archive
|
|
5278
5344
|
* would describe a store that still holds a valid token as "no such device".
|
|
5279
5345
|
*/
|
|
5280
|
-
#linkOr(device) {
|
|
5346
|
+
async #linkOr(device) {
|
|
5281
5347
|
const quarantined = this.#quarantineVerdict();
|
|
5282
5348
|
if (quarantined !== void 0) return { verdict: quarantined };
|
|
5283
|
-
const resolved = this.#requireConnection(device);
|
|
5349
|
+
const resolved = await this.#requireConnection(device);
|
|
5284
5350
|
if ("error" in resolved) return { verdict: resolved };
|
|
5285
5351
|
return { link: resolved };
|
|
5286
5352
|
}
|
|
@@ -5477,12 +5543,13 @@ var CherryToolEngine = class {
|
|
|
5477
5543
|
* #65). What a caller can act on is a token, so that is what is emitted.
|
|
5478
5544
|
*/
|
|
5479
5545
|
#listPage(page, binding) {
|
|
5480
|
-
const position = optionalString(page.nextCursor);
|
|
5546
|
+
const position = nameablePeerPosition(optionalString(page.nextCursor));
|
|
5547
|
+
const token = !page.truncated || position === void 0 ? void 0 : withinResumeTokenCeiling(encodeListResumeCursor(binding, position));
|
|
5481
5548
|
return {
|
|
5482
5549
|
items: page.items,
|
|
5483
5550
|
truncated: page.truncated,
|
|
5484
5551
|
...page.truncationReason === void 0 ? {} : { truncationReason: page.truncationReason },
|
|
5485
|
-
...
|
|
5552
|
+
...token === void 0 ? {} : { resumeCursor: token }
|
|
5486
5553
|
};
|
|
5487
5554
|
}
|
|
5488
5555
|
#devicesTool() {
|
|
@@ -5501,10 +5568,13 @@ var CherryToolEngine = class {
|
|
|
5501
5568
|
const selector = optionalString(args["device"]);
|
|
5502
5569
|
const quarantined = this.#quarantineVerdict();
|
|
5503
5570
|
if (quarantined !== void 0) return quarantined;
|
|
5504
|
-
if (selector === void 0)
|
|
5571
|
+
if (selector === void 0) {
|
|
5572
|
+
const records = this.#deps.store.list();
|
|
5573
|
+
return { devices: await Promise.all(records.map((record) => this.#deviceView(record))) };
|
|
5574
|
+
}
|
|
5505
5575
|
const selected = this.#selectRecords(selector);
|
|
5506
5576
|
if (!Array.isArray(selected)) return selected;
|
|
5507
|
-
return { devices: selected.map((record) => this.#deviceView(record)) };
|
|
5577
|
+
return { devices: await Promise.all(selected.map((record) => this.#deviceView(record))) };
|
|
5508
5578
|
}
|
|
5509
5579
|
});
|
|
5510
5580
|
}
|
|
@@ -5534,7 +5604,9 @@ var CherryToolEngine = class {
|
|
|
5534
5604
|
return [resolution];
|
|
5535
5605
|
}
|
|
5536
5606
|
/**
|
|
5537
|
-
* One device's view WITHOUT dialing
|
|
5607
|
+
* One device's view WITHOUT dialing — but after waiting out an attempt that is
|
|
5608
|
+
* already in flight, exactly as {@link #resolve} does, so a cold-start row
|
|
5609
|
+
* does not present a transient `connecting` as the device's answer.
|
|
5538
5610
|
*
|
|
5539
5611
|
* A disabled record is authoritative: the pooled connection is constructed
|
|
5540
5612
|
* with its own `record.enabled` snapshot and reports `disabled` only once
|
|
@@ -5542,10 +5614,11 @@ var CherryToolEngine = class {
|
|
|
5542
5614
|
* for a device whose connection was never started. Everything else falls back
|
|
5543
5615
|
* to the connection's own CLOSED state set.
|
|
5544
5616
|
*/
|
|
5545
|
-
#deviceView(record) {
|
|
5617
|
+
async #deviceView(record) {
|
|
5546
5618
|
let state = record.enabled ? "offline" : "disabled";
|
|
5547
5619
|
if (record.enabled) try {
|
|
5548
|
-
|
|
5620
|
+
const connection = this.#deps.connect(record.deviceId);
|
|
5621
|
+
state = connection.state === "connecting" ? await connection.waitForSettled(this.#deps.deviceSettleWaitMs ?? 5e3) : connection.state;
|
|
5549
5622
|
} catch (error) {
|
|
5550
5623
|
console.warn(`[cherry-remote] cherry_devices could not read the connection for ${record.deviceId}`, error);
|
|
5551
5624
|
}
|
|
@@ -5561,7 +5634,7 @@ var CherryToolEngine = class {
|
|
|
5561
5634
|
#agentsTool() {
|
|
5562
5635
|
return defineCherryTool({
|
|
5563
5636
|
name: "cherry_agents",
|
|
5564
|
-
description: "List the Cherry Studio agents available on a device. Paginated: `limit` bounds the TOTAL across pages (default 100, max 1000) and is aggregated internally. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out (`limit` is your ceiling, `safety-bound` the 1000-item internal one).
|
|
5637
|
+
description: "List the Cherry Studio agents available on a device. Paginated: `limit` bounds the TOTAL across pages (default 100, max 1000) and is aggregated internally. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). When the stop point can be named it carries `resumeCursor`: replay it as `cursor` with the same tool, device and filters to continue from that stop point (`limit` then bounds the next answer).",
|
|
5565
5638
|
parameters: {
|
|
5566
5639
|
type: "object",
|
|
5567
5640
|
properties: {
|
|
@@ -5581,7 +5654,7 @@ var CherryToolEngine = class {
|
|
|
5581
5654
|
additionalProperties: false
|
|
5582
5655
|
},
|
|
5583
5656
|
execute: async (args) => {
|
|
5584
|
-
const resolved = this.#linkOr(requiredString(args, "device"));
|
|
5657
|
+
const resolved = await this.#linkOr(requiredString(args, "device"));
|
|
5585
5658
|
if (resolved.link === void 0) return resolved.verdict;
|
|
5586
5659
|
const { connection, record } = resolved.link;
|
|
5587
5660
|
const binding = {
|
|
@@ -5604,7 +5677,7 @@ var CherryToolEngine = class {
|
|
|
5604
5677
|
#workspacesTool() {
|
|
5605
5678
|
return defineCherryTool({
|
|
5606
5679
|
name: "cherry_workspaces",
|
|
5607
|
-
description: "List an agent's REGISTERED workspaces, plus whether a system workspace exists. The system workspace has no enumerable id: it is not in `items`, and its identity is only observable by creating a session in it and reading it back. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out
|
|
5680
|
+
description: "List an agent's REGISTERED workspaces, plus whether a system workspace exists. The system workspace has no enumerable id: it is not in `items`, and its identity is only observable by creating a session in it and reading it back. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out; when the stop point can be named it carries `resumeCursor` — replay it as `cursor` with the same filters to continue.",
|
|
5608
5681
|
parameters: {
|
|
5609
5682
|
type: "object",
|
|
5610
5683
|
properties: {
|
|
@@ -5628,7 +5701,7 @@ var CherryToolEngine = class {
|
|
|
5628
5701
|
additionalProperties: false
|
|
5629
5702
|
},
|
|
5630
5703
|
execute: async (args) => {
|
|
5631
|
-
const resolved = this.#linkOr(requiredString(args, "device"));
|
|
5704
|
+
const resolved = await this.#linkOr(requiredString(args, "device"));
|
|
5632
5705
|
if (resolved.link === void 0) return resolved.verdict;
|
|
5633
5706
|
const { connection, record } = resolved.link;
|
|
5634
5707
|
const params = { agentId: requiredString(args, "agentId") };
|
|
@@ -5659,7 +5732,7 @@ var CherryToolEngine = class {
|
|
|
5659
5732
|
#sessionsTool() {
|
|
5660
5733
|
return defineCherryTool({
|
|
5661
5734
|
name: "cherry_sessions",
|
|
5662
|
-
description: "List an agent's sessions, optionally filtered by workspace. The `agentId` and `workspaceId` filters pass straight through to the peer. Paginated: `limit` bounds the TOTAL across pages (default 100, max 1000) and is aggregated internally. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out (`limit` is your ceiling, `safety-bound` the 1000-item internal one).
|
|
5735
|
+
description: "List an agent's sessions, optionally filtered by workspace. The `agentId` and `workspaceId` filters pass straight through to the peer. Paginated: `limit` bounds the TOTAL across pages (default 100, max 1000) and is aggregated internally. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). When the stop point can be named it carries `resumeCursor`: replay it as `cursor` with the same tool, device and filters to continue from that stop point (`limit` then bounds the next answer).",
|
|
5663
5736
|
parameters: {
|
|
5664
5737
|
type: "object",
|
|
5665
5738
|
properties: {
|
|
@@ -5687,7 +5760,7 @@ var CherryToolEngine = class {
|
|
|
5687
5760
|
additionalProperties: false
|
|
5688
5761
|
},
|
|
5689
5762
|
execute: async (args) => {
|
|
5690
|
-
const resolved = this.#linkOr(requiredString(args, "device"));
|
|
5763
|
+
const resolved = await this.#linkOr(requiredString(args, "device"));
|
|
5691
5764
|
if (resolved.link === void 0) return resolved.verdict;
|
|
5692
5765
|
const { connection, record } = resolved.link;
|
|
5693
5766
|
const agentId = optionalString(args["agentId"]);
|
|
@@ -5716,7 +5789,7 @@ var CherryToolEngine = class {
|
|
|
5716
5789
|
#readTool() {
|
|
5717
5790
|
return defineCherryTool({
|
|
5718
5791
|
name: "cherry_read",
|
|
5719
|
-
description: "Read a session's message history snapshot. Two-level baseline: `agent.sessions.get` supplies the history revision, `agent.messages.list` the messages, and each message's own revision the parts. Large part text is externalized by the peer and fetched and sha256-verified through `agent.content.read`. An unchanged message (same message revision) reuses its cached part LIST, fenced by that revision, so a repeated read of a quiet session costs no `agent.parts.list` RPC; externalized content is still fetched and sha256-verified on every read, so unverified bytes are never served from memory. A binary attachment is reported as `kind: \"file\"` with `name`/`mediaType`/`byteLength` and an EMPTY `content` — the bytes are deliberately not fetched into a text channel. Messages come NEWEST-first, so the first entry is the latest and `limit` drops the OLDEST messages — truncation never cuts inside a retained message's parts; a message whose own parts could not be fully read carries `incomplete`, and may carry `unavailableParts` when parts were unreadable. The answer is ALSO bounded by an output budget: `truncated` with `truncationReason: \"aggregate-bytes\"` means the walk stopped because the result reached its size limit, not because `limit` was reached — raising `limit` does NOT reach the messages left out (it only costs more round trips and stops at the same budget). THE OLDER MESSAGES ARE REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor`
|
|
5792
|
+
description: "Read a session's message history snapshot. Two-level baseline: `agent.sessions.get` supplies the history revision, `agent.messages.list` the messages, and each message's own revision the parts. Large part text is externalized by the peer and fetched and sha256-verified through `agent.content.read`. An unchanged message (same message revision) reuses its cached part LIST, fenced by that revision, so a repeated read of a quiet session costs no `agent.parts.list` RPC; externalized content is still fetched and sha256-verified on every read, so unverified bytes are never served from memory. A binary attachment is reported as `kind: \"file\"` with `name`/`mediaType`/`byteLength` and an EMPTY `content` — the bytes are deliberately not fetched into a text channel. Messages come NEWEST-first, so the first entry is the latest and `limit` drops the OLDEST messages — truncation never cuts inside a retained message's parts; a message whose own parts could not be fully read carries `incomplete`, and may carry `unavailableParts` when parts were unreadable. The answer is ALSO bounded by an output budget: `truncated` with `truncationReason: \"aggregate-bytes\"` means the walk stopped because the result reached its size limit, not because `limit` was reached — raising `limit` does NOT reach the messages left out (it only costs more round trips and stops at the same budget). THE OLDER MESSAGES ARE REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor` when the stop point can be named — absent when it cannot, e.g. an empty or malformed position, a token past the length ceiling, or no anchor id — and passing it back as `messagesResumeCursor` continues the walk from there (`limit` then bounds how many messages are returned FROM that point). The two token forms are `\"cursor:<peer position>\"` (the walk consumed its whole page window) and `\"after:<messageId>\"` (it stopped inside the window; the read restarts at the newest end and resumes strictly after that message). A token this tool did not emit is refused locally rather than forwarded, and an `after:` anchor that no longer exists in the history is an error, not an empty page. A `truncationReason` of \"limit\" is the caller's own ceiling and is reached by raising `limit`. Either way the newest message is always returned whole.",
|
|
5720
5793
|
parameters: {
|
|
5721
5794
|
type: "object",
|
|
5722
5795
|
properties: {
|
|
@@ -5732,7 +5805,7 @@ var CherryToolEngine = class {
|
|
|
5732
5805
|
type: "integer",
|
|
5733
5806
|
minimum: 1,
|
|
5734
5807
|
maximum: MAX_AGGREGATE_LIMIT,
|
|
5735
|
-
description: "Maximum number of messages to return in total across pages (default 100). Distinct from the output budget: the answer may stop earlier with `truncationReason: \"aggregate-bytes\"`, which a larger `limit` does not extend — the resume token is how older messages are reached."
|
|
5808
|
+
description: "Maximum number of messages to return in total across pages (default 100). Distinct from the output budget: the answer may stop earlier with `truncationReason: \"aggregate-bytes\"`, which a larger `limit` does not extend — the resume token, when one is returned, is how older messages are reached."
|
|
5736
5809
|
},
|
|
5737
5810
|
messagesResumeCursor: {
|
|
5738
5811
|
type: "string",
|
|
@@ -5745,7 +5818,7 @@ var CherryToolEngine = class {
|
|
|
5745
5818
|
},
|
|
5746
5819
|
execute: async (args) => {
|
|
5747
5820
|
const resume = parseMessageResume(args["messagesResumeCursor"]);
|
|
5748
|
-
const resolved = this.#linkOr(requiredString(args, "device"));
|
|
5821
|
+
const resolved = await this.#linkOr(requiredString(args, "device"));
|
|
5749
5822
|
if (resolved.link === void 0) return resolved.verdict;
|
|
5750
5823
|
const { connection, record } = resolved.link;
|
|
5751
5824
|
const sessionId = requiredString(args, "sessionId");
|
|
@@ -6165,7 +6238,7 @@ var CherryToolEngine = class {
|
|
|
6165
6238
|
additionalProperties: false
|
|
6166
6239
|
},
|
|
6167
6240
|
execute: async (args) => {
|
|
6168
|
-
const resolved = this.#linkOr(requiredString(args, "device"));
|
|
6241
|
+
const resolved = await this.#linkOr(requiredString(args, "device"));
|
|
6169
6242
|
if (resolved.link === void 0) return resolved.verdict;
|
|
6170
6243
|
const { record } = resolved.link;
|
|
6171
6244
|
const sessionId = requiredString(args, "sessionId");
|
|
@@ -6228,7 +6301,7 @@ var CherryToolEngine = class {
|
|
|
6228
6301
|
additionalProperties: false
|
|
6229
6302
|
},
|
|
6230
6303
|
execute: async (args) => {
|
|
6231
|
-
const resolved = this.#linkOr(requiredString(args, "device"));
|
|
6304
|
+
const resolved = await this.#linkOr(requiredString(args, "device"));
|
|
6232
6305
|
if (resolved.link === void 0) return resolved.verdict;
|
|
6233
6306
|
const { connection, record } = resolved.link;
|
|
6234
6307
|
const agentId = requiredString(args, "agentId");
|
|
@@ -6313,7 +6386,7 @@ var CherryToolEngine = class {
|
|
|
6313
6386
|
additionalProperties: false
|
|
6314
6387
|
},
|
|
6315
6388
|
execute: async (args) => {
|
|
6316
|
-
const resolved = this.#linkOr(requiredString(args, "device"));
|
|
6389
|
+
const resolved = await this.#linkOr(requiredString(args, "device"));
|
|
6317
6390
|
if (resolved.link === void 0) return resolved.verdict;
|
|
6318
6391
|
const { connection, record } = resolved.link;
|
|
6319
6392
|
const sessionId = requiredString(args, "sessionId");
|
|
@@ -6437,7 +6510,7 @@ var CherryToolEngine = class {
|
|
|
6437
6510
|
additionalProperties: false
|
|
6438
6511
|
},
|
|
6439
6512
|
execute: async (args) => {
|
|
6440
|
-
const resolved = this.#linkOr(requiredString(args, "device"));
|
|
6513
|
+
const resolved = await this.#linkOr(requiredString(args, "device"));
|
|
6441
6514
|
if (resolved.link === void 0) return resolved.verdict;
|
|
6442
6515
|
const { connection, record } = resolved.link;
|
|
6443
6516
|
const sessionId = requiredString(args, "sessionId");
|
|
@@ -6491,7 +6564,7 @@ var CherryToolEngine = class {
|
|
|
6491
6564
|
#interactionsTool() {
|
|
6492
6565
|
return defineCherryTool({
|
|
6493
6566
|
name: "cherry_interactions",
|
|
6494
|
-
description: "List the session's PENDING interactions (questions and approval decisions) that need an answer. The peer serves the full four-state set (pending/approved/denied/expired) — the pending filter is applied here. Each entry carries its `kind` (question|decision) and a summary; answer questions with cherry_respond's `response` form and decisions with `decision`. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out
|
|
6567
|
+
description: "List the session's PENDING interactions (questions and approval decisions) that need an answer. The peer serves the full four-state set (pending/approved/denied/expired) — the pending filter is applied here. Each entry carries its `kind` (question|decision) and a summary; answer questions with cherry_respond's `response` form and decisions with `decision`. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out; when the stop point can be named it carries `resumeCursor` — replay it as `cursor` from the same `sessionId` to continue.",
|
|
6495
6568
|
parameters: {
|
|
6496
6569
|
type: "object",
|
|
6497
6570
|
properties: {
|
|
@@ -6519,7 +6592,7 @@ var CherryToolEngine = class {
|
|
|
6519
6592
|
additionalProperties: false
|
|
6520
6593
|
},
|
|
6521
6594
|
execute: async (args) => {
|
|
6522
|
-
const resolved = this.#linkOr(requiredString(args, "device"));
|
|
6595
|
+
const resolved = await this.#linkOr(requiredString(args, "device"));
|
|
6523
6596
|
if (resolved.link === void 0) return resolved.verdict;
|
|
6524
6597
|
const { connection, record } = resolved.link;
|
|
6525
6598
|
const sessionId = requiredString(args, "sessionId");
|
|
@@ -6619,7 +6692,7 @@ var CherryToolEngine = class {
|
|
|
6619
6692
|
additionalProperties: false
|
|
6620
6693
|
},
|
|
6621
6694
|
execute: async (args) => {
|
|
6622
|
-
const resolved = this.#linkOr(requiredString(args, "device"));
|
|
6695
|
+
const resolved = await this.#linkOr(requiredString(args, "device"));
|
|
6623
6696
|
if (resolved.link === void 0) return resolved.verdict;
|
|
6624
6697
|
const { connection, record } = resolved.link;
|
|
6625
6698
|
const sessionId = requiredString(args, "sessionId");
|
|
@@ -6696,7 +6769,7 @@ var CherryToolEngine = class {
|
|
|
6696
6769
|
additionalProperties: false
|
|
6697
6770
|
},
|
|
6698
6771
|
execute: async (args) => {
|
|
6699
|
-
const resolved = this.#linkOr(requiredString(args, "device"));
|
|
6772
|
+
const resolved = await this.#linkOr(requiredString(args, "device"));
|
|
6700
6773
|
if (resolved.link === void 0) return resolved.verdict;
|
|
6701
6774
|
const { connection, record } = resolved.link;
|
|
6702
6775
|
const sessionId = requiredString(args, "sessionId");
|
|
@@ -7674,9 +7747,11 @@ var CherryToolEngine = class {
|
|
|
7674
7747
|
}
|
|
7675
7748
|
/**
|
|
7676
7749
|
* The timeout result: NOT a failure. Partial content read so far +, whenever a
|
|
7677
|
-
* stop point can be named honestly, a `messagesResumeCursor` token marking
|
|
7678
|
-
* the unread history begins
|
|
7679
|
-
* or
|
|
7750
|
+
* stop point can be named honestly, a `messagesResumeCursor` token marking
|
|
7751
|
+
* where the unread history begins. The token is missing in TWO OPPOSITE cases
|
|
7752
|
+
* — a COMPLETE walk, or a TRUNCATED one whose stop point could not be named —
|
|
7753
|
+
* and `messagesTruncated` is what tells them apart; see
|
|
7754
|
+
* {@link CherryMessageWalkVerdict.messagesResumeCursor}.
|
|
7680
7755
|
*
|
|
7681
7756
|
* The spec's own sentence (§3.6, 2026-10-09 design: 「超时不是失败:返回已收到
|
|
7682
7757
|
* 的部分内容 + 当前游标,agent 可续查」) is a statement of that contract in
|
|
@@ -7903,9 +7978,10 @@ var CherryToolEngine = class {
|
|
|
7903
7978
|
* `messagesResumeCursor`: the read scans from the newest end to the anchor
|
|
7904
7979
|
* and continues strictly after it.
|
|
7905
7980
|
*
|
|
7906
|
-
* The token is
|
|
7907
|
-
*
|
|
7908
|
-
*
|
|
7981
|
+
* The token is missing in TWO OPPOSITE cases — a COMPLETE walk, and a
|
|
7982
|
+
* TRUNCATED one whose stop point could not be named — and `messagesTruncated`
|
|
7983
|
+
* is what tells them apart; absence never means "nothing older left" on its
|
|
7984
|
+
* own. See {@link CherryMessageWalkVerdict.messagesResumeCursor}.
|
|
7909
7985
|
*/
|
|
7910
7986
|
async #readTerminalMessages(connection, sessionId, session, deadline) {
|
|
7911
7987
|
const historyRevision = optionalString(session["historyRevision"]);
|
|
@@ -8135,14 +8211,90 @@ function parseQuestionInput(input) {
|
|
|
8135
8211
|
* invented: a wrong anchor is worse than no anchor, and `truncated` still says
|
|
8136
8212
|
* the walk was cut. Otherwise the whole collected window was consumed, and the
|
|
8137
8213
|
* peer's page position is exactly where the unread history begins
|
|
8138
|
-
* (`cursor:<pos>`)
|
|
8139
|
-
*
|
|
8214
|
+
* (`cursor:<pos>`).
|
|
8215
|
+
*
|
|
8216
|
+
* BOTH arms name a remainder only when the PARSER would accept it
|
|
8217
|
+
* ({@link mangledRemainder}): empty, not equal to its own `.trim()` (which is
|
|
8218
|
+
* leading/trailing whitespace), or carrying a control character anywhere is
|
|
8219
|
+
* refused HERE exactly as `parseMessageResume` refuses it on replay. The
|
|
8220
|
+
* emitter must never mint what the parser refuses — a token this tool will not
|
|
8221
|
+
* take back is worse than none, because the refusal then surfaces at the RESUME
|
|
8222
|
+
* call instead of at the answer that produced it. This is the CONTENT
|
|
8223
|
+
* dimension; {@link withinResumeTokenCeiling} is the sibling LENGTH one. The
|
|
8224
|
+
* `after:` anchor is covered by the same rule, not just the `cursor:` position:
|
|
8225
|
+
* a message id is an `opaqueId` too, and padding is schema-valid, so a padded
|
|
8226
|
+
* id really can reach the anchor.
|
|
8227
|
+
*
|
|
8228
|
+
* EXPORTED FOR ITS OWN TESTS. The LENGTH guard on the `after:` arm cannot be
|
|
8229
|
+
* driven through a tool: an anchor is a message ID, and projecting that message
|
|
8230
|
+
* means asking the peer for `agent.parts.list` with it, whose `messageId` is an
|
|
8231
|
+
* `opaqueId` (256 characters). A peer item carrying an id above the token
|
|
8232
|
+
* ceiling therefore fails its parts walk and is never RETURNED, so no walk
|
|
8233
|
+
* reaches the point of naming it. The length guard on that arm is still
|
|
8234
|
+
* required — the emitter must never mint what the parser refuses, whatever the
|
|
8235
|
+
* peer's cap becomes — and this export is how a unit test pins it
|
|
8236
|
+
* (`tests/unit/resumeTokenEmit.test.ts`). The CONTENT guard on both arms IS
|
|
8237
|
+
* reachable, because `opaqueId` allows padding: `tests/integration/
|
|
8238
|
+
* toolsReadResume.test.ts` ⑭d/⑭e drive it through the tools.
|
|
8140
8239
|
*/
|
|
8141
8240
|
function messageResumeToken(page, returned, lastReturnedId) {
|
|
8142
|
-
const anchor = optionalString(lastReturnedId);
|
|
8143
|
-
if (returned < page.items.length || page.pageOverflow === true) return anchor === void 0 ? void 0 : `${RESUME_ANCHOR_PREFIX}${anchor}
|
|
8144
|
-
const position = optionalString(page.nextCursor);
|
|
8145
|
-
return position === void 0 ? void 0 : `${RESUME_CURSOR_PREFIX}${position}
|
|
8241
|
+
const anchor = nameableRemainder(optionalString(lastReturnedId));
|
|
8242
|
+
if (returned < page.items.length || page.pageOverflow === true) return anchor === void 0 ? void 0 : withinResumeTokenCeiling(`${RESUME_ANCHOR_PREFIX}${anchor}`);
|
|
8243
|
+
const position = nameablePeerPosition(optionalString(page.nextCursor));
|
|
8244
|
+
return position === void 0 ? void 0 : withinResumeTokenCeiling(`${RESUME_CURSOR_PREFIX}${position}`);
|
|
8245
|
+
}
|
|
8246
|
+
/**
|
|
8247
|
+
* The CONTENT dimension of "a remainder this bridge may name", or `undefined`
|
|
8248
|
+
* when it must not name one.
|
|
8249
|
+
*
|
|
8250
|
+
* The parsers run `mangledRemainder` on every remainder they read, so one that
|
|
8251
|
+
* fails it is a remainder this tool would refuse on replay: naming it hands the
|
|
8252
|
+
* caller a stop point that can never be used, and the refusal then surfaces at
|
|
8253
|
+
* the RESUME call instead of at the answer that produced it (issues #70, #80).
|
|
8254
|
+
* What it rejects, exactly: empty, not equal to its own `.trim()` (leading/
|
|
8255
|
+
* trailing whitespace), or a control character ANYWHERE — an interior plain space
|
|
8256
|
+
* is legal and round-trips.
|
|
8257
|
+
*
|
|
8258
|
+
* This is the parser's own predicate, not a copy of it, so the two sides cannot
|
|
8259
|
+
* drift into two dialects. The sibling dimension is the LENGTH ceiling, judged
|
|
8260
|
+
* on the FINAL token ({@link withinResumeTokenCeiling}).
|
|
8261
|
+
*/
|
|
8262
|
+
function nameableRemainder(value) {
|
|
8263
|
+
return value === void 0 || mangledRemainder(value) ? void 0 : value;
|
|
8264
|
+
}
|
|
8265
|
+
/**
|
|
8266
|
+
* A remainder this bridge may name AS A PAGE POSITION: {@link nameableRemainder}
|
|
8267
|
+
* plus the one refusal that applies to positions and NOT to anchors.
|
|
8268
|
+
*
|
|
8269
|
+
* `looksLikeV1ListToken` exists because a v1 list token (0.1.6: base64url of
|
|
8270
|
+
* `{v,m,d,h,p}`) lives under `cursor:` and is indistinguishable from a peer
|
|
8271
|
+
* position, so both parsers NAME and refuse it rather than forwarding it to a
|
|
8272
|
+
* desktop that would silently serve its FIRST page. An emitter that wrapped such
|
|
8273
|
+
* a position would mint `cursor:<v1 shape>` and then have its own parser answer
|
|
8274
|
+
* "issued by an older plugin version" about a token this tool had just printed —
|
|
8275
|
+
* the same broken round trip as #76/#80, one detector further out (issue #85).
|
|
8276
|
+
*
|
|
8277
|
+
* The `after:` arm deliberately does NOT use this: `parseMessageResume`'s
|
|
8278
|
+
* `after:` branch never consults the detector, so an `after:` remainder of that
|
|
8279
|
+
* shape is a token it accepts.
|
|
8280
|
+
*/
|
|
8281
|
+
function nameablePeerPosition(value) {
|
|
8282
|
+
const remainder = nameableRemainder(value);
|
|
8283
|
+
return remainder === void 0 || looksLikeV1ListToken(remainder) ? void 0 : remainder;
|
|
8284
|
+
}
|
|
8285
|
+
/**
|
|
8286
|
+
* The token ceiling, enforced on the way OUT as well as in. A token this tool
|
|
8287
|
+
* refuses to replay is worse than no token at all: `truncated` already says the
|
|
8288
|
+
* walk was cut, and the remedy then follows the reason — a `'limit'` stop
|
|
8289
|
+
* answers to a wider `limit` only where the caller owns that ceiling — so a
|
|
8290
|
+
* stop point whose token would push past
|
|
8291
|
+
* {@link MAX_RESUME_TOKEN_CHARS} is simply not named. Measured on the FINAL
|
|
8292
|
+
* string, never on a reconstruction of its length, so a format change cannot
|
|
8293
|
+
* leave the guard behind. Exactly at the ceiling is accepted — it is a length
|
|
8294
|
+
* the parser takes back.
|
|
8295
|
+
*/
|
|
8296
|
+
function withinResumeTokenCeiling(token) {
|
|
8297
|
+
return token.length <= 4096 ? token : void 0;
|
|
8146
8298
|
}
|
|
8147
8299
|
/**
|
|
8148
8300
|
* The message-walk verdict, projected off an aggregate page.
|
|
@@ -8240,6 +8392,12 @@ function messageIdOf(raw) {
|
|
|
8240
8392
|
* `undefined`/`null` means "from the newest message", which is the ordinary
|
|
8241
8393
|
* read. An empty remainder (`'cursor:'`, `'after:'`) is refused like any other
|
|
8242
8394
|
* foreign token: an empty anchor could never match a message.
|
|
8395
|
+
*
|
|
8396
|
+
* EXPORTED FOR ITS OWN TESTS — specifically for the round-trip property, which
|
|
8397
|
+
* is the invariant this whole file is built around: every token the emitters
|
|
8398
|
+
* NAME must be one this function ACCEPTS. Pinning that with a probe table is
|
|
8399
|
+
* what turns "we mirrored every refusal" from a promise into a check
|
|
8400
|
+
* (`tests/unit/resumeTokenEmit.test.ts`, issue #85).
|
|
8243
8401
|
*/
|
|
8244
8402
|
function parseMessageResume(value) {
|
|
8245
8403
|
if (value === void 0 || value === null) return void 0;
|
package/docs/e2e-checklist.md
CHANGED
|
@@ -175,7 +175,7 @@ schema 已允许 `interrupted`,所以客户端**必须**处理,但当前服
|
|
|
175
175
|
**W1**(步骤 6 会真的把它删掉),其中有一个**保持开着**的会话;③ 一个不勾选的工作区
|
|
176
176
|
**W2**(含一个会话);④ 一个**没有工作区**的会话——`cwd` 不属于任何已注册工作区,最省事的造法是:
|
|
177
177
|
注册一个临时工作区 W0 → 在其中建一个会话 → 删掉 W0(该会话转入「未分组」,D7 的判定对象就是它)。
|
|
178
|
-
面板入口:设置页 →「**Cherry
|
|
178
|
+
面板入口:设置页 →「**Cherry 互联**」→「**启用范围**」。`curl -s
|
|
179
179
|
http://127.0.0.1:23023/cherry-remote/api/scope`(宿主换过端口就换端口;curl 不带 `Origin`,走
|
|
180
180
|
信任 fence 的放行分支)应返回 `host.state: "ready"`、`host.capable: true`、`host.degraded: []`。
|
|
181
181
|
|
package/lib/client.js
CHANGED
|
@@ -13,7 +13,7 @@ window.__ModuleLoader__.load({
|
|
|
13
13
|
*/
|
|
14
14
|
var dictionaries = {
|
|
15
15
|
zh: {
|
|
16
|
-
"app.title": "Cherry
|
|
16
|
+
"app.title": "Cherry 互联",
|
|
17
17
|
"app.subtitle": "通过远程传输把本机 DeepSeek Harness 连接到 Cherry Studio 桌面端;设备档案保存在插件数据目录,页面不接触任何凭据。",
|
|
18
18
|
"app.eventSourceUnavailable": "此环境不支持 EventSource",
|
|
19
19
|
"app.devices.heading": "已配对设备",
|
|
@@ -113,7 +113,7 @@ window.__ModuleLoader__.load({
|
|
|
113
113
|
"list.separator": "、"
|
|
114
114
|
},
|
|
115
115
|
en: {
|
|
116
|
-
"app.title": "Cherry
|
|
116
|
+
"app.title": "Cherry Bridge",
|
|
117
117
|
"app.subtitle": "Connect this machine’s DeepSeek Harness to a Cherry Studio desktop over the remote transport. Device records live in the plugin data directory; this page never touches a credential.",
|
|
118
118
|
"app.eventSourceUnavailable": "EventSource is unavailable in this environment",
|
|
119
119
|
"app.devices.heading": "Paired devices",
|
package/locale/en.json
CHANGED
package/locale/zh.json
CHANGED
package/package.json
CHANGED
package/src/connection.ts
CHANGED
|
@@ -627,6 +627,13 @@ export class DeviceConnection {
|
|
|
627
627
|
readonly #helloProtocolVersions: readonly number[]
|
|
628
628
|
readonly #transportRetryWaitMs: number
|
|
629
629
|
readonly #stateListeners = new Set<(state: DeviceState) => void>()
|
|
630
|
+
/**
|
|
631
|
+
* Callers parked in {@link waitForSettled}. Separate from the state listeners
|
|
632
|
+
* because their lifetime is ONE wait: a listener stays for the lifecycle, a
|
|
633
|
+
* settle waiter removes itself the moment it is answered (or times out), and
|
|
634
|
+
* mixing them would leave every answered wait in the multicast set.
|
|
635
|
+
*/
|
|
636
|
+
readonly #settleWaiters = new Set<(state: DeviceState) => void>()
|
|
630
637
|
/**
|
|
631
638
|
* Waits parked inside `#awaitReconnect`. close() drains them: a shutdown has
|
|
632
639
|
* no reconnect to wait for, and — the case that matters — a close() followed
|
|
@@ -783,6 +790,53 @@ export class DeviceConnection {
|
|
|
783
790
|
this.#stateListeners.add(cb)
|
|
784
791
|
}
|
|
785
792
|
|
|
793
|
+
/**
|
|
794
|
+
* Waits for an attempt that is ALREADY IN FLIGHT to settle, bounded by
|
|
795
|
+
* `timeoutMs`, and answers with the state it ends on.
|
|
796
|
+
*
|
|
797
|
+
* The only state this waits on is `connecting`: an attempt is running, and
|
|
798
|
+
* its outcome is what the caller asked about. `connected` is answered at
|
|
799
|
+
* once, and so are `offline` (parked in backoff), `disabled` and
|
|
800
|
+
* `identity-mismatch` — no wait can change those, and waiting on them would
|
|
801
|
+
* merely delay the caller's answer by the whole bound. `offline` is the
|
|
802
|
+
* common case for a genuinely powered-off desktop, so it is the one that
|
|
803
|
+
* matters: the tools stay fail-fast there.
|
|
804
|
+
*
|
|
805
|
+
* This NEVER starts an attempt — it observes one the pool already started.
|
|
806
|
+
* That is the line the tool face has always drawn (a tool must not dial); the
|
|
807
|
+
* wait only stops the tool from calling a live dial a failure.
|
|
808
|
+
*
|
|
809
|
+
* The bound expiring is not an error: the current state (`connecting`) is
|
|
810
|
+
* returned, and the caller judges it exactly as it would have without the
|
|
811
|
+
* wait.
|
|
812
|
+
*/
|
|
813
|
+
async waitForSettled(timeoutMs: number): Promise<DeviceState> {
|
|
814
|
+
if (this.#state !== 'connecting' || timeoutMs <= 0) return this.#state
|
|
815
|
+
return new Promise<DeviceState>((resolve) => {
|
|
816
|
+
let settled = false
|
|
817
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
818
|
+
const finish = (state: DeviceState): void => {
|
|
819
|
+
if (settled) return
|
|
820
|
+
settled = true
|
|
821
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
822
|
+
this.#settleWaiters.delete(onState)
|
|
823
|
+
resolve(state)
|
|
824
|
+
}
|
|
825
|
+
// The waiter's own filter: a transition to `offline` IS the attempt
|
|
826
|
+
// settling (failed); only a transition INTO `connecting` is not an
|
|
827
|
+
// answer, and `#setState` never reports a state it is already in.
|
|
828
|
+
const onState = (state: DeviceState): void => {
|
|
829
|
+
if (state !== 'connecting') finish(state)
|
|
830
|
+
}
|
|
831
|
+
timer = setTimeout(() => finish(this.#state), timeoutMs)
|
|
832
|
+
this.#settleWaiters.add(onState)
|
|
833
|
+
// The attempt may have settled between the guard above and the
|
|
834
|
+
// registration; that transition is already gone from the multicast, so
|
|
835
|
+
// it has to be observed by hand.
|
|
836
|
+
if (this.#state !== 'connecting') finish(this.#state)
|
|
837
|
+
})
|
|
838
|
+
}
|
|
839
|
+
|
|
786
840
|
/**
|
|
787
841
|
* Starts (or joins) the supervised lifecycle. Resolves once this cycle
|
|
788
842
|
* reaches `connected`; rejects with the cycle's failure. Transient
|
|
@@ -1582,5 +1636,12 @@ export class DeviceConnection {
|
|
|
1582
1636
|
console.warn(`[cherry-remote] state listener threw for ${this.record.deviceId}`, error)
|
|
1583
1637
|
}
|
|
1584
1638
|
}
|
|
1639
|
+
for (const cb of [...this.#settleWaiters]) {
|
|
1640
|
+
try {
|
|
1641
|
+
cb(next)
|
|
1642
|
+
} catch (error) {
|
|
1643
|
+
console.warn(`[cherry-remote] settle waiter threw for ${this.record.deviceId}`, error)
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1585
1646
|
}
|
|
1586
1647
|
}
|