@gatherthread/dsh-host 0.1.0-alpha.2 → 0.1.0-alpha.4

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 CHANGED
@@ -19,7 +19,7 @@ The package does not install a DSH runtime dependency.
19
19
  After `@gatherthread/dsh-host` is published, the ordinary three-step path is:
20
20
 
21
21
  ```text
22
- npx @deepseek-ai/dsh@0.1.2-rc.1 plugin --profile web add @gatherthread/dsh-host@0.1.0-alpha.2
22
+ npx @deepseek-ai/dsh@0.1.2-rc.1 plugin --profile web add @gatherthread/dsh-host@0.1.0-alpha.4
23
23
  npx @deepseek-ai/dsh@0.1.2-rc.1 web
24
24
  # In DSH: Settings -> GatherThread / 共序 -> Sign in and pair
25
25
  ```
@@ -159,3 +159,10 @@ same runtime advance the projection cursor but are not appended back to DSH,
159
159
  while events written through another harness are imported normally. V1
160
160
  connector state deliberately restarts only the native projection cursor at zero
161
161
  once so history that the earlier poller observed but discarded is backfilled.
162
+
163
+ Automatic upload is stored independently for every bound DSH conversation and
164
+ defaults on. In **Settings -> GatherThread / 共序**, the user can switch it off
165
+ without stopping cloud-to-local projection, or choose **Manual upload** to scan
166
+ and send completed eligible local turns. The manual action is also the recovery
167
+ path after a missed native event or transport interruption, uses the existing
168
+ durable idempotent outbox, and never silently re-enables automatic upload.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "name": "@gatherthread/dsh-host",
4
- "version": "0.1.0-alpha.2",
4
+ "version": "0.1.0-alpha.4",
5
5
  "license": "Apache-2.0",
6
6
  "defaultEnabled": false,
7
7
  "hostEntry": "@gatherthread/dsh-host",
package/client/client.js CHANGED
@@ -105,7 +105,9 @@ function GatherThreadStatusPanel({ connection, sessions }) {
105
105
  }
106
106
  onSuccess?.(next);
107
107
  } catch {
108
- setNotice("操作未完成。请检查服务器地址、当前授权和模型配置。");
108
+ setNotice(endpoint.startsWith("sync/")
109
+ ? "上传操作未完成。请检查连接和会话权限后重试。"
110
+ : "操作未完成。请检查服务器地址、当前授权和模型配置。");
109
111
  } finally {
110
112
  setBusy(false);
111
113
  }
@@ -129,7 +131,9 @@ function GatherThreadStatusPanel({ connection, sessions }) {
129
131
  React.createElement("p", { style: styles.summary },
130
132
  `活跃会话 ${String(snapshot?.activeSessionCount ?? 0)} / ${String(sessionRows.length)}`),
131
133
  React.createElement("div", { style: styles.list },
132
- ...sessionRows.map((session) => React.createElement("div", {
134
+ ...sessionRows.map((session) => {
135
+ const sync = nativeState?.localSync?.find((item) => item.sessionId === session.sessionId);
136
+ return React.createElement("div", {
133
137
  key: session.sessionId,
134
138
  style: styles.row,
135
139
  },
@@ -137,7 +141,30 @@ function GatherThreadStatusPanel({ connection, sessions }) {
137
141
  React.createElement("strong", { style: styles.sessionTitle }, session.title),
138
142
  React.createElement("span", { style: styles.sessionMeta },
139
143
  session.lastSyncedAt === undefined ? "尚未同步" : `最近同步 ${formatTime(session.lastSyncedAt)}`)),
140
- React.createElement("span", { style: badgeStyle(session.state) }, statusLabel(session.state))))),
144
+ sync === undefined ? React.createElement("span", { style: badgeStyle(session.state) }, statusLabel(session.state))
145
+ : React.createElement("div", { style: styles.syncActions },
146
+ React.createElement("label", { style: styles.syncToggle },
147
+ React.createElement("input", {
148
+ type: "checkbox",
149
+ checked: sync.automaticUpload,
150
+ disabled: busy,
151
+ onChange: (event) => void runAction("sync/set-auto-upload", {
152
+ projectId: sync.projectId,
153
+ sessionId: sync.sessionId,
154
+ enabled: event.target.checked,
155
+ }),
156
+ }),
157
+ "自动上传"),
158
+ React.createElement("button", {
159
+ type: "button",
160
+ disabled: busy || sync.uploadableLocalTurns < 1,
161
+ style: styles.compactButton,
162
+ onClick: () => void runAction("sync/upload", {
163
+ projectId: sync.projectId,
164
+ sessionId: sync.sessionId,
165
+ }),
166
+ }, sync.uploadableLocalTurns > 0 ? `手动上传 ${String(sync.uploadableLocalTurns)}` : "已上传")));
167
+ })),
141
168
  nativeState === undefined || unavailable
142
169
  ? null
143
170
  : renderNativeControls({
@@ -386,7 +413,7 @@ function detail(label, value) {
386
413
  function parseNativeState(value) {
387
414
  exactObject(value, [
388
415
  "schemaVersion", "integration", "authorization", "compatibility", "runtime",
389
- "officialServerUrl", "serverUrl", "deviceName", "route", "projectCount", "bindings", "pairing", "recoverableError",
416
+ "officialServerUrl", "serverUrl", "deviceName", "route", "projectCount", "bindings", "localSync", "pairing", "recoverableError",
390
417
  ]);
391
418
  if (value.schemaVersion !== 2 || value.integration !== "gatherthread") throw new Error("invalid native status identity");
392
419
  if (!AUTHORIZATION_STATES.has(value.authorization)) throw new Error("invalid native authorization state");
@@ -409,6 +436,7 @@ function parseNativeState(value) {
409
436
  if (value.deviceName !== undefined) boundedText(value.deviceName, 120);
410
437
  let route;
411
438
  let bindings;
439
+ let localSync;
412
440
  if (value.route !== undefined || value.bindings !== undefined || value.projectCount !== undefined) {
413
441
  if (value.route === undefined || !Number.isSafeInteger(value.projectCount) || value.projectCount < 0
414
442
  || !Array.isArray(value.bindings) || value.bindings.length > 100
@@ -430,6 +458,25 @@ function parseNativeState(value) {
430
458
  }
431
459
  return { ...binding };
432
460
  });
461
+ const localSyncValue = value.localSync ?? [];
462
+ if (!Array.isArray(localSyncValue) || localSyncValue.length > 100) throw new Error("invalid local sync status");
463
+ localSync = localSyncValue.map((sync) => {
464
+ exactObject(sync, [
465
+ "projectId", "projectName", "sessionId", "localSessionId", "title", "automaticUpload",
466
+ "pendingLocalTurns", "uploadableLocalTurns",
467
+ ]);
468
+ boundedText(sync.projectId, 128);
469
+ boundedText(sync.projectName, 160);
470
+ boundedText(sync.sessionId, 128);
471
+ boundedText(sync.localSessionId, 200);
472
+ boundedText(sync.title, 160);
473
+ if (typeof sync.automaticUpload !== "boolean"
474
+ || !Number.isSafeInteger(sync.pendingLocalTurns) || sync.pendingLocalTurns < 0
475
+ || !Number.isSafeInteger(sync.uploadableLocalTurns) || sync.uploadableLocalTurns < 0) {
476
+ throw new Error("invalid local sync status");
477
+ }
478
+ return { ...sync };
479
+ });
433
480
  }
434
481
  let pairing;
435
482
  if (value.pairing !== undefined) {
@@ -465,7 +512,7 @@ function parseNativeState(value) {
465
512
  ...(officialServerUrl === undefined ? {} : { officialServerUrl }),
466
513
  ...(serverUrl === undefined ? {} : { serverUrl }),
467
514
  ...(value.deviceName === undefined ? {} : { deviceName: value.deviceName }),
468
- ...(route === undefined ? {} : { route, projectCount: value.projectCount, bindings }),
515
+ ...(route === undefined ? {} : { route, projectCount: value.projectCount, bindings, localSync }),
469
516
  ...(pairing === undefined ? {} : { pairing }),
470
517
  ...(value.recoverableError === undefined ? {} : { recoverableError: value.recoverableError }),
471
518
  };
@@ -592,6 +639,9 @@ const styles = {
592
639
  sessionText: { minWidth: 0, display: "grid", gap: 3 },
593
640
  sessionTitle: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontSize: 14 },
594
641
  sessionMeta: { opacity: 0.62, fontSize: 12 },
642
+ syncActions: { flex: "none", display: "grid", justifyItems: "end", gap: 6 },
643
+ syncToggle: { display: "inline-flex", alignItems: "center", gap: 5, fontSize: 12, cursor: "pointer" },
644
+ compactButton: { minHeight: 28, padding: "4px 8px", borderRadius: 8, border: "1px solid rgba(127,127,127,.3)", background: "transparent", color: "inherit", font: "inherit", fontSize: 12, cursor: "pointer" },
595
645
  badge: { flex: "none", padding: "4px 8px", borderRadius: 999, fontSize: 12, fontWeight: 600, background: "rgba(127,127,127,.12)", color: "inherit", border: "1px solid rgba(127,127,127,.2)" },
596
646
  connectionCard: { display: "grid", gap: 12, padding: "14px", border: "1px solid rgba(127,127,127,.2)", borderRadius: 14, background: "rgba(127,127,127,.06)" },
597
647
  subheading: { display: "grid", gap: 4 },
@@ -191,10 +191,10 @@ var HttpCollaborationClient = class {
191
191
  const body = requiredObject(await this.#request(`/snapshot-requests/${encodeURIComponent(requestId)}`));
192
192
  return fromWireSnapshotRequest(body.snapshot_request ?? body);
193
193
  }
194
- async createSnapshotRequest(sessionId) {
194
+ async createSnapshotRequest(sessionId, kind = "immutable") {
195
195
  const body = requiredObject(await this.#request(`/sessions/${encodeURIComponent(sessionId)}/snapshot-requests`, {
196
196
  method: "POST",
197
- body: "{}"
197
+ body: JSON.stringify({ kind })
198
198
  }));
199
199
  return fromWireSnapshotRequest(body.snapshot_request ?? body);
200
200
  }
@@ -315,6 +315,7 @@ function fromWireSnapshotRequest(value) {
315
315
  return {
316
316
  id: requiredString(input.id, "snapshot_request.id"),
317
317
  sessionId: requiredString(input.session_id, "snapshot_request.session_id"),
318
+ kind: input.kind === "visible_history_replace" ? "visible_history_replace" : "immutable",
318
319
  throughSequence: requiredNumber(input.through_sequence, "snapshot_request.through_sequence"),
319
320
  status,
320
321
  ...createdAt === void 0 ? {} : { createdAt },
@@ -1398,6 +1399,7 @@ var DshHostConnector = class {
1398
1399
  #timer;
1399
1400
  #heartbeatTimer;
1400
1401
  #pollPromise;
1402
+ #localSyncControlPromise;
1401
1403
  #heartbeatPromise;
1402
1404
  #stopPromise;
1403
1405
  #backgroundFatalError;
@@ -1429,6 +1431,47 @@ var DshHostConnector = class {
1429
1431
  get stopped() {
1430
1432
  return this.#stopped;
1431
1433
  }
1434
+ localSyncStatus() {
1435
+ const state = this.#requireState();
1436
+ const pendingOperations = state.outbox.filter((operation) => operation.kind === "local_turn");
1437
+ const pending = pendingOperations.length;
1438
+ const discoverFrom = pendingOperations.reduce((sequence, operation) => Math.max(sequence, operation.dshToSequence), state.publishedDshSequence);
1439
+ const discoverable = state.activeRequest === void 0 ? captureCompletedLocalTurns(this.#host.snapshotFrom(discoverFrom), discoverFrom, this.#config.dshSessionId).turns.length : 0;
1440
+ return {
1441
+ sessionId: this.#config.sessionId,
1442
+ localSessionId: this.#config.dshSessionId,
1443
+ automaticUpload: state.automaticUpload,
1444
+ pendingLocalTurns: pending,
1445
+ uploadableLocalTurns: pending + discoverable
1446
+ };
1447
+ }
1448
+ async setLocalAutoUpload(enabled) {
1449
+ const status = await this.#withLocalSyncControl(async () => {
1450
+ const state = this.#requireState();
1451
+ state.automaticUpload = enabled;
1452
+ await this.#stateStore.save(state);
1453
+ return this.localSyncStatus();
1454
+ });
1455
+ if (enabled)
1456
+ await this.pollOnce();
1457
+ return enabled ? this.localSyncStatus() : status;
1458
+ }
1459
+ uploadLocalTurns() {
1460
+ return this.#withLocalSyncControl(async () => {
1461
+ const before = this.localSyncStatus();
1462
+ await this.#flushOutbox(true);
1463
+ await this.#captureLocalTurns(0);
1464
+ const discoveredLocalTurns = this.localSyncStatus().pendingLocalTurns;
1465
+ const pendingBefore = this.localSyncStatus().pendingLocalTurns;
1466
+ await this.#flushOutbox(true);
1467
+ const after = this.localSyncStatus();
1468
+ return {
1469
+ ...after,
1470
+ discoveredLocalTurns,
1471
+ uploadedLocalTurns: Math.max(0, before.pendingLocalTurns + pendingBefore - after.pendingLocalTurns)
1472
+ };
1473
+ });
1474
+ }
1432
1475
  async start(options = {}) {
1433
1476
  if (this.#started)
1434
1477
  throw new Error("DSH connector is already started");
@@ -1493,7 +1536,7 @@ var DshHostConnector = class {
1493
1536
  this.#notifyLifecycle("idle");
1494
1537
  if (options.schedule !== false)
1495
1538
  this.#scheduleHeartbeat();
1496
- await this.#flushOutbox();
1539
+ await this.#flushOutbox(this.#requireState().automaticUpload);
1497
1540
  await this.#finalizeDeliveredRequest();
1498
1541
  if (this.#state.activeRequest !== void 0) {
1499
1542
  await this.#withExecutionPermit(() => this.#recoverActiveRequest());
@@ -1514,6 +1557,10 @@ var DshHostConnector = class {
1514
1557
  if (!this.#started || this.#stopped) {
1515
1558
  return Promise.reject(new Error("DSH connector is not running"));
1516
1559
  }
1560
+ const localSyncControl = this.#localSyncControlPromise;
1561
+ if (localSyncControl !== void 0) {
1562
+ return localSyncControl.then(() => this.pollOnce());
1563
+ }
1517
1564
  if (this.#pollPromise !== void 0)
1518
1565
  return this.#pollPromise;
1519
1566
  this.#pollPromise = this.#poll().then((result) => {
@@ -1528,6 +1575,28 @@ var DshHostConnector = class {
1528
1575
  });
1529
1576
  return this.#pollPromise;
1530
1577
  }
1578
+ async #withLocalSyncControl(operation) {
1579
+ const previous = this.#localSyncControlPromise;
1580
+ let release;
1581
+ const barrier = new Promise((resolve) => {
1582
+ release = resolve;
1583
+ });
1584
+ const current = (previous ?? Promise.resolve()).then(() => barrier);
1585
+ this.#localSyncControlPromise = current;
1586
+ try {
1587
+ await previous;
1588
+ const poll = this.#pollPromise;
1589
+ if (poll !== void 0)
1590
+ await poll;
1591
+ if (!this.#started || this.#stopped)
1592
+ throw new Error("DSH connector is not running");
1593
+ return await operation();
1594
+ } finally {
1595
+ release();
1596
+ if (this.#localSyncControlPromise === current)
1597
+ this.#localSyncControlPromise = void 0;
1598
+ }
1599
+ }
1531
1600
  stop() {
1532
1601
  this.#stopPromise ??= (async () => {
1533
1602
  this.#stopped = true;
@@ -1557,14 +1626,17 @@ var DshHostConnector = class {
1557
1626
  }
1558
1627
  async #poll() {
1559
1628
  const state = this.#requireState();
1560
- await this.#flushOutbox();
1629
+ await this.#flushOutbox(state.automaticUpload);
1561
1630
  await this.#finalizeDeliveredRequest();
1562
1631
  if (state.activeRequest !== void 0) {
1563
1632
  await this.#withExecutionPermit(() => this.#recoverActiveRequest());
1564
1633
  return { scanned: 0, claimed: 0, completed: 1 };
1565
1634
  }
1566
- await this.#captureLocalTurns();
1567
- await this.#flushOutbox();
1635
+ if (state.automaticUpload) {
1636
+ await this.#captureLocalTurns();
1637
+ await this.#flushOutbox(true);
1638
+ }
1639
+ const manualUploadRequired = !state.automaticUpload && this.localSyncStatus().uploadableLocalTurns > 0;
1568
1640
  let scanCursor = state.projectionCursor;
1569
1641
  let scanned = 0;
1570
1642
  while (!this.#stopped) {
@@ -1580,6 +1652,9 @@ var DshHostConnector = class {
1580
1652
  continue;
1581
1653
  }
1582
1654
  await this.#projectCanonicalBatch(passive, passive.at(-1)?.sequence ?? state.projectionCursor);
1655
+ if (manualUploadRequired) {
1656
+ return { scanned, claimed: 0, completed: 0 };
1657
+ }
1583
1658
  if (profile.provider !== void 0 && profile.provider !== this.#config.provider) {
1584
1659
  throw new Error("DeepSeek Harness Agent request provider does not match the configured Host binding");
1585
1660
  }
@@ -1757,7 +1832,7 @@ var DshHostConnector = class {
1757
1832
  state.serverCursor = Math.max(state.serverCursor, throughSequence);
1758
1833
  await this.#stateStore.save(state);
1759
1834
  }
1760
- async #captureLocalTurns() {
1835
+ async #captureLocalTurns(basedOnSequence) {
1761
1836
  const state = this.#requireState();
1762
1837
  if (state.activeRequest !== void 0 || state.outbox.length > 0)
1763
1838
  return;
@@ -1775,7 +1850,7 @@ var DshHostConnector = class {
1775
1850
  input: {
1776
1851
  localTurnId: turn.localTurnId,
1777
1852
  runtimeId: runtime.id,
1778
- basedOnSequence: state.serverCursor,
1853
+ basedOnSequence: basedOnSequence ?? state.serverCursor,
1779
1854
  occurredAt: turn.occurredAt,
1780
1855
  observedModel: this.#config.model,
1781
1856
  requestPayload: {
@@ -1877,13 +1952,15 @@ var DshHostConnector = class {
1877
1952
  });
1878
1953
  return operations;
1879
1954
  }
1880
- async #flushOutbox() {
1955
+ async #flushOutbox(includeLocalTurns = true) {
1881
1956
  const state = this.#requireState();
1882
1957
  const runtime = this.#requireRuntime();
1883
1958
  while (state.outbox.length > 0) {
1884
1959
  const operation = state.outbox[0];
1885
1960
  if (operation === void 0)
1886
1961
  break;
1962
+ if (operation.kind === "local_turn" && !includeLocalTurns)
1963
+ break;
1887
1964
  if (operation.input.runtimeId !== runtime.id) {
1888
1965
  throw new Error("DSH connector outbox belongs to a different runtime");
1889
1966
  }
@@ -1965,7 +2042,7 @@ var DshHostConnector = class {
1965
2042
  throw new Error("Persisted DSH Session exists without connector state; refusing ambiguous resume");
1966
2043
  }
1967
2044
  return {
1968
- version: 2,
2045
+ version: 3,
1969
2046
  binding: {
1970
2047
  projectId: this.#config.projectId,
1971
2048
  sessionId: this.#config.sessionId,
@@ -1974,6 +2051,7 @@ var DshHostConnector = class {
1974
2051
  serverCursor: 0,
1975
2052
  projectionCursor: 0,
1976
2053
  publishedDshSequence: 0,
2054
+ automaticUpload: true,
1977
2055
  outbox: []
1978
2056
  };
1979
2057
  }
@@ -3273,6 +3351,21 @@ var DshProjectManager = class {
3273
3351
  activeSessionIds() {
3274
3352
  return [...this.#managed].filter(([, binding]) => binding.phase === "active" && !binding.connector.stopped).map(([sessionId]) => sessionId).sort();
3275
3353
  }
3354
+ localSyncStatuses() {
3355
+ return [...this.#managed.entries()].filter(([, binding]) => binding.phase === "active" && !binding.connector.stopped).flatMap(([, binding]) => binding.connector.localSyncStatus?.() ?? []).sort((left, right) => left.sessionId.localeCompare(right.sessionId));
3356
+ }
3357
+ async setLocalAutoUpload(sessionId, enabled) {
3358
+ const connector = this.#requireManagedConnector(sessionId);
3359
+ if (!connector.setLocalAutoUpload)
3360
+ throw new Error("This DSH conversation does not support upload controls");
3361
+ return connector.setLocalAutoUpload(enabled);
3362
+ }
3363
+ async uploadLocalTurns(sessionId) {
3364
+ const connector = this.#requireManagedConnector(sessionId);
3365
+ if (!connector.uploadLocalTurns)
3366
+ throw new Error("This DSH conversation does not support manual upload");
3367
+ return connector.uploadLocalTurns();
3368
+ }
3276
3369
  async start() {
3277
3370
  if (this.#started)
3278
3371
  throw new Error("DSH project manager is already started");
@@ -3512,6 +3605,13 @@ var DshProjectManager = class {
3512
3605
  throw new Error("Authenticated GatherThread actor is unavailable");
3513
3606
  return this.#actor;
3514
3607
  }
3608
+ #requireManagedConnector(sessionId) {
3609
+ const binding = this.#managed.get(sessionId);
3610
+ if (!binding || binding.phase !== "active" || binding.connector.stopped) {
3611
+ throw new Error("The selected DSH conversation is not active");
3612
+ }
3613
+ return binding.connector;
3614
+ }
3515
3615
  #reportError(error) {
3516
3616
  this.#onBackgroundError?.(error);
3517
3617
  }
@@ -3660,17 +3760,20 @@ var FileConnectorStateStore = class {
3660
3760
  function validateConnectorState(value) {
3661
3761
  const state = requiredObject4(value, "state");
3662
3762
  const legacy = state.version === 1;
3763
+ const legacyUploadPolicy = state.version === 1 || state.version === 2;
3663
3764
  exactKeys(state, /* @__PURE__ */ new Set([
3664
3765
  "version",
3665
3766
  "binding",
3666
3767
  "serverCursor",
3667
3768
  ...legacy ? [] : ["projectionCursor"],
3668
3769
  "publishedDshSequence",
3770
+ ...legacyUploadPolicy ? [] : ["automaticUpload"],
3669
3771
  "activeRequest",
3670
3772
  "outbox"
3671
3773
  ]), "state");
3672
- if (!legacy && state.version !== 2)
3774
+ if (state.version !== 1 && state.version !== 2 && state.version !== 3) {
3673
3775
  throw new Error("Unsupported DSH connector state version");
3776
+ }
3674
3777
  const binding = requiredObject4(state.binding, "state.binding");
3675
3778
  exactKeys(binding, /* @__PURE__ */ new Set(["projectId", "sessionId", "dshSessionId"]), "state.binding");
3676
3779
  const serverCursor = nonNegativeInteger(state.serverCursor, "state.serverCursor");
@@ -3681,7 +3784,7 @@ function validateConnectorState(value) {
3681
3784
  throw new Error("state.outbox must be an array");
3682
3785
  const outbox = state.outbox.map(parseOutboxOperation);
3683
3786
  const validated = {
3684
- version: 2,
3787
+ version: 3,
3685
3788
  binding: {
3686
3789
  projectId: safeString(binding.projectId, "state.binding.projectId", 128),
3687
3790
  sessionId: safeString(binding.sessionId, "state.binding.sessionId", 128),
@@ -3690,6 +3793,7 @@ function validateConnectorState(value) {
3690
3793
  serverCursor,
3691
3794
  projectionCursor,
3692
3795
  publishedDshSequence,
3796
+ automaticUpload: legacyUploadPolicy ? true : requiredBoolean(state.automaticUpload, "state.automaticUpload"),
3693
3797
  ...activeRequest === void 0 ? {} : { activeRequest },
3694
3798
  outbox
3695
3799
  };
@@ -3869,6 +3973,11 @@ function nonNegativeInteger(value, label) {
3869
3973
  }
3870
3974
  return Number(value);
3871
3975
  }
3976
+ function requiredBoolean(value, label) {
3977
+ if (typeof value !== "boolean")
3978
+ throw new Error(`${label} must be a boolean`);
3979
+ return value;
3980
+ }
3872
3981
  function positiveInteger(value, label) {
3873
3982
  const parsed = nonNegativeInteger(value, label);
3874
3983
  if (parsed === 0)
@@ -4133,7 +4242,9 @@ var RPC_ENDPOINTS = /* @__PURE__ */ new Set([
4133
4242
  "pairing/cancel",
4134
4243
  "catalog/get",
4135
4244
  "connection/configure",
4136
- "connection/disconnect"
4245
+ "connection/disconnect",
4246
+ "sync/set-auto-upload",
4247
+ "sync/upload"
4137
4248
  ]);
4138
4249
  var MAX_CATALOG_PROVIDERS = 64;
4139
4250
  var MAX_CATALOG_MODELS = 256;
@@ -4202,7 +4313,8 @@ var DshNativeHostController = class {
4202
4313
  ...this.#grant.route === void 0 ? {} : {
4203
4314
  route: { ...this.#grant.route },
4204
4315
  projectCount: this.#projects.size,
4205
- bindings: this.#currentBindings(this.#grant.route).slice(0, 100)
4316
+ bindings: this.#currentBindings(this.#grant.route).slice(0, 100),
4317
+ localSync: this.#currentLocalSync().slice(0, 100)
4206
4318
  }
4207
4319
  },
4208
4320
  ...this.#pairing === void 0 ? {} : {
@@ -4371,6 +4483,34 @@ var DshNativeHostController = class {
4371
4483
  this.#options.status.setConnection("stopped");
4372
4484
  return this.publicState();
4373
4485
  }
4486
+ async setLocalAutoUpload(inputValue) {
4487
+ this.#assertAvailable();
4488
+ const input = exactInput(inputValue, /* @__PURE__ */ new Set(["projectId", "sessionId", "enabled"]));
4489
+ const projectId = safeInputIdentifier(input.projectId, "projectId");
4490
+ const sessionId = safeInputIdentifier(input.sessionId, "sessionId");
4491
+ if (typeof input.enabled !== "boolean")
4492
+ throw new Error("enabled must be true or false");
4493
+ const owner = this.#owners.get(projectId);
4494
+ if (!owner)
4495
+ throw new Error("The selected GatherThread Project is not active");
4496
+ if (!owner.setLocalAutoUpload)
4497
+ throw new Error("This DSH Project does not expose upload controls");
4498
+ await owner.setLocalAutoUpload(sessionId, input.enabled);
4499
+ return this.publicState();
4500
+ }
4501
+ async uploadLocalTurns(inputValue) {
4502
+ this.#assertAvailable();
4503
+ const input = exactInput(inputValue, /* @__PURE__ */ new Set(["projectId", "sessionId"]));
4504
+ const projectId = safeInputIdentifier(input.projectId, "projectId");
4505
+ const sessionId = safeInputIdentifier(input.sessionId, "sessionId");
4506
+ const owner = this.#owners.get(projectId);
4507
+ if (!owner)
4508
+ throw new Error("The selected GatherThread Project is not active");
4509
+ if (!owner.uploadLocalTurns)
4510
+ throw new Error("This DSH Project does not expose manual upload");
4511
+ await owner.uploadLocalTurns(sessionId);
4512
+ return this.publicState();
4513
+ }
4374
4514
  dispose() {
4375
4515
  this.#disposePromise ??= (async () => {
4376
4516
  this.#disposed = true;
@@ -4507,6 +4647,20 @@ var DshNativeHostController = class {
4507
4647
  model: route.model
4508
4648
  })).sort((left, right) => left.projectName.localeCompare(right.projectName) || left.projectId.localeCompare(right.projectId));
4509
4649
  }
4650
+ #currentLocalSync() {
4651
+ const titles = new Map(this.#options.status.snapshot().sessions.map((session) => [session.sessionId, session.title]));
4652
+ return [...this.#owners.entries()].flatMap(([projectId, owner]) => {
4653
+ const project = this.#projects.get(projectId);
4654
+ if (!project)
4655
+ return [];
4656
+ return (owner.localSyncStatuses?.() ?? []).map((sync) => ({
4657
+ ...sync,
4658
+ projectId,
4659
+ projectName: boundedPublicText2(project.name, "project name", 160),
4660
+ title: titles.get(sync.sessionId) ?? sync.sessionId
4661
+ }));
4662
+ }).sort((left, right) => left.projectName.localeCompare(right.projectName) || left.title.localeCompare(right.title) || left.sessionId.localeCompare(right.sessionId));
4663
+ }
4510
4664
  #updateProjectStatus(projectId, update) {
4511
4665
  if (!this.#projects.has(projectId) || this.#disposed)
4512
4666
  return;
@@ -4628,6 +4782,10 @@ function registerNativeDshRpc(contextValue, controller) {
4628
4782
  case "connection/disconnect":
4629
4783
  exactInput(payload, /* @__PURE__ */ new Set());
4630
4784
  return rpcSuccess(await controller.disconnect());
4785
+ case "sync/set-auto-upload":
4786
+ return rpcSuccess(await controller.setLocalAutoUpload(payload));
4787
+ case "sync/upload":
4788
+ return rpcSuccess(await controller.uploadLocalTurns(payload));
4631
4789
  default:
4632
4790
  return rpcFailure("gatherthread/not-found", "GatherThread DSH action is unavailable");
4633
4791
  }
@@ -4709,6 +4867,9 @@ async function createProductionOwner(options) {
4709
4867
  }
4710
4868
  let stopPromise;
4711
4869
  return {
4870
+ localSyncStatuses: () => manager.localSyncStatuses(),
4871
+ setLocalAutoUpload: (sessionId, enabled) => manager.setLocalAutoUpload(sessionId, enabled),
4872
+ uploadLocalTurns: (sessionId) => manager.uploadLocalTurns(sessionId),
4712
4873
  stop() {
4713
4874
  stopPromise ??= (async () => {
4714
4875
  options.signal.removeEventListener("abort", abortFromRoot);
@@ -4773,6 +4934,9 @@ function createNativeManagedConnector(options) {
4773
4934
  async start() {
4774
4935
  await connector.start();
4775
4936
  },
4937
+ localSyncStatus: () => connector.localSyncStatus(),
4938
+ setLocalAutoUpload: (enabled) => connector.setLocalAutoUpload(enabled),
4939
+ uploadLocalTurns: () => connector.uploadLocalTurns(),
4776
4940
  stop() {
4777
4941
  stopPromise ??= (async () => {
4778
4942
  options.rootSignal.removeEventListener("abort", abortFromRoot);
@@ -512,10 +512,10 @@ var HttpCollaborationClient = class {
512
512
  const body = requiredObject2(await this.#request(`/snapshot-requests/${encodeURIComponent(requestId)}`));
513
513
  return fromWireSnapshotRequest(body.snapshot_request ?? body);
514
514
  }
515
- async createSnapshotRequest(sessionId) {
515
+ async createSnapshotRequest(sessionId, kind = "immutable") {
516
516
  const body = requiredObject2(await this.#request(`/sessions/${encodeURIComponent(sessionId)}/snapshot-requests`, {
517
517
  method: "POST",
518
- body: "{}"
518
+ body: JSON.stringify({ kind })
519
519
  }));
520
520
  return fromWireSnapshotRequest(body.snapshot_request ?? body);
521
521
  }
@@ -636,6 +636,7 @@ function fromWireSnapshotRequest(value) {
636
636
  return {
637
637
  id: requiredString2(input.id, "snapshot_request.id"),
638
638
  sessionId: requiredString2(input.session_id, "snapshot_request.session_id"),
639
+ kind: input.kind === "visible_history_replace" ? "visible_history_replace" : "immutable",
639
640
  throughSequence: requiredNumber(input.through_sequence, "snapshot_request.through_sequence"),
640
641
  status,
641
642
  ...createdAt === void 0 ? {} : { createdAt },
@@ -1309,6 +1310,7 @@ var DshHostConnector = class {
1309
1310
  #timer;
1310
1311
  #heartbeatTimer;
1311
1312
  #pollPromise;
1313
+ #localSyncControlPromise;
1312
1314
  #heartbeatPromise;
1313
1315
  #stopPromise;
1314
1316
  #backgroundFatalError;
@@ -1340,6 +1342,47 @@ var DshHostConnector = class {
1340
1342
  get stopped() {
1341
1343
  return this.#stopped;
1342
1344
  }
1345
+ localSyncStatus() {
1346
+ const state = this.#requireState();
1347
+ const pendingOperations = state.outbox.filter((operation) => operation.kind === "local_turn");
1348
+ const pending = pendingOperations.length;
1349
+ const discoverFrom = pendingOperations.reduce((sequence, operation) => Math.max(sequence, operation.dshToSequence), state.publishedDshSequence);
1350
+ const discoverable = state.activeRequest === void 0 ? captureCompletedLocalTurns(this.#host.snapshotFrom(discoverFrom), discoverFrom, this.#config.dshSessionId).turns.length : 0;
1351
+ return {
1352
+ sessionId: this.#config.sessionId,
1353
+ localSessionId: this.#config.dshSessionId,
1354
+ automaticUpload: state.automaticUpload,
1355
+ pendingLocalTurns: pending,
1356
+ uploadableLocalTurns: pending + discoverable
1357
+ };
1358
+ }
1359
+ async setLocalAutoUpload(enabled) {
1360
+ const status = await this.#withLocalSyncControl(async () => {
1361
+ const state = this.#requireState();
1362
+ state.automaticUpload = enabled;
1363
+ await this.#stateStore.save(state);
1364
+ return this.localSyncStatus();
1365
+ });
1366
+ if (enabled)
1367
+ await this.pollOnce();
1368
+ return enabled ? this.localSyncStatus() : status;
1369
+ }
1370
+ uploadLocalTurns() {
1371
+ return this.#withLocalSyncControl(async () => {
1372
+ const before = this.localSyncStatus();
1373
+ await this.#flushOutbox(true);
1374
+ await this.#captureLocalTurns(0);
1375
+ const discoveredLocalTurns = this.localSyncStatus().pendingLocalTurns;
1376
+ const pendingBefore = this.localSyncStatus().pendingLocalTurns;
1377
+ await this.#flushOutbox(true);
1378
+ const after = this.localSyncStatus();
1379
+ return {
1380
+ ...after,
1381
+ discoveredLocalTurns,
1382
+ uploadedLocalTurns: Math.max(0, before.pendingLocalTurns + pendingBefore - after.pendingLocalTurns)
1383
+ };
1384
+ });
1385
+ }
1343
1386
  async start(options = {}) {
1344
1387
  if (this.#started)
1345
1388
  throw new Error("DSH connector is already started");
@@ -1404,7 +1447,7 @@ var DshHostConnector = class {
1404
1447
  this.#notifyLifecycle("idle");
1405
1448
  if (options.schedule !== false)
1406
1449
  this.#scheduleHeartbeat();
1407
- await this.#flushOutbox();
1450
+ await this.#flushOutbox(this.#requireState().automaticUpload);
1408
1451
  await this.#finalizeDeliveredRequest();
1409
1452
  if (this.#state.activeRequest !== void 0) {
1410
1453
  await this.#withExecutionPermit(() => this.#recoverActiveRequest());
@@ -1425,6 +1468,10 @@ var DshHostConnector = class {
1425
1468
  if (!this.#started || this.#stopped) {
1426
1469
  return Promise.reject(new Error("DSH connector is not running"));
1427
1470
  }
1471
+ const localSyncControl = this.#localSyncControlPromise;
1472
+ if (localSyncControl !== void 0) {
1473
+ return localSyncControl.then(() => this.pollOnce());
1474
+ }
1428
1475
  if (this.#pollPromise !== void 0)
1429
1476
  return this.#pollPromise;
1430
1477
  this.#pollPromise = this.#poll().then((result) => {
@@ -1439,6 +1486,28 @@ var DshHostConnector = class {
1439
1486
  });
1440
1487
  return this.#pollPromise;
1441
1488
  }
1489
+ async #withLocalSyncControl(operation) {
1490
+ const previous = this.#localSyncControlPromise;
1491
+ let release;
1492
+ const barrier = new Promise((resolve) => {
1493
+ release = resolve;
1494
+ });
1495
+ const current = (previous ?? Promise.resolve()).then(() => barrier);
1496
+ this.#localSyncControlPromise = current;
1497
+ try {
1498
+ await previous;
1499
+ const poll = this.#pollPromise;
1500
+ if (poll !== void 0)
1501
+ await poll;
1502
+ if (!this.#started || this.#stopped)
1503
+ throw new Error("DSH connector is not running");
1504
+ return await operation();
1505
+ } finally {
1506
+ release();
1507
+ if (this.#localSyncControlPromise === current)
1508
+ this.#localSyncControlPromise = void 0;
1509
+ }
1510
+ }
1442
1511
  stop() {
1443
1512
  this.#stopPromise ??= (async () => {
1444
1513
  this.#stopped = true;
@@ -1468,14 +1537,17 @@ var DshHostConnector = class {
1468
1537
  }
1469
1538
  async #poll() {
1470
1539
  const state = this.#requireState();
1471
- await this.#flushOutbox();
1540
+ await this.#flushOutbox(state.automaticUpload);
1472
1541
  await this.#finalizeDeliveredRequest();
1473
1542
  if (state.activeRequest !== void 0) {
1474
1543
  await this.#withExecutionPermit(() => this.#recoverActiveRequest());
1475
1544
  return { scanned: 0, claimed: 0, completed: 1 };
1476
1545
  }
1477
- await this.#captureLocalTurns();
1478
- await this.#flushOutbox();
1546
+ if (state.automaticUpload) {
1547
+ await this.#captureLocalTurns();
1548
+ await this.#flushOutbox(true);
1549
+ }
1550
+ const manualUploadRequired = !state.automaticUpload && this.localSyncStatus().uploadableLocalTurns > 0;
1479
1551
  let scanCursor = state.projectionCursor;
1480
1552
  let scanned = 0;
1481
1553
  while (!this.#stopped) {
@@ -1491,6 +1563,9 @@ var DshHostConnector = class {
1491
1563
  continue;
1492
1564
  }
1493
1565
  await this.#projectCanonicalBatch(passive, passive.at(-1)?.sequence ?? state.projectionCursor);
1566
+ if (manualUploadRequired) {
1567
+ return { scanned, claimed: 0, completed: 0 };
1568
+ }
1494
1569
  if (profile.provider !== void 0 && profile.provider !== this.#config.provider) {
1495
1570
  throw new Error("DeepSeek Harness Agent request provider does not match the configured Host binding");
1496
1571
  }
@@ -1668,7 +1743,7 @@ var DshHostConnector = class {
1668
1743
  state.serverCursor = Math.max(state.serverCursor, throughSequence);
1669
1744
  await this.#stateStore.save(state);
1670
1745
  }
1671
- async #captureLocalTurns() {
1746
+ async #captureLocalTurns(basedOnSequence) {
1672
1747
  const state = this.#requireState();
1673
1748
  if (state.activeRequest !== void 0 || state.outbox.length > 0)
1674
1749
  return;
@@ -1686,7 +1761,7 @@ var DshHostConnector = class {
1686
1761
  input: {
1687
1762
  localTurnId: turn.localTurnId,
1688
1763
  runtimeId: runtime.id,
1689
- basedOnSequence: state.serverCursor,
1764
+ basedOnSequence: basedOnSequence ?? state.serverCursor,
1690
1765
  occurredAt: turn.occurredAt,
1691
1766
  observedModel: this.#config.model,
1692
1767
  requestPayload: {
@@ -1788,13 +1863,15 @@ var DshHostConnector = class {
1788
1863
  });
1789
1864
  return operations;
1790
1865
  }
1791
- async #flushOutbox() {
1866
+ async #flushOutbox(includeLocalTurns = true) {
1792
1867
  const state = this.#requireState();
1793
1868
  const runtime = this.#requireRuntime();
1794
1869
  while (state.outbox.length > 0) {
1795
1870
  const operation = state.outbox[0];
1796
1871
  if (operation === void 0)
1797
1872
  break;
1873
+ if (operation.kind === "local_turn" && !includeLocalTurns)
1874
+ break;
1798
1875
  if (operation.input.runtimeId !== runtime.id) {
1799
1876
  throw new Error("DSH connector outbox belongs to a different runtime");
1800
1877
  }
@@ -1876,7 +1953,7 @@ var DshHostConnector = class {
1876
1953
  throw new Error("Persisted DSH Session exists without connector state; refusing ambiguous resume");
1877
1954
  }
1878
1955
  return {
1879
- version: 2,
1956
+ version: 3,
1880
1957
  binding: {
1881
1958
  projectId: this.#config.projectId,
1882
1959
  sessionId: this.#config.sessionId,
@@ -1885,6 +1962,7 @@ var DshHostConnector = class {
1885
1962
  serverCursor: 0,
1886
1963
  projectionCursor: 0,
1887
1964
  publishedDshSequence: 0,
1965
+ automaticUpload: true,
1888
1966
  outbox: []
1889
1967
  };
1890
1968
  }
@@ -2648,17 +2726,20 @@ var FileConnectorStateStore = class {
2648
2726
  function validateConnectorState(value) {
2649
2727
  const state = requiredObject3(value, "state");
2650
2728
  const legacy = state.version === 1;
2729
+ const legacyUploadPolicy = state.version === 1 || state.version === 2;
2651
2730
  exactKeys(state, /* @__PURE__ */ new Set([
2652
2731
  "version",
2653
2732
  "binding",
2654
2733
  "serverCursor",
2655
2734
  ...legacy ? [] : ["projectionCursor"],
2656
2735
  "publishedDshSequence",
2736
+ ...legacyUploadPolicy ? [] : ["automaticUpload"],
2657
2737
  "activeRequest",
2658
2738
  "outbox"
2659
2739
  ]), "state");
2660
- if (!legacy && state.version !== 2)
2740
+ if (state.version !== 1 && state.version !== 2 && state.version !== 3) {
2661
2741
  throw new Error("Unsupported DSH connector state version");
2742
+ }
2662
2743
  const binding = requiredObject3(state.binding, "state.binding");
2663
2744
  exactKeys(binding, /* @__PURE__ */ new Set(["projectId", "sessionId", "dshSessionId"]), "state.binding");
2664
2745
  const serverCursor = nonNegativeInteger(state.serverCursor, "state.serverCursor");
@@ -2669,7 +2750,7 @@ function validateConnectorState(value) {
2669
2750
  throw new Error("state.outbox must be an array");
2670
2751
  const outbox = state.outbox.map(parseOutboxOperation);
2671
2752
  const validated = {
2672
- version: 2,
2753
+ version: 3,
2673
2754
  binding: {
2674
2755
  projectId: safeString(binding.projectId, "state.binding.projectId", 128),
2675
2756
  sessionId: safeString(binding.sessionId, "state.binding.sessionId", 128),
@@ -2678,6 +2759,7 @@ function validateConnectorState(value) {
2678
2759
  serverCursor,
2679
2760
  projectionCursor,
2680
2761
  publishedDshSequence,
2762
+ automaticUpload: legacyUploadPolicy ? true : requiredBoolean(state.automaticUpload, "state.automaticUpload"),
2681
2763
  ...activeRequest === void 0 ? {} : { activeRequest },
2682
2764
  outbox
2683
2765
  };
@@ -2857,6 +2939,11 @@ function nonNegativeInteger(value, label) {
2857
2939
  }
2858
2940
  return Number(value);
2859
2941
  }
2942
+ function requiredBoolean(value, label) {
2943
+ if (typeof value !== "boolean")
2944
+ throw new Error(`${label} must be a boolean`);
2945
+ return value;
2946
+ }
2860
2947
  function positiveInteger(value, label) {
2861
2948
  const parsed = nonNegativeInteger(value, label);
2862
2949
  if (parsed === 0)
@@ -3020,6 +3107,21 @@ var DshProjectManager = class {
3020
3107
  activeSessionIds() {
3021
3108
  return [...this.#managed].filter(([, binding]) => binding.phase === "active" && !binding.connector.stopped).map(([sessionId]) => sessionId).sort();
3022
3109
  }
3110
+ localSyncStatuses() {
3111
+ return [...this.#managed.entries()].filter(([, binding]) => binding.phase === "active" && !binding.connector.stopped).flatMap(([, binding]) => binding.connector.localSyncStatus?.() ?? []).sort((left, right) => left.sessionId.localeCompare(right.sessionId));
3112
+ }
3113
+ async setLocalAutoUpload(sessionId, enabled) {
3114
+ const connector = this.#requireManagedConnector(sessionId);
3115
+ if (!connector.setLocalAutoUpload)
3116
+ throw new Error("This DSH conversation does not support upload controls");
3117
+ return connector.setLocalAutoUpload(enabled);
3118
+ }
3119
+ async uploadLocalTurns(sessionId) {
3120
+ const connector = this.#requireManagedConnector(sessionId);
3121
+ if (!connector.uploadLocalTurns)
3122
+ throw new Error("This DSH conversation does not support manual upload");
3123
+ return connector.uploadLocalTurns();
3124
+ }
3023
3125
  async start() {
3024
3126
  if (this.#started)
3025
3127
  throw new Error("DSH project manager is already started");
@@ -3259,6 +3361,13 @@ var DshProjectManager = class {
3259
3361
  throw new Error("Authenticated GatherThread actor is unavailable");
3260
3362
  return this.#actor;
3261
3363
  }
3364
+ #requireManagedConnector(sessionId) {
3365
+ const binding = this.#managed.get(sessionId);
3366
+ if (!binding || binding.phase !== "active" || binding.connector.stopped) {
3367
+ throw new Error("The selected DSH conversation is not active");
3368
+ }
3369
+ return binding.connector;
3370
+ }
3262
3371
  #reportError(error) {
3263
3372
  this.#onBackgroundError?.(error);
3264
3373
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gatherthread/dsh-host",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.4",
4
4
  "description": "Opt-in GatherThread integration for the DeepSeek Harness Web profile",
5
5
  "license": "Apache-2.0",
6
6
  "publishConfig": {