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

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.3
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.3",
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 },
@@ -1398,6 +1398,7 @@ var DshHostConnector = class {
1398
1398
  #timer;
1399
1399
  #heartbeatTimer;
1400
1400
  #pollPromise;
1401
+ #localSyncControlPromise;
1401
1402
  #heartbeatPromise;
1402
1403
  #stopPromise;
1403
1404
  #backgroundFatalError;
@@ -1429,6 +1430,47 @@ var DshHostConnector = class {
1429
1430
  get stopped() {
1430
1431
  return this.#stopped;
1431
1432
  }
1433
+ localSyncStatus() {
1434
+ const state = this.#requireState();
1435
+ const pendingOperations = state.outbox.filter((operation) => operation.kind === "local_turn");
1436
+ const pending = pendingOperations.length;
1437
+ const discoverFrom = pendingOperations.reduce((sequence, operation) => Math.max(sequence, operation.dshToSequence), state.publishedDshSequence);
1438
+ const discoverable = state.activeRequest === void 0 ? captureCompletedLocalTurns(this.#host.snapshotFrom(discoverFrom), discoverFrom, this.#config.dshSessionId).turns.length : 0;
1439
+ return {
1440
+ sessionId: this.#config.sessionId,
1441
+ localSessionId: this.#config.dshSessionId,
1442
+ automaticUpload: state.automaticUpload,
1443
+ pendingLocalTurns: pending,
1444
+ uploadableLocalTurns: pending + discoverable
1445
+ };
1446
+ }
1447
+ async setLocalAutoUpload(enabled) {
1448
+ const status = await this.#withLocalSyncControl(async () => {
1449
+ const state = this.#requireState();
1450
+ state.automaticUpload = enabled;
1451
+ await this.#stateStore.save(state);
1452
+ return this.localSyncStatus();
1453
+ });
1454
+ if (enabled)
1455
+ await this.pollOnce();
1456
+ return enabled ? this.localSyncStatus() : status;
1457
+ }
1458
+ uploadLocalTurns() {
1459
+ return this.#withLocalSyncControl(async () => {
1460
+ const before = this.localSyncStatus();
1461
+ await this.#flushOutbox(true);
1462
+ await this.#captureLocalTurns(0);
1463
+ const discoveredLocalTurns = this.localSyncStatus().pendingLocalTurns;
1464
+ const pendingBefore = this.localSyncStatus().pendingLocalTurns;
1465
+ await this.#flushOutbox(true);
1466
+ const after = this.localSyncStatus();
1467
+ return {
1468
+ ...after,
1469
+ discoveredLocalTurns,
1470
+ uploadedLocalTurns: Math.max(0, before.pendingLocalTurns + pendingBefore - after.pendingLocalTurns)
1471
+ };
1472
+ });
1473
+ }
1432
1474
  async start(options = {}) {
1433
1475
  if (this.#started)
1434
1476
  throw new Error("DSH connector is already started");
@@ -1493,7 +1535,7 @@ var DshHostConnector = class {
1493
1535
  this.#notifyLifecycle("idle");
1494
1536
  if (options.schedule !== false)
1495
1537
  this.#scheduleHeartbeat();
1496
- await this.#flushOutbox();
1538
+ await this.#flushOutbox(this.#requireState().automaticUpload);
1497
1539
  await this.#finalizeDeliveredRequest();
1498
1540
  if (this.#state.activeRequest !== void 0) {
1499
1541
  await this.#withExecutionPermit(() => this.#recoverActiveRequest());
@@ -1514,6 +1556,10 @@ var DshHostConnector = class {
1514
1556
  if (!this.#started || this.#stopped) {
1515
1557
  return Promise.reject(new Error("DSH connector is not running"));
1516
1558
  }
1559
+ const localSyncControl = this.#localSyncControlPromise;
1560
+ if (localSyncControl !== void 0) {
1561
+ return localSyncControl.then(() => this.pollOnce());
1562
+ }
1517
1563
  if (this.#pollPromise !== void 0)
1518
1564
  return this.#pollPromise;
1519
1565
  this.#pollPromise = this.#poll().then((result) => {
@@ -1528,6 +1574,28 @@ var DshHostConnector = class {
1528
1574
  });
1529
1575
  return this.#pollPromise;
1530
1576
  }
1577
+ async #withLocalSyncControl(operation) {
1578
+ const previous = this.#localSyncControlPromise;
1579
+ let release;
1580
+ const barrier = new Promise((resolve) => {
1581
+ release = resolve;
1582
+ });
1583
+ const current = (previous ?? Promise.resolve()).then(() => barrier);
1584
+ this.#localSyncControlPromise = current;
1585
+ try {
1586
+ await previous;
1587
+ const poll = this.#pollPromise;
1588
+ if (poll !== void 0)
1589
+ await poll;
1590
+ if (!this.#started || this.#stopped)
1591
+ throw new Error("DSH connector is not running");
1592
+ return await operation();
1593
+ } finally {
1594
+ release();
1595
+ if (this.#localSyncControlPromise === current)
1596
+ this.#localSyncControlPromise = void 0;
1597
+ }
1598
+ }
1531
1599
  stop() {
1532
1600
  this.#stopPromise ??= (async () => {
1533
1601
  this.#stopped = true;
@@ -1557,14 +1625,17 @@ var DshHostConnector = class {
1557
1625
  }
1558
1626
  async #poll() {
1559
1627
  const state = this.#requireState();
1560
- await this.#flushOutbox();
1628
+ await this.#flushOutbox(state.automaticUpload);
1561
1629
  await this.#finalizeDeliveredRequest();
1562
1630
  if (state.activeRequest !== void 0) {
1563
1631
  await this.#withExecutionPermit(() => this.#recoverActiveRequest());
1564
1632
  return { scanned: 0, claimed: 0, completed: 1 };
1565
1633
  }
1566
- await this.#captureLocalTurns();
1567
- await this.#flushOutbox();
1634
+ if (state.automaticUpload) {
1635
+ await this.#captureLocalTurns();
1636
+ await this.#flushOutbox(true);
1637
+ }
1638
+ const manualUploadRequired = !state.automaticUpload && this.localSyncStatus().uploadableLocalTurns > 0;
1568
1639
  let scanCursor = state.projectionCursor;
1569
1640
  let scanned = 0;
1570
1641
  while (!this.#stopped) {
@@ -1580,6 +1651,9 @@ var DshHostConnector = class {
1580
1651
  continue;
1581
1652
  }
1582
1653
  await this.#projectCanonicalBatch(passive, passive.at(-1)?.sequence ?? state.projectionCursor);
1654
+ if (manualUploadRequired) {
1655
+ return { scanned, claimed: 0, completed: 0 };
1656
+ }
1583
1657
  if (profile.provider !== void 0 && profile.provider !== this.#config.provider) {
1584
1658
  throw new Error("DeepSeek Harness Agent request provider does not match the configured Host binding");
1585
1659
  }
@@ -1757,7 +1831,7 @@ var DshHostConnector = class {
1757
1831
  state.serverCursor = Math.max(state.serverCursor, throughSequence);
1758
1832
  await this.#stateStore.save(state);
1759
1833
  }
1760
- async #captureLocalTurns() {
1834
+ async #captureLocalTurns(basedOnSequence) {
1761
1835
  const state = this.#requireState();
1762
1836
  if (state.activeRequest !== void 0 || state.outbox.length > 0)
1763
1837
  return;
@@ -1775,7 +1849,7 @@ var DshHostConnector = class {
1775
1849
  input: {
1776
1850
  localTurnId: turn.localTurnId,
1777
1851
  runtimeId: runtime.id,
1778
- basedOnSequence: state.serverCursor,
1852
+ basedOnSequence: basedOnSequence ?? state.serverCursor,
1779
1853
  occurredAt: turn.occurredAt,
1780
1854
  observedModel: this.#config.model,
1781
1855
  requestPayload: {
@@ -1877,13 +1951,15 @@ var DshHostConnector = class {
1877
1951
  });
1878
1952
  return operations;
1879
1953
  }
1880
- async #flushOutbox() {
1954
+ async #flushOutbox(includeLocalTurns = true) {
1881
1955
  const state = this.#requireState();
1882
1956
  const runtime = this.#requireRuntime();
1883
1957
  while (state.outbox.length > 0) {
1884
1958
  const operation = state.outbox[0];
1885
1959
  if (operation === void 0)
1886
1960
  break;
1961
+ if (operation.kind === "local_turn" && !includeLocalTurns)
1962
+ break;
1887
1963
  if (operation.input.runtimeId !== runtime.id) {
1888
1964
  throw new Error("DSH connector outbox belongs to a different runtime");
1889
1965
  }
@@ -1965,7 +2041,7 @@ var DshHostConnector = class {
1965
2041
  throw new Error("Persisted DSH Session exists without connector state; refusing ambiguous resume");
1966
2042
  }
1967
2043
  return {
1968
- version: 2,
2044
+ version: 3,
1969
2045
  binding: {
1970
2046
  projectId: this.#config.projectId,
1971
2047
  sessionId: this.#config.sessionId,
@@ -1974,6 +2050,7 @@ var DshHostConnector = class {
1974
2050
  serverCursor: 0,
1975
2051
  projectionCursor: 0,
1976
2052
  publishedDshSequence: 0,
2053
+ automaticUpload: true,
1977
2054
  outbox: []
1978
2055
  };
1979
2056
  }
@@ -3273,6 +3350,21 @@ var DshProjectManager = class {
3273
3350
  activeSessionIds() {
3274
3351
  return [...this.#managed].filter(([, binding]) => binding.phase === "active" && !binding.connector.stopped).map(([sessionId]) => sessionId).sort();
3275
3352
  }
3353
+ localSyncStatuses() {
3354
+ 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));
3355
+ }
3356
+ async setLocalAutoUpload(sessionId, enabled) {
3357
+ const connector = this.#requireManagedConnector(sessionId);
3358
+ if (!connector.setLocalAutoUpload)
3359
+ throw new Error("This DSH conversation does not support upload controls");
3360
+ return connector.setLocalAutoUpload(enabled);
3361
+ }
3362
+ async uploadLocalTurns(sessionId) {
3363
+ const connector = this.#requireManagedConnector(sessionId);
3364
+ if (!connector.uploadLocalTurns)
3365
+ throw new Error("This DSH conversation does not support manual upload");
3366
+ return connector.uploadLocalTurns();
3367
+ }
3276
3368
  async start() {
3277
3369
  if (this.#started)
3278
3370
  throw new Error("DSH project manager is already started");
@@ -3512,6 +3604,13 @@ var DshProjectManager = class {
3512
3604
  throw new Error("Authenticated GatherThread actor is unavailable");
3513
3605
  return this.#actor;
3514
3606
  }
3607
+ #requireManagedConnector(sessionId) {
3608
+ const binding = this.#managed.get(sessionId);
3609
+ if (!binding || binding.phase !== "active" || binding.connector.stopped) {
3610
+ throw new Error("The selected DSH conversation is not active");
3611
+ }
3612
+ return binding.connector;
3613
+ }
3515
3614
  #reportError(error) {
3516
3615
  this.#onBackgroundError?.(error);
3517
3616
  }
@@ -3660,17 +3759,20 @@ var FileConnectorStateStore = class {
3660
3759
  function validateConnectorState(value) {
3661
3760
  const state = requiredObject4(value, "state");
3662
3761
  const legacy = state.version === 1;
3762
+ const legacyUploadPolicy = state.version === 1 || state.version === 2;
3663
3763
  exactKeys(state, /* @__PURE__ */ new Set([
3664
3764
  "version",
3665
3765
  "binding",
3666
3766
  "serverCursor",
3667
3767
  ...legacy ? [] : ["projectionCursor"],
3668
3768
  "publishedDshSequence",
3769
+ ...legacyUploadPolicy ? [] : ["automaticUpload"],
3669
3770
  "activeRequest",
3670
3771
  "outbox"
3671
3772
  ]), "state");
3672
- if (!legacy && state.version !== 2)
3773
+ if (state.version !== 1 && state.version !== 2 && state.version !== 3) {
3673
3774
  throw new Error("Unsupported DSH connector state version");
3775
+ }
3674
3776
  const binding = requiredObject4(state.binding, "state.binding");
3675
3777
  exactKeys(binding, /* @__PURE__ */ new Set(["projectId", "sessionId", "dshSessionId"]), "state.binding");
3676
3778
  const serverCursor = nonNegativeInteger(state.serverCursor, "state.serverCursor");
@@ -3681,7 +3783,7 @@ function validateConnectorState(value) {
3681
3783
  throw new Error("state.outbox must be an array");
3682
3784
  const outbox = state.outbox.map(parseOutboxOperation);
3683
3785
  const validated = {
3684
- version: 2,
3786
+ version: 3,
3685
3787
  binding: {
3686
3788
  projectId: safeString(binding.projectId, "state.binding.projectId", 128),
3687
3789
  sessionId: safeString(binding.sessionId, "state.binding.sessionId", 128),
@@ -3690,6 +3792,7 @@ function validateConnectorState(value) {
3690
3792
  serverCursor,
3691
3793
  projectionCursor,
3692
3794
  publishedDshSequence,
3795
+ automaticUpload: legacyUploadPolicy ? true : requiredBoolean(state.automaticUpload, "state.automaticUpload"),
3693
3796
  ...activeRequest === void 0 ? {} : { activeRequest },
3694
3797
  outbox
3695
3798
  };
@@ -3869,6 +3972,11 @@ function nonNegativeInteger(value, label) {
3869
3972
  }
3870
3973
  return Number(value);
3871
3974
  }
3975
+ function requiredBoolean(value, label) {
3976
+ if (typeof value !== "boolean")
3977
+ throw new Error(`${label} must be a boolean`);
3978
+ return value;
3979
+ }
3872
3980
  function positiveInteger(value, label) {
3873
3981
  const parsed = nonNegativeInteger(value, label);
3874
3982
  if (parsed === 0)
@@ -4133,7 +4241,9 @@ var RPC_ENDPOINTS = /* @__PURE__ */ new Set([
4133
4241
  "pairing/cancel",
4134
4242
  "catalog/get",
4135
4243
  "connection/configure",
4136
- "connection/disconnect"
4244
+ "connection/disconnect",
4245
+ "sync/set-auto-upload",
4246
+ "sync/upload"
4137
4247
  ]);
4138
4248
  var MAX_CATALOG_PROVIDERS = 64;
4139
4249
  var MAX_CATALOG_MODELS = 256;
@@ -4202,7 +4312,8 @@ var DshNativeHostController = class {
4202
4312
  ...this.#grant.route === void 0 ? {} : {
4203
4313
  route: { ...this.#grant.route },
4204
4314
  projectCount: this.#projects.size,
4205
- bindings: this.#currentBindings(this.#grant.route).slice(0, 100)
4315
+ bindings: this.#currentBindings(this.#grant.route).slice(0, 100),
4316
+ localSync: this.#currentLocalSync().slice(0, 100)
4206
4317
  }
4207
4318
  },
4208
4319
  ...this.#pairing === void 0 ? {} : {
@@ -4371,6 +4482,34 @@ var DshNativeHostController = class {
4371
4482
  this.#options.status.setConnection("stopped");
4372
4483
  return this.publicState();
4373
4484
  }
4485
+ async setLocalAutoUpload(inputValue) {
4486
+ this.#assertAvailable();
4487
+ const input = exactInput(inputValue, /* @__PURE__ */ new Set(["projectId", "sessionId", "enabled"]));
4488
+ const projectId = safeInputIdentifier(input.projectId, "projectId");
4489
+ const sessionId = safeInputIdentifier(input.sessionId, "sessionId");
4490
+ if (typeof input.enabled !== "boolean")
4491
+ throw new Error("enabled must be true or false");
4492
+ const owner = this.#owners.get(projectId);
4493
+ if (!owner)
4494
+ throw new Error("The selected GatherThread Project is not active");
4495
+ if (!owner.setLocalAutoUpload)
4496
+ throw new Error("This DSH Project does not expose upload controls");
4497
+ await owner.setLocalAutoUpload(sessionId, input.enabled);
4498
+ return this.publicState();
4499
+ }
4500
+ async uploadLocalTurns(inputValue) {
4501
+ this.#assertAvailable();
4502
+ const input = exactInput(inputValue, /* @__PURE__ */ new Set(["projectId", "sessionId"]));
4503
+ const projectId = safeInputIdentifier(input.projectId, "projectId");
4504
+ const sessionId = safeInputIdentifier(input.sessionId, "sessionId");
4505
+ const owner = this.#owners.get(projectId);
4506
+ if (!owner)
4507
+ throw new Error("The selected GatherThread Project is not active");
4508
+ if (!owner.uploadLocalTurns)
4509
+ throw new Error("This DSH Project does not expose manual upload");
4510
+ await owner.uploadLocalTurns(sessionId);
4511
+ return this.publicState();
4512
+ }
4374
4513
  dispose() {
4375
4514
  this.#disposePromise ??= (async () => {
4376
4515
  this.#disposed = true;
@@ -4507,6 +4646,20 @@ var DshNativeHostController = class {
4507
4646
  model: route.model
4508
4647
  })).sort((left, right) => left.projectName.localeCompare(right.projectName) || left.projectId.localeCompare(right.projectId));
4509
4648
  }
4649
+ #currentLocalSync() {
4650
+ const titles = new Map(this.#options.status.snapshot().sessions.map((session) => [session.sessionId, session.title]));
4651
+ return [...this.#owners.entries()].flatMap(([projectId, owner]) => {
4652
+ const project = this.#projects.get(projectId);
4653
+ if (!project)
4654
+ return [];
4655
+ return (owner.localSyncStatuses?.() ?? []).map((sync) => ({
4656
+ ...sync,
4657
+ projectId,
4658
+ projectName: boundedPublicText2(project.name, "project name", 160),
4659
+ title: titles.get(sync.sessionId) ?? sync.sessionId
4660
+ }));
4661
+ }).sort((left, right) => left.projectName.localeCompare(right.projectName) || left.title.localeCompare(right.title) || left.sessionId.localeCompare(right.sessionId));
4662
+ }
4510
4663
  #updateProjectStatus(projectId, update) {
4511
4664
  if (!this.#projects.has(projectId) || this.#disposed)
4512
4665
  return;
@@ -4628,6 +4781,10 @@ function registerNativeDshRpc(contextValue, controller) {
4628
4781
  case "connection/disconnect":
4629
4782
  exactInput(payload, /* @__PURE__ */ new Set());
4630
4783
  return rpcSuccess(await controller.disconnect());
4784
+ case "sync/set-auto-upload":
4785
+ return rpcSuccess(await controller.setLocalAutoUpload(payload));
4786
+ case "sync/upload":
4787
+ return rpcSuccess(await controller.uploadLocalTurns(payload));
4631
4788
  default:
4632
4789
  return rpcFailure("gatherthread/not-found", "GatherThread DSH action is unavailable");
4633
4790
  }
@@ -4709,6 +4866,9 @@ async function createProductionOwner(options) {
4709
4866
  }
4710
4867
  let stopPromise;
4711
4868
  return {
4869
+ localSyncStatuses: () => manager.localSyncStatuses(),
4870
+ setLocalAutoUpload: (sessionId, enabled) => manager.setLocalAutoUpload(sessionId, enabled),
4871
+ uploadLocalTurns: (sessionId) => manager.uploadLocalTurns(sessionId),
4712
4872
  stop() {
4713
4873
  stopPromise ??= (async () => {
4714
4874
  options.signal.removeEventListener("abort", abortFromRoot);
@@ -4773,6 +4933,9 @@ function createNativeManagedConnector(options) {
4773
4933
  async start() {
4774
4934
  await connector.start();
4775
4935
  },
4936
+ localSyncStatus: () => connector.localSyncStatus(),
4937
+ setLocalAutoUpload: (enabled) => connector.setLocalAutoUpload(enabled),
4938
+ uploadLocalTurns: () => connector.uploadLocalTurns(),
4776
4939
  stop() {
4777
4940
  stopPromise ??= (async () => {
4778
4941
  options.rootSignal.removeEventListener("abort", abortFromRoot);
@@ -1309,6 +1309,7 @@ var DshHostConnector = class {
1309
1309
  #timer;
1310
1310
  #heartbeatTimer;
1311
1311
  #pollPromise;
1312
+ #localSyncControlPromise;
1312
1313
  #heartbeatPromise;
1313
1314
  #stopPromise;
1314
1315
  #backgroundFatalError;
@@ -1340,6 +1341,47 @@ var DshHostConnector = class {
1340
1341
  get stopped() {
1341
1342
  return this.#stopped;
1342
1343
  }
1344
+ localSyncStatus() {
1345
+ const state = this.#requireState();
1346
+ const pendingOperations = state.outbox.filter((operation) => operation.kind === "local_turn");
1347
+ const pending = pendingOperations.length;
1348
+ const discoverFrom = pendingOperations.reduce((sequence, operation) => Math.max(sequence, operation.dshToSequence), state.publishedDshSequence);
1349
+ const discoverable = state.activeRequest === void 0 ? captureCompletedLocalTurns(this.#host.snapshotFrom(discoverFrom), discoverFrom, this.#config.dshSessionId).turns.length : 0;
1350
+ return {
1351
+ sessionId: this.#config.sessionId,
1352
+ localSessionId: this.#config.dshSessionId,
1353
+ automaticUpload: state.automaticUpload,
1354
+ pendingLocalTurns: pending,
1355
+ uploadableLocalTurns: pending + discoverable
1356
+ };
1357
+ }
1358
+ async setLocalAutoUpload(enabled) {
1359
+ const status = await this.#withLocalSyncControl(async () => {
1360
+ const state = this.#requireState();
1361
+ state.automaticUpload = enabled;
1362
+ await this.#stateStore.save(state);
1363
+ return this.localSyncStatus();
1364
+ });
1365
+ if (enabled)
1366
+ await this.pollOnce();
1367
+ return enabled ? this.localSyncStatus() : status;
1368
+ }
1369
+ uploadLocalTurns() {
1370
+ return this.#withLocalSyncControl(async () => {
1371
+ const before = this.localSyncStatus();
1372
+ await this.#flushOutbox(true);
1373
+ await this.#captureLocalTurns(0);
1374
+ const discoveredLocalTurns = this.localSyncStatus().pendingLocalTurns;
1375
+ const pendingBefore = this.localSyncStatus().pendingLocalTurns;
1376
+ await this.#flushOutbox(true);
1377
+ const after = this.localSyncStatus();
1378
+ return {
1379
+ ...after,
1380
+ discoveredLocalTurns,
1381
+ uploadedLocalTurns: Math.max(0, before.pendingLocalTurns + pendingBefore - after.pendingLocalTurns)
1382
+ };
1383
+ });
1384
+ }
1343
1385
  async start(options = {}) {
1344
1386
  if (this.#started)
1345
1387
  throw new Error("DSH connector is already started");
@@ -1404,7 +1446,7 @@ var DshHostConnector = class {
1404
1446
  this.#notifyLifecycle("idle");
1405
1447
  if (options.schedule !== false)
1406
1448
  this.#scheduleHeartbeat();
1407
- await this.#flushOutbox();
1449
+ await this.#flushOutbox(this.#requireState().automaticUpload);
1408
1450
  await this.#finalizeDeliveredRequest();
1409
1451
  if (this.#state.activeRequest !== void 0) {
1410
1452
  await this.#withExecutionPermit(() => this.#recoverActiveRequest());
@@ -1425,6 +1467,10 @@ var DshHostConnector = class {
1425
1467
  if (!this.#started || this.#stopped) {
1426
1468
  return Promise.reject(new Error("DSH connector is not running"));
1427
1469
  }
1470
+ const localSyncControl = this.#localSyncControlPromise;
1471
+ if (localSyncControl !== void 0) {
1472
+ return localSyncControl.then(() => this.pollOnce());
1473
+ }
1428
1474
  if (this.#pollPromise !== void 0)
1429
1475
  return this.#pollPromise;
1430
1476
  this.#pollPromise = this.#poll().then((result) => {
@@ -1439,6 +1485,28 @@ var DshHostConnector = class {
1439
1485
  });
1440
1486
  return this.#pollPromise;
1441
1487
  }
1488
+ async #withLocalSyncControl(operation) {
1489
+ const previous = this.#localSyncControlPromise;
1490
+ let release;
1491
+ const barrier = new Promise((resolve) => {
1492
+ release = resolve;
1493
+ });
1494
+ const current = (previous ?? Promise.resolve()).then(() => barrier);
1495
+ this.#localSyncControlPromise = current;
1496
+ try {
1497
+ await previous;
1498
+ const poll = this.#pollPromise;
1499
+ if (poll !== void 0)
1500
+ await poll;
1501
+ if (!this.#started || this.#stopped)
1502
+ throw new Error("DSH connector is not running");
1503
+ return await operation();
1504
+ } finally {
1505
+ release();
1506
+ if (this.#localSyncControlPromise === current)
1507
+ this.#localSyncControlPromise = void 0;
1508
+ }
1509
+ }
1442
1510
  stop() {
1443
1511
  this.#stopPromise ??= (async () => {
1444
1512
  this.#stopped = true;
@@ -1468,14 +1536,17 @@ var DshHostConnector = class {
1468
1536
  }
1469
1537
  async #poll() {
1470
1538
  const state = this.#requireState();
1471
- await this.#flushOutbox();
1539
+ await this.#flushOutbox(state.automaticUpload);
1472
1540
  await this.#finalizeDeliveredRequest();
1473
1541
  if (state.activeRequest !== void 0) {
1474
1542
  await this.#withExecutionPermit(() => this.#recoverActiveRequest());
1475
1543
  return { scanned: 0, claimed: 0, completed: 1 };
1476
1544
  }
1477
- await this.#captureLocalTurns();
1478
- await this.#flushOutbox();
1545
+ if (state.automaticUpload) {
1546
+ await this.#captureLocalTurns();
1547
+ await this.#flushOutbox(true);
1548
+ }
1549
+ const manualUploadRequired = !state.automaticUpload && this.localSyncStatus().uploadableLocalTurns > 0;
1479
1550
  let scanCursor = state.projectionCursor;
1480
1551
  let scanned = 0;
1481
1552
  while (!this.#stopped) {
@@ -1491,6 +1562,9 @@ var DshHostConnector = class {
1491
1562
  continue;
1492
1563
  }
1493
1564
  await this.#projectCanonicalBatch(passive, passive.at(-1)?.sequence ?? state.projectionCursor);
1565
+ if (manualUploadRequired) {
1566
+ return { scanned, claimed: 0, completed: 0 };
1567
+ }
1494
1568
  if (profile.provider !== void 0 && profile.provider !== this.#config.provider) {
1495
1569
  throw new Error("DeepSeek Harness Agent request provider does not match the configured Host binding");
1496
1570
  }
@@ -1668,7 +1742,7 @@ var DshHostConnector = class {
1668
1742
  state.serverCursor = Math.max(state.serverCursor, throughSequence);
1669
1743
  await this.#stateStore.save(state);
1670
1744
  }
1671
- async #captureLocalTurns() {
1745
+ async #captureLocalTurns(basedOnSequence) {
1672
1746
  const state = this.#requireState();
1673
1747
  if (state.activeRequest !== void 0 || state.outbox.length > 0)
1674
1748
  return;
@@ -1686,7 +1760,7 @@ var DshHostConnector = class {
1686
1760
  input: {
1687
1761
  localTurnId: turn.localTurnId,
1688
1762
  runtimeId: runtime.id,
1689
- basedOnSequence: state.serverCursor,
1763
+ basedOnSequence: basedOnSequence ?? state.serverCursor,
1690
1764
  occurredAt: turn.occurredAt,
1691
1765
  observedModel: this.#config.model,
1692
1766
  requestPayload: {
@@ -1788,13 +1862,15 @@ var DshHostConnector = class {
1788
1862
  });
1789
1863
  return operations;
1790
1864
  }
1791
- async #flushOutbox() {
1865
+ async #flushOutbox(includeLocalTurns = true) {
1792
1866
  const state = this.#requireState();
1793
1867
  const runtime = this.#requireRuntime();
1794
1868
  while (state.outbox.length > 0) {
1795
1869
  const operation = state.outbox[0];
1796
1870
  if (operation === void 0)
1797
1871
  break;
1872
+ if (operation.kind === "local_turn" && !includeLocalTurns)
1873
+ break;
1798
1874
  if (operation.input.runtimeId !== runtime.id) {
1799
1875
  throw new Error("DSH connector outbox belongs to a different runtime");
1800
1876
  }
@@ -1876,7 +1952,7 @@ var DshHostConnector = class {
1876
1952
  throw new Error("Persisted DSH Session exists without connector state; refusing ambiguous resume");
1877
1953
  }
1878
1954
  return {
1879
- version: 2,
1955
+ version: 3,
1880
1956
  binding: {
1881
1957
  projectId: this.#config.projectId,
1882
1958
  sessionId: this.#config.sessionId,
@@ -1885,6 +1961,7 @@ var DshHostConnector = class {
1885
1961
  serverCursor: 0,
1886
1962
  projectionCursor: 0,
1887
1963
  publishedDshSequence: 0,
1964
+ automaticUpload: true,
1888
1965
  outbox: []
1889
1966
  };
1890
1967
  }
@@ -2648,17 +2725,20 @@ var FileConnectorStateStore = class {
2648
2725
  function validateConnectorState(value) {
2649
2726
  const state = requiredObject3(value, "state");
2650
2727
  const legacy = state.version === 1;
2728
+ const legacyUploadPolicy = state.version === 1 || state.version === 2;
2651
2729
  exactKeys(state, /* @__PURE__ */ new Set([
2652
2730
  "version",
2653
2731
  "binding",
2654
2732
  "serverCursor",
2655
2733
  ...legacy ? [] : ["projectionCursor"],
2656
2734
  "publishedDshSequence",
2735
+ ...legacyUploadPolicy ? [] : ["automaticUpload"],
2657
2736
  "activeRequest",
2658
2737
  "outbox"
2659
2738
  ]), "state");
2660
- if (!legacy && state.version !== 2)
2739
+ if (state.version !== 1 && state.version !== 2 && state.version !== 3) {
2661
2740
  throw new Error("Unsupported DSH connector state version");
2741
+ }
2662
2742
  const binding = requiredObject3(state.binding, "state.binding");
2663
2743
  exactKeys(binding, /* @__PURE__ */ new Set(["projectId", "sessionId", "dshSessionId"]), "state.binding");
2664
2744
  const serverCursor = nonNegativeInteger(state.serverCursor, "state.serverCursor");
@@ -2669,7 +2749,7 @@ function validateConnectorState(value) {
2669
2749
  throw new Error("state.outbox must be an array");
2670
2750
  const outbox = state.outbox.map(parseOutboxOperation);
2671
2751
  const validated = {
2672
- version: 2,
2752
+ version: 3,
2673
2753
  binding: {
2674
2754
  projectId: safeString(binding.projectId, "state.binding.projectId", 128),
2675
2755
  sessionId: safeString(binding.sessionId, "state.binding.sessionId", 128),
@@ -2678,6 +2758,7 @@ function validateConnectorState(value) {
2678
2758
  serverCursor,
2679
2759
  projectionCursor,
2680
2760
  publishedDshSequence,
2761
+ automaticUpload: legacyUploadPolicy ? true : requiredBoolean(state.automaticUpload, "state.automaticUpload"),
2681
2762
  ...activeRequest === void 0 ? {} : { activeRequest },
2682
2763
  outbox
2683
2764
  };
@@ -2857,6 +2938,11 @@ function nonNegativeInteger(value, label) {
2857
2938
  }
2858
2939
  return Number(value);
2859
2940
  }
2941
+ function requiredBoolean(value, label) {
2942
+ if (typeof value !== "boolean")
2943
+ throw new Error(`${label} must be a boolean`);
2944
+ return value;
2945
+ }
2860
2946
  function positiveInteger(value, label) {
2861
2947
  const parsed = nonNegativeInteger(value, label);
2862
2948
  if (parsed === 0)
@@ -3020,6 +3106,21 @@ var DshProjectManager = class {
3020
3106
  activeSessionIds() {
3021
3107
  return [...this.#managed].filter(([, binding]) => binding.phase === "active" && !binding.connector.stopped).map(([sessionId]) => sessionId).sort();
3022
3108
  }
3109
+ localSyncStatuses() {
3110
+ 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));
3111
+ }
3112
+ async setLocalAutoUpload(sessionId, enabled) {
3113
+ const connector = this.#requireManagedConnector(sessionId);
3114
+ if (!connector.setLocalAutoUpload)
3115
+ throw new Error("This DSH conversation does not support upload controls");
3116
+ return connector.setLocalAutoUpload(enabled);
3117
+ }
3118
+ async uploadLocalTurns(sessionId) {
3119
+ const connector = this.#requireManagedConnector(sessionId);
3120
+ if (!connector.uploadLocalTurns)
3121
+ throw new Error("This DSH conversation does not support manual upload");
3122
+ return connector.uploadLocalTurns();
3123
+ }
3023
3124
  async start() {
3024
3125
  if (this.#started)
3025
3126
  throw new Error("DSH project manager is already started");
@@ -3259,6 +3360,13 @@ var DshProjectManager = class {
3259
3360
  throw new Error("Authenticated GatherThread actor is unavailable");
3260
3361
  return this.#actor;
3261
3362
  }
3363
+ #requireManagedConnector(sessionId) {
3364
+ const binding = this.#managed.get(sessionId);
3365
+ if (!binding || binding.phase !== "active" || binding.connector.stopped) {
3366
+ throw new Error("The selected DSH conversation is not active");
3367
+ }
3368
+ return binding.connector;
3369
+ }
3262
3370
  #reportError(error) {
3263
3371
  this.#onBackgroundError?.(error);
3264
3372
  }
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.3",
4
4
  "description": "Opt-in GatherThread integration for the DeepSeek Harness Web profile",
5
5
  "license": "Apache-2.0",
6
6
  "publishConfig": {