dsh-remote-local-plugin 0.8.3 → 0.8.5

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/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- /* dsh-remote-local-plugin@0.8.3 — bundled host entry */
1
+ /* dsh-remote-local-plugin@0.8.5 — bundled host entry */
2
2
 
3
3
  // src/index.ts
4
4
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
@@ -252,7 +252,7 @@ async function handleHostApi(ctx, service, mirrors, request) {
252
252
  });
253
253
  }
254
254
  try {
255
- const value = await dispatch(ctx, service, mirrors, body.endpoint, body.payload);
255
+ const value = await dispatch(ctx, service, mirrors, body.endpoint, body.payload, request.signal);
256
256
  return jsonResult({ ok: true, value });
257
257
  } catch (error) {
258
258
  const failure = asLocalError(error);
@@ -266,7 +266,7 @@ async function handleHostApi(ctx, service, mirrors, request) {
266
266
  });
267
267
  }
268
268
  }
269
- async function dispatch(ctx, service, mirrors, endpoint, payload) {
269
+ async function dispatch(ctx, service, mirrors, endpoint, payload, signal) {
270
270
  switch (endpoint) {
271
271
  case "state":
272
272
  return service.state();
@@ -295,6 +295,8 @@ async function dispatch(ctx, service, mirrors, endpoint, payload) {
295
295
  return service.refresh(requireStringField(payload, "hostId"));
296
296
  case "host/provision":
297
297
  return service.provision(requireStringField(payload, "hostId"));
298
+ case "host/restart":
299
+ return service.restart(requireStringField(payload, "hostId"));
298
300
  case "host/workspaces": {
299
301
  const hostId = requireStringField(payload, "hostId");
300
302
  const workspaces = await service.workspaces(hostId);
@@ -396,7 +398,6 @@ async function dispatch(ctx, service, mirrors, endpoint, payload) {
396
398
  const files = ctx.get("workspaceFiles");
397
399
  if (files === void 0) return { mirrored: false };
398
400
  const scope = { sessionId, workspaceRoot: root };
399
- const signal = new AbortController().signal;
400
401
  if (endpoint === "host/files/list") return { mirrored: true, value: await files.list(scope, path, signal) };
401
402
  if (endpoint === "host/files/stat") return { mirrored: true, value: await files.stat(scope, path, signal) };
402
403
  if (endpoint === "host/files/bytes") return { mirrored: true, value: await files.readBytes(scope, path, {
@@ -574,6 +575,7 @@ var RemoteMirrorService = class {
574
575
  #syncing = false;
575
576
  #saveQueue = Promise.resolve();
576
577
  #timer;
578
+ #selectionOff;
577
579
  #disposed = false;
578
580
  constructor(ctx, hosts, config) {
579
581
  this.#ctx = ctx;
@@ -583,6 +585,9 @@ var RemoteMirrorService = class {
583
585
  /** Load mappings and begin periodic discovery. */
584
586
  async start() {
585
587
  await this.#load();
588
+ this.#selectionOff = this.#ctx.on("session/event", ((session, event) => {
589
+ this.#onLocalSessionEvent(session, event);
590
+ }), { global: true });
586
591
  if (!this.#config.mirrorSessions) return;
587
592
  this.#timer = setInterval(() => {
588
593
  void this.sync();
@@ -593,9 +598,13 @@ var RemoteMirrorService = class {
593
598
  async dispose() {
594
599
  this.#disposed = true;
595
600
  if (this.#timer !== void 0) clearInterval(this.#timer);
601
+ this.#selectionOff?.();
602
+ this.#selectionOff = void 0;
596
603
  for (const runtime of this.#runtimes.values()) {
597
604
  runtime.stopped = true;
598
605
  if (runtime.timer !== void 0) clearTimeout(runtime.timer);
606
+ runtime.stream?.abort();
607
+ runtime.assistant?.abort();
599
608
  }
600
609
  for (const dispose of this.#registrations.values()) dispose();
601
610
  this.#registrations.clear();
@@ -674,18 +683,26 @@ var RemoteMirrorService = class {
674
683
  const text = contentText(message);
675
684
  if (text === "") return;
676
685
  runtime.pendingUsers.push(message);
686
+ if (!runtime.running) {
687
+ runtime.awaitingTurn = runtime.latestTurn + 1;
688
+ runtime.awaitingStarted = false;
689
+ } else {
690
+ runtime.awaitingStarted = true;
691
+ }
677
692
  this.#setRunning(runtime, true);
678
693
  if (runtime.stopped || runtime.timer === void 0) {
679
694
  runtime.stopped = false;
680
695
  runtime.lastError = null;
681
696
  this.#schedule(runtime);
682
697
  }
698
+ const selection = this.#selectionFor(runtime);
699
+ if (selection !== void 0) Object.assign(runtime.agent.options, selection);
683
700
  void this.#hosts.promptSession(
684
701
  runtime.record.hostId,
685
702
  runtime.record.remoteSessionId,
686
703
  text,
687
704
  mode,
688
- latestSelection(runtime.session)
705
+ selection
689
706
  ).catch((error) => {
690
707
  runtime.lastError = error instanceof Error ? error.message : String(error);
691
708
  runtime.pendingUsers.shift();
@@ -693,12 +710,32 @@ var RemoteMirrorService = class {
693
710
  this.#setRunning(runtime, false);
694
711
  });
695
712
  }
696
- /** Route cancellation to the remote session. */
713
+ /** Route cancellation to the remote session only after the remote accepts it. */
697
714
  cancel(runtime) {
698
- void this.#hosts.cancelSession(runtime.record.hostId, runtime.record.remoteSessionId).catch((error) => {
699
- this.#ctx.logger.warn("dsh-remote-local: remote cancel failed: %s", String(error));
715
+ void this.#hosts.cancelSession(runtime.record.hostId, runtime.record.remoteSessionId).then(() => {
716
+ runtime.awaitingTurn = null;
717
+ runtime.awaitingStarted = false;
718
+ this.#setRunning(runtime, false);
719
+ }).catch((error) => {
720
+ runtime.lastError = error instanceof Error ? error.message : String(error);
721
+ this.#ctx.logger.warn("dsh-remote-local: remote cancel failed: %s", runtime.lastError);
722
+ this.#setRunning(runtime, true);
700
723
  });
701
- this.#setRunning(runtime, false);
724
+ }
725
+ #selectionFor(runtime) {
726
+ const projections = this.#ctx.get("sessionProjections");
727
+ const state = projections?.stateOf?.(runtime.session, "modelSelection");
728
+ const read = (candidate) => {
729
+ if (typeof candidate !== "object" || candidate === null) return void 0;
730
+ const record = candidate;
731
+ if (typeof record.provider !== "string" || typeof record.model !== "string") return void 0;
732
+ return {
733
+ provider: record.provider,
734
+ model: record.model,
735
+ ...typeof record.reasoningEffort === "string" ? { reasoningEffort: record.reasoningEffort } : {}
736
+ };
737
+ };
738
+ return read(state?.pending) ?? latestSelection(runtime.session) ?? read(runtime.agent.options) ?? read(state?.lastUsed);
702
739
  }
703
740
  async #syncHost(host) {
704
741
  this.#ensureApprovalStream(host);
@@ -890,18 +927,32 @@ var RemoteMirrorService = class {
890
927
  async #ensureMirror(host, remote, workspace, _remotePath) {
891
928
  const ref = key(host.id, remote.sessionId);
892
929
  const existing = this.#state.sessions[ref];
930
+ let reuse;
893
931
  if (existing !== void 0) {
894
932
  const live = this.#sessions.get(existing.localSessionId);
895
- const runtime2 = this.#runtimes.get(existing.localSessionId);
896
- if (live !== void 0 && runtime2 !== void 0) {
933
+ const runtime = this.#runtimes.get(existing.localSessionId);
934
+ if (live !== void 0 && runtime !== void 0 && (!runtime.stopped || !remote.live)) {
897
935
  await this.#attach(workspace, live.id);
936
+ if (remote.live) {
937
+ this.#startStream(runtime);
938
+ this.#startAssistantStream(runtime);
939
+ }
898
940
  return existing;
899
941
  }
900
- if (live !== void 0) {
901
- await this.#archive(existing.localSessionId);
902
- } else if (existing.localSessionId !== "") {
903
- await this.#archive(existing.localSessionId);
942
+ const stalled = this.#runtimes.get(existing.localSessionId);
943
+ if (stalled !== void 0) {
944
+ stalled.stopped = true;
945
+ if (stalled.timer !== void 0) clearTimeout(stalled.timer);
946
+ stalled.stream?.abort();
947
+ stalled.assistant?.abort();
948
+ this.#runtimes.delete(existing.localSessionId);
904
949
  }
950
+ const registration = this.#registrations.get(existing.localSessionId);
951
+ if (registration !== void 0) {
952
+ registration();
953
+ this.#registrations.delete(existing.localSessionId);
954
+ }
955
+ if (live !== void 0) reuse = live;
905
956
  }
906
957
  const snapshot = await this.#hosts.snapshot(host.id, remote.sessionId, this.#config.mirrorMaxEvents);
907
958
  if (snapshot.truncated && (snapshot.events[0]?.seq ?? 0) > 0) {
@@ -914,11 +965,38 @@ var RemoteMirrorService = class {
914
965
  }
915
966
  const anchor = this.#anchorFor(workspace);
916
967
  await mkdir(anchor, { recursive: true });
917
- const localId = `session-${randomUUID()}`;
968
+ const filtered = snapshot.events.filter((event) => !isMirrorNoise(event.type));
969
+ const seqMap = new Map(filtered.map((event, index) => [event.seq, index]));
970
+ let latestTurn = 0;
971
+ for (const event of filtered) {
972
+ if (event.type !== "turn/start") continue;
973
+ const turn = event.data.turn;
974
+ if (typeof turn === "number") latestTurn = Math.max(latestTurn, turn);
975
+ }
976
+ const remoteToLocalSeq = /* @__PURE__ */ new Map();
977
+ for (const [index, event] of filtered.entries()) remoteToLocalSeq.set(event.seq, index);
978
+ if (reuse !== void 0) {
979
+ const localEvents = reuse.snapshotEvents();
980
+ for (let index = 0; index < Math.min(localEvents.length, filtered.length); index += 1) {
981
+ const localEvent = localEvents[index];
982
+ const remoteEvent = filtered[index];
983
+ if (localEvent !== void 0 && remoteEvent !== void 0) {
984
+ remoteToLocalSeq.set(remoteEvent.seq, localEvent.seq);
985
+ }
986
+ }
987
+ const record2 = existing;
988
+ await this.#attach(workspace, reuse.id);
989
+ await this.#mountRuntime(record2, reuse, snapshot, remoteToLocalSeq, latestTurn, remote.live);
990
+ this.#ctx.logger.info(
991
+ "dsh-remote-local: reattached remote session %s to local %s",
992
+ remote.sessionId,
993
+ reuse.id
994
+ );
995
+ return record2;
996
+ }
997
+ const localId = existing?.localSessionId ?? `session-${randomUUID()}`;
918
998
  let session;
919
999
  try {
920
- const filtered = snapshot.events.filter((event) => !isMirrorNoise(event.type));
921
- const seqMap = new Map(filtered.map((event, index) => [event.seq, index]));
922
1000
  const seed = filtered.map((event, index) => toSeedEvent(event, index, seqMap));
923
1001
  session = this.#sessions.create(localId, {
924
1002
  ...seed.length === 0 ? {} : { seed },
@@ -934,7 +1012,7 @@ var RemoteMirrorService = class {
934
1012
  } catch (error) {
935
1013
  this.#ctx.logger.debug("dsh-remote-local: mirror flush failed: %s", String(error));
936
1014
  }
937
- const record = {
1015
+ const record = existing === void 0 ? {
938
1016
  hostId: host.id,
939
1017
  remoteSessionId: remote.sessionId,
940
1018
  localSessionId: localId,
@@ -942,32 +1020,53 @@ var RemoteMirrorService = class {
942
1020
  anchor,
943
1021
  lastSeq: snapshot.cursor,
944
1022
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1023
+ } : {
1024
+ ...existing,
1025
+ remoteWorkspaceId: remote.workspaceId,
1026
+ anchor,
1027
+ lastSeq: snapshot.cursor,
1028
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
945
1029
  };
946
1030
  this.#state.sessions[ref] = record;
1031
+ await this.#mountRuntime(record, session, snapshot, remoteToLocalSeq, latestTurn, remote.live);
1032
+ this.#ctx.logger.info(
1033
+ "dsh-remote-local: mirrored remote session %s as local %s",
1034
+ remote.sessionId,
1035
+ localId
1036
+ );
1037
+ return record;
1038
+ }
1039
+ async #mountRuntime(record, session, snapshot, remoteToLocalSeq, latestTurn, live) {
947
1040
  const runtime = {
948
1041
  record,
949
1042
  agent: null,
950
1043
  session,
951
1044
  timer: void 0,
1045
+ stream: void 0,
1046
+ assistant: void 0,
1047
+ remoteToLocalSeq,
1048
+ pendingAssistantEnds: /* @__PURE__ */ new Map(),
1049
+ latestTurn,
1050
+ awaitingTurn: null,
1051
+ awaitingStarted: false,
1052
+ applyingRemote: false,
952
1053
  running: false,
953
1054
  stopped: false,
954
1055
  lastError: null,
955
1056
  pendingUsers: [],
956
1057
  waiters: []
957
1058
  };
958
- const options = modelOptions(snapshot.events);
1059
+ const options = this.#defaultSelection() ?? modelOptions(snapshot.events);
959
1060
  const agent = new RemoteMirrorAgent(this, runtime, this.#ctx, options);
960
1061
  runtime.agent = agent;
961
- this.#runtimes.set(localId, runtime);
962
- this.#registrations.set(localId, this.#agents.enter(agent, void 0));
1062
+ this.#runtimes.set(record.localSessionId, runtime);
1063
+ this.#registrations.set(record.localSessionId, this.#agents.enter(agent, void 0));
963
1064
  await this.#save();
964
1065
  this.#schedule(runtime);
965
- this.#ctx.logger.info(
966
- "dsh-remote-local: mirrored remote session %s as local %s",
967
- remote.sessionId,
968
- localId
969
- );
970
- return record;
1066
+ if (live) {
1067
+ this.#startStream(runtime);
1068
+ this.#startAssistantStream(runtime);
1069
+ }
971
1070
  }
972
1071
  #schedule(runtime) {
973
1072
  if (runtime.stopped || this.#disposed) return;
@@ -981,6 +1080,8 @@ var RemoteMirrorService = class {
981
1080
  const local = this.#sessions.get(runtime.session.id);
982
1081
  if (local === void 0) {
983
1082
  runtime.stopped = true;
1083
+ runtime.stream?.abort();
1084
+ runtime.assistant?.abort();
984
1085
  return;
985
1086
  }
986
1087
  const snapshot = await this.#hosts.snapshot(
@@ -991,24 +1092,29 @@ var RemoteMirrorService = class {
991
1092
  if (snapshot.truncated && (snapshot.events[0]?.seq ?? 0) > runtime.record.lastSeq) {
992
1093
  runtime.lastError = "remote log grew past the mirror window";
993
1094
  runtime.stopped = true;
1095
+ runtime.stream?.abort();
1096
+ runtime.assistant?.abort();
994
1097
  return;
995
1098
  }
996
- let sawEnd = false;
997
1099
  for (const event of snapshot.events) {
998
1100
  if (event.seq < runtime.record.lastSeq) continue;
999
1101
  if (event.seq !== runtime.record.lastSeq) {
1000
1102
  throw new Error(`remote event gap at ${event.seq}, mirror ${runtime.record.lastSeq}`);
1001
1103
  }
1002
- if (!isMirrorNoise(event.type)) appendRemoteEvent(local, event, runtime);
1104
+ this.#appendEvent(runtime, event);
1003
1105
  runtime.record.lastSeq = event.seq + 1;
1004
- if (event.type === "turn/end") sawEnd = true;
1005
1106
  }
1006
1107
  runtime.record.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1007
- if (sawEnd) this.#setRunning(runtime, false);
1008
1108
  if (!snapshot.live && runtime.record.lastSeq >= snapshot.cursor) {
1009
1109
  runtime.stopped = true;
1110
+ runtime.stream?.abort();
1111
+ runtime.assistant?.abort();
1010
1112
  }
1011
1113
  await this.#save();
1114
+ if (snapshot.live && !runtime.stopped) {
1115
+ this.#startStream(runtime);
1116
+ this.#startAssistantStream(runtime);
1117
+ }
1012
1118
  } catch (error) {
1013
1119
  runtime.lastError = error instanceof Error ? error.message : String(error);
1014
1120
  this.#ctx.logger.warn(
@@ -1019,12 +1125,204 @@ var RemoteMirrorService = class {
1019
1125
  }
1020
1126
  this.#schedule(runtime);
1021
1127
  }
1128
+ #startStream(runtime) {
1129
+ if (runtime.stopped || this.#disposed || runtime.stream !== void 0) return;
1130
+ const controller = new AbortController();
1131
+ runtime.stream = controller;
1132
+ void this.#followStream(runtime, controller.signal).finally(() => {
1133
+ if (runtime.stream === controller) runtime.stream = void 0;
1134
+ });
1135
+ }
1136
+ async #followStream(runtime, signal) {
1137
+ try {
1138
+ for await (const frame of this.#hosts.followSessionEvents(
1139
+ runtime.record.hostId,
1140
+ runtime.record.remoteSessionId,
1141
+ runtime.record.lastSeq,
1142
+ signal
1143
+ )) {
1144
+ if (runtime.stopped || this.#disposed || signal.aborted) return;
1145
+ if (frame.type === "end") return;
1146
+ if (frame.type !== "event") continue;
1147
+ const event = frame.event;
1148
+ if (event.seq < runtime.record.lastSeq) continue;
1149
+ if (event.seq !== runtime.record.lastSeq) {
1150
+ runtime.lastError = `remote event gap at ${event.seq}, mirror ${runtime.record.lastSeq}`;
1151
+ return;
1152
+ }
1153
+ this.#appendEvent(runtime, event);
1154
+ runtime.record.lastSeq = event.seq + 1;
1155
+ runtime.record.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1156
+ await this.#save();
1157
+ }
1158
+ } catch (error) {
1159
+ if (signal.aborted || runtime.stopped || this.#disposed) return;
1160
+ runtime.lastError = error instanceof Error ? error.message : String(error);
1161
+ this.#ctx.logger.warn(
1162
+ "dsh-remote-local: mirror stream %s failed: %s",
1163
+ runtime.record.remoteSessionId,
1164
+ runtime.lastError
1165
+ );
1166
+ }
1167
+ }
1168
+ #appendEvent(runtime, event) {
1169
+ const before = runtime.session.seq;
1170
+ runtime.applyingRemote = true;
1171
+ try {
1172
+ if (!isMirrorNoise(event.type)) {
1173
+ appendRemoteEvent(runtime.session, remapRemoteEvent(event, runtime.remoteToLocalSeq), runtime);
1174
+ }
1175
+ } finally {
1176
+ runtime.applyingRemote = false;
1177
+ }
1178
+ const after = runtime.session.seq;
1179
+ if (after > before) {
1180
+ runtime.remoteToLocalSeq.set(event.seq, after - 1);
1181
+ this.#flushAssistantEnd(runtime, event.seq);
1182
+ }
1183
+ const turn = typeof event.data === "object" && event.data !== null ? event.data.turn : void 0;
1184
+ if (typeof turn !== "number") return;
1185
+ if (event.type === "turn/start") {
1186
+ runtime.latestTurn = Math.max(runtime.latestTurn, turn);
1187
+ return;
1188
+ }
1189
+ if (event.type !== "turn/end") return;
1190
+ runtime.latestTurn = Math.max(runtime.latestTurn, turn);
1191
+ if (runtime.awaitingTurn === null || turn >= runtime.awaitingTurn) {
1192
+ runtime.awaitingTurn = null;
1193
+ runtime.awaitingStarted = false;
1194
+ this.#setRunning(runtime, false);
1195
+ }
1196
+ }
1197
+ #onLocalSessionEvent(session, event) {
1198
+ const candidate = event;
1199
+ if (candidate.type !== "model/selection") return;
1200
+ const runtime = [...this.#runtimes.values()].find((item) => item.session.id === session.id);
1201
+ if (runtime === void 0 || runtime.stopped || runtime.applyingRemote) return;
1202
+ const record = candidate.data;
1203
+ if (typeof record.provider !== "string" || typeof record.model !== "string") return;
1204
+ const selection = {
1205
+ provider: record.provider,
1206
+ model: record.model,
1207
+ ...typeof record.reasoningEffort === "string" ? { reasoningEffort: record.reasoningEffort } : {}
1208
+ };
1209
+ Object.assign(runtime.agent.options, selection);
1210
+ void this.#hosts.selectModel(runtime.record.hostId, runtime.record.remoteSessionId, selection).catch((error) => {
1211
+ runtime.lastError = error instanceof Error ? error.message : String(error);
1212
+ this.#ctx.logger.warn("dsh-remote-local: remote model selection failed: %s", runtime.lastError);
1213
+ });
1214
+ }
1215
+ #defaultSelection() {
1216
+ const defaults = this.#ctx.get("agentDefaultModel");
1217
+ const candidate = defaults?.currentSelection?.();
1218
+ if (typeof candidate !== "object" || candidate === null) return void 0;
1219
+ const record = candidate;
1220
+ if (typeof record.provider !== "string" || typeof record.model !== "string") return void 0;
1221
+ return {
1222
+ provider: record.provider,
1223
+ model: record.model,
1224
+ ...typeof record.reasoningEffort === "string" ? { reasoningEffort: record.reasoningEffort } : {}
1225
+ };
1226
+ }
1227
+ #flushAssistantEnd(runtime, remoteSeq) {
1228
+ const pending = runtime.pendingAssistantEnds.get(remoteSeq);
1229
+ if (pending === void 0) return;
1230
+ const localSeq = runtime.remoteToLocalSeq.get(remoteSeq);
1231
+ if (localSeq === void 0) return;
1232
+ runtime.pendingAssistantEnds.delete(remoteSeq);
1233
+ if (pending.outcome?.kind !== "committed") {
1234
+ this.#emitAssistant(runtime, pending);
1235
+ return;
1236
+ }
1237
+ this.#emitAssistant(runtime, {
1238
+ ...pending,
1239
+ outcome: { ...pending.outcome, seq: localSeq }
1240
+ });
1241
+ }
1242
+ #emitAssistant(runtime, frame) {
1243
+ try {
1244
+ const emit = this.#ctx.emit;
1245
+ emit.call(this.#ctx, "agent/assistant-stream", { agent: runtime.agent, frame });
1246
+ } catch (error) {
1247
+ this.#ctx.logger.warn(
1248
+ "dsh-remote-local: local assistant frame %s failed: %s",
1249
+ runtime.record.remoteSessionId,
1250
+ String(error)
1251
+ );
1252
+ }
1253
+ }
1254
+ #startAssistantStream(runtime) {
1255
+ if (runtime.stopped || this.#disposed || runtime.assistant !== void 0) return;
1256
+ const controller = new AbortController();
1257
+ runtime.assistant = controller;
1258
+ void this.#followAssistant(runtime, controller.signal).finally(() => {
1259
+ if (runtime.assistant === controller) runtime.assistant = void 0;
1260
+ });
1261
+ }
1262
+ async #followAssistant(runtime, signal) {
1263
+ try {
1264
+ for await (const frame of this.#hosts.followAssistantFrames(
1265
+ runtime.record.hostId,
1266
+ runtime.record.remoteSessionId,
1267
+ signal
1268
+ )) {
1269
+ if (runtime.stopped || this.#disposed || signal.aborted) return;
1270
+ if (frame.type === "start") {
1271
+ runtime.awaitingStarted = true;
1272
+ this.#setRunning(runtime, true);
1273
+ const { startedAfterSeq: _startedAfterSeq, ...rest } = frame;
1274
+ this.#emitAssistant(runtime, rest);
1275
+ continue;
1276
+ }
1277
+ if (frame.type === "chunk") {
1278
+ const chunkType = frame.chunk?.type;
1279
+ if (chunkType !== "tool-call-delta") this.#emitAssistant(runtime, frame);
1280
+ continue;
1281
+ }
1282
+ const outcome = frame.outcome;
1283
+ if (outcome === void 0) return;
1284
+ if (outcome.kind !== "committed") {
1285
+ this.#emitAssistant(runtime, frame);
1286
+ continue;
1287
+ }
1288
+ const localSeq = runtime.remoteToLocalSeq.get(outcome.seq);
1289
+ if (localSeq === void 0) {
1290
+ runtime.pendingAssistantEnds.set(outcome.seq, frame);
1291
+ continue;
1292
+ }
1293
+ this.#emitAssistant(runtime, {
1294
+ ...frame,
1295
+ outcome: { ...outcome, seq: localSeq }
1296
+ });
1297
+ }
1298
+ } catch (error) {
1299
+ if (signal.aborted || runtime.stopped || this.#disposed) return;
1300
+ this.#ctx.logger.warn(
1301
+ "dsh-remote-local: assistant stream %s failed: %s",
1302
+ runtime.record.remoteSessionId,
1303
+ error instanceof Error ? error.message : String(error)
1304
+ );
1305
+ }
1306
+ }
1022
1307
  #setRunning(runtime, running) {
1023
1308
  if (runtime.running === running) return;
1024
1309
  runtime.running = running;
1025
1310
  if (!running) {
1026
1311
  for (const waiter of runtime.waiters.splice(0)) waiter();
1027
1312
  }
1313
+ try {
1314
+ const emit = this.#ctx.emit;
1315
+ emit.call(this.#ctx, "agent/status", {
1316
+ agent: runtime.agent,
1317
+ status: running ? "running" : "idle"
1318
+ });
1319
+ } catch (error) {
1320
+ this.#ctx.logger.warn(
1321
+ "dsh-remote-local: local agent status %s failed: %s",
1322
+ runtime.record.remoteSessionId,
1323
+ String(error)
1324
+ );
1325
+ }
1028
1326
  }
1029
1327
  async #attach(workspace, sessionId) {
1030
1328
  try {
@@ -1074,6 +1372,19 @@ var RemoteMirrorService = class {
1074
1372
  return task;
1075
1373
  }
1076
1374
  };
1375
+ function remapRemoteEvent(event, remoteToLocal) {
1376
+ const sourceEventSeqs = event.sourceEventSeqs?.map((seq) => remoteToLocal.get(seq)).filter((seq) => seq !== void 0);
1377
+ const surfaceOp = typeof event.surfaceOp === "object" ? {
1378
+ op: "replace",
1379
+ startSeq: remoteToLocal.get(event.surfaceOp.startSeq) ?? 0,
1380
+ endSeq: remoteToLocal.get(event.surfaceOp.endSeq) ?? 0
1381
+ } : event.surfaceOp;
1382
+ return {
1383
+ ...event,
1384
+ ...sourceEventSeqs === void 0 || sourceEventSeqs.length === 0 ? {} : { sourceEventSeqs },
1385
+ ...surfaceOp === void 0 ? {} : { surfaceOp }
1386
+ };
1387
+ }
1077
1388
  function isMirrorNoise(type) {
1078
1389
  return type === "agent/inbox/spliced";
1079
1390
  }
@@ -1189,6 +1500,7 @@ function safe(value) {
1189
1500
  // src/remote/files.ts
1190
1501
  import { spawn as spawn2 } from "node:child_process";
1191
1502
  import { isAbsolute as isAbsolute2, join as join4, relative, resolve as resolve3, sep } from "node:path";
1503
+ var HELPER_TIMEOUT_MS = 15e3;
1192
1504
  var HELPER = String.raw`
1193
1505
  import sys, json, os, base64
1194
1506
  req = json.load(sys.stdin)
@@ -1346,21 +1658,41 @@ function installRemoteWorkspaceFiles(ctx, mirrors, hosts) {
1346
1658
  function targetOf(mirrors, workspaceRoot, path) {
1347
1659
  const context = mirrors.remoteFileContext(workspaceRoot);
1348
1660
  if (context === void 0) return void 0;
1349
- const localPath = isAbsolute2(path) ? resolve3(path) : resolve3(workspaceRoot, path);
1350
- const rel = relative(workspaceRoot, localPath);
1351
- if (rel === "" || !rel.startsWith("..") && !isAbsolute2(rel)) {
1352
- const remotePath = rel === "" ? context.remoteRoot : join4(context.remoteRoot, rel);
1661
+ const anchor = resolve3(workspaceRoot);
1662
+ const remoteRoot = resolve3(context.remoteRoot);
1663
+ const candidate = isAbsolute2(path) ? resolve3(path) : resolve3(anchor, path);
1664
+ const contains = (root, value) => {
1665
+ const rel = relative(root, value);
1666
+ return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
1667
+ };
1668
+ if (isAbsolute2(path) && contains(remoteRoot, candidate)) {
1669
+ const rel = relative(remoteRoot, candidate);
1670
+ return {
1671
+ hostId: context.hostId,
1672
+ localPath: rel === "" ? anchor : resolve3(anchor, rel),
1673
+ remotePath: candidate,
1674
+ relativePath: rel.split(sep).join("/"),
1675
+ workspaceRoot: anchor
1676
+ };
1677
+ }
1678
+ if (contains(anchor, candidate)) {
1679
+ const rel = relative(anchor, candidate);
1680
+ const remotePath = rel === "" ? remoteRoot : join4(remoteRoot, rel);
1353
1681
  return {
1354
1682
  hostId: context.hostId,
1355
- localPath: localPath === "" ? workspaceRoot : localPath,
1683
+ localPath: candidate,
1356
1684
  remotePath,
1357
1685
  relativePath: rel.split(sep).join("/"),
1358
- workspaceRoot
1686
+ workspaceRoot: anchor
1359
1687
  };
1360
1688
  }
1361
- return void 0;
1689
+ throw Object.assign(
1690
+ new Error(`remote file path is outside the mirrored workspace: ${path}`),
1691
+ { code: "workspace-file/not-found" }
1692
+ );
1362
1693
  }
1363
1694
  async function helper(hosts, hostId, request, signal) {
1695
+ if (signal.aborted) throw new Error("remote file helper aborted");
1364
1696
  const ssh = hosts.sshFor(hostId);
1365
1697
  const args = [
1366
1698
  "-o",
@@ -1380,7 +1712,7 @@ async function helper(hosts, hostId, request, signal) {
1380
1712
  const timer = setTimeout(() => {
1381
1713
  child.kill("SIGKILL");
1382
1714
  reject(new Error("remote file helper timed out"));
1383
- }, 3e4);
1715
+ }, HELPER_TIMEOUT_MS);
1384
1716
  const abort = () => {
1385
1717
  child.kill("SIGKILL");
1386
1718
  reject(new Error("remote file helper aborted"));
@@ -1394,6 +1726,11 @@ async function helper(hosts, hostId, request, signal) {
1394
1726
  child.stderr.on("data", (chunk) => {
1395
1727
  stderr += chunk;
1396
1728
  });
1729
+ child.stdin.on("error", (error) => {
1730
+ clearTimeout(timer);
1731
+ signal.removeEventListener("abort", abort);
1732
+ reject(error);
1733
+ });
1397
1734
  child.on("error", (error) => {
1398
1735
  clearTimeout(timer);
1399
1736
  signal.removeEventListener("abort", abort);
@@ -1840,6 +2177,35 @@ var RemoteProtocolError = class extends Error {
1840
2177
  }
1841
2178
  };
1842
2179
 
2180
+ // ../protocol/lib/types.js
2181
+ function isRemoteAssistantFrame(value) {
2182
+ if (typeof value !== "object" || value === null)
2183
+ return false;
2184
+ const frame = value;
2185
+ if (frame.type !== "start" && frame.type !== "chunk" && frame.type !== "end")
2186
+ return false;
2187
+ if (typeof frame.attemptId !== "string" || typeof frame.revision !== "number")
2188
+ return false;
2189
+ if (frame.type === "start") {
2190
+ return typeof frame.turn === "number" && typeof frame.step === "number";
2191
+ }
2192
+ if (frame.type === "chunk") {
2193
+ return typeof frame.index === "number" && typeof frame.time === "number" && "chunk" in frame;
2194
+ }
2195
+ if (typeof frame.index !== "number")
2196
+ return false;
2197
+ const outcome = frame.outcome;
2198
+ if (typeof outcome !== "object" || outcome === null)
2199
+ return false;
2200
+ const kind = outcome.kind;
2201
+ if (kind === "abandoned")
2202
+ return true;
2203
+ if (kind !== "committed")
2204
+ return false;
2205
+ const record = outcome;
2206
+ return typeof record.eventType === "string" && typeof record.seq === "number";
2207
+ }
2208
+
1843
2209
  // ../protocol/lib/routes.js
1844
2210
  var AGENT_ROUTES = {
1845
2211
  health: `${AGENT_API_PREFIX}/health`,
@@ -1856,6 +2222,9 @@ function workspaceSessionsPath(workspaceId) {
1856
2222
  function sessionPath(sessionId) {
1857
2223
  return `${AGENT_API_PREFIX}/sessions/${encodeURIComponent(sessionId)}`;
1858
2224
  }
2225
+ function sessionModelPath(sessionId) {
2226
+ return `${sessionPath(sessionId)}/model`;
2227
+ }
1859
2228
  function sessionPromptPath(sessionId) {
1860
2229
  return `${sessionPath(sessionId)}/prompt`;
1861
2230
  }
@@ -1866,6 +2235,9 @@ function sessionEventsPath(sessionId, cursor) {
1866
2235
  const base = `${sessionPath(sessionId)}/events`;
1867
2236
  return cursor === void 0 ? base : `${base}?cursor=${encodeURIComponent(String(cursor))}`;
1868
2237
  }
2238
+ function sessionAssistantEventsPath(sessionId) {
2239
+ return `${sessionPath(sessionId)}/assistant-events`;
2240
+ }
1869
2241
  var SESSION_PREFIX = `${AGENT_API_PREFIX}/sessions/`;
1870
2242
  var WORKSPACE_PREFIX = `${AGENT_API_PREFIX}/workspaces/`;
1871
2243
 
@@ -2108,6 +2480,13 @@ var RemoteAgentClient = class {
2108
2480
  body: request
2109
2481
  });
2110
2482
  }
2483
+ /** Select the model and reasoning effort for one remote session. */
2484
+ async selectModel(sessionId, request) {
2485
+ await this.#request(sessionModelPath(sessionId), isAcceptedResult, {
2486
+ method: "POST",
2487
+ body: request
2488
+ });
2489
+ }
2111
2490
  /** Send a text prompt to a remote session. */
2112
2491
  async promptSession(sessionId, request) {
2113
2492
  await this.#request(sessionPromptPath(sessionId), isAcceptedResult, {
@@ -2192,6 +2571,38 @@ var RemoteAgentClient = class {
2192
2571
  yield frame;
2193
2572
  }
2194
2573
  }
2574
+ /** Follow one session's dense process-local Assistant frames. */
2575
+ async *followAssistantFrames(sessionId, signal) {
2576
+ const response = await this.#openStream(sessionAssistantEventsPath(sessionId), signal);
2577
+ const body = response.body;
2578
+ if (body === null) {
2579
+ throw new RemoteProtocolError({
2580
+ code: "request/invalid",
2581
+ message: "remote agent returned an empty assistant stream"
2582
+ });
2583
+ }
2584
+ for await (const message of parseSseStream(body)) {
2585
+ if (message.data === "")
2586
+ continue;
2587
+ let frame;
2588
+ try {
2589
+ frame = JSON.parse(message.data);
2590
+ } catch (error) {
2591
+ throw new RemoteProtocolError({
2592
+ code: "request/invalid",
2593
+ message: "remote agent sent malformed assistant JSON",
2594
+ cause: error
2595
+ });
2596
+ }
2597
+ if (!isRemoteAssistantFrame(frame)) {
2598
+ throw new RemoteProtocolError({
2599
+ code: "request/invalid",
2600
+ message: "remote agent sent an unknown assistant frame"
2601
+ });
2602
+ }
2603
+ yield frame;
2604
+ }
2605
+ }
2195
2606
  /** Validate an event sequence against a cursor. */
2196
2607
  static guardEvent(cursor, event) {
2197
2608
  return cursor.accept(event);
@@ -2656,6 +3067,7 @@ function renderSystemdUnit(options) {
2656
3067
  // $PATH is expanded by the bootstrap shell after nodePathPrelude(), so the
2657
3068
  // systemd unit sees the discovered nvm/Volta/fnm Node bin directory too.
2658
3069
  'Environment="PATH=$INSTALL_DIR/node/bin:$INSTALL_DIR/node_modules/.bin:/usr/local/bin:/usr/bin:/bin:$PATH"',
3070
+ "${PROXY_ENV_LINES}",
2659
3071
  ...options.modelBaseUrl === void 0 ? [] : ['Environment="DEEPSEEK_BASE_URL=$MODEL_BASE_URL"'],
2660
3072
  ...options.modelApiKey === void 0 ? [] : ['Environment="DEEPSEEK_API_KEY=$MODEL_API_KEY"'],
2661
3073
  'ExecStart="$DSH_BIN" web --host 127.0.0.1 --port $PORT --no-open',
@@ -2795,6 +3207,9 @@ PNPM_VERSION=${shellLiteral(options.pnpmVersion)}
2795
3207
  NODE_VERSION=${shellLiteral(options.nodeVersion)}
2796
3208
  AUTO_INSTALL_NODE=${options.autoInstallNode ? "1" : "0"}
2797
3209
  AGENT_TARBALL=${shellLiteral(options.agentTarball)}
3210
+ AGENT_NPM_SPEC=${options.agentPackageSpec === void 0 ? "''" : shellLiteral(options.agentPackageSpec)}
3211
+ AGENT_ARTIFACT_DIR="$INSTALL_DIR/artifacts"
3212
+ AGENT_ARTIFACT="$AGENT_ARTIFACT_DIR/$(basename "$AGENT_TARBALL")"
2798
3213
  MODEL_BASE_URL=${options.modelBaseUrl === void 0 ? "''" : shellLiteral(options.modelBaseUrl)}
2799
3214
  MODEL_API_KEY=${options.modelApiKey === void 0 ? "''" : shellLiteral(options.modelApiKey)}
2800
3215
  PROXY_URL=${options.proxyUrl === void 0 ? "''" : shellLiteral(options.proxyUrl)}
@@ -2858,6 +3273,16 @@ if [ -n "$PROXY_URL" ]; then
2858
3273
  fi
2859
3274
  echo "dsh-remote: install proxy configured"
2860
3275
  fi
3276
+ PROXY_ENV_LINES=""
3277
+ if [ -n "$PROXY_URL" ]; then
3278
+ PROXY_ENV_LINES="Environment=\\"HTTP_PROXY=$PROXY_URL\\"
3279
+ Environment=\\"HTTPS_PROXY=$PROXY_URL\\"
3280
+ Environment=\\"ALL_PROXY=$PROXY_URL\\""
3281
+ if [ -n "$PROXY_NO_PROXY" ]; then
3282
+ PROXY_ENV_LINES="$PROXY_ENV_LINES
3283
+ Environment=\\"NO_PROXY=$PROXY_NO_PROXY\\""
3284
+ fi
3285
+ fi
2861
3286
 
2862
3287
  ${nodePathPrelude()}
2863
3288
 
@@ -2898,7 +3323,14 @@ if [ ! -x "$DSH_BIN" ]; then
2898
3323
  fi
2899
3324
 
2900
3325
  echo "dsh-remote: adding plugin to profile $PROFILE"
2901
- "$DSH_BIN" plugin --profile "$PROFILE" add "$AGENT_TARBALL"
3326
+ mkdir -p "$AGENT_ARTIFACT_DIR"
3327
+ cp -f "$AGENT_TARBALL" "$AGENT_ARTIFACT"
3328
+ if [ -n "$AGENT_NPM_SPEC" ] && "$DSH_BIN" plugin --profile "$PROFILE" add "$AGENT_NPM_SPEC"; then
3329
+ echo "dsh-remote: installed agent from npm"
3330
+ else
3331
+ echo "dsh-remote: npm agent unavailable; installing the bundled agent" >&2
3332
+ "$DSH_BIN" plugin --profile "$PROFILE" add "$AGENT_ARTIFACT"
3333
+ fi
2902
3334
 
2903
3335
  echo "dsh-remote: starting dsh web"
2904
3336
  ${startBlock}
@@ -3122,6 +3554,7 @@ async function provisionRemoteAgent(options) {
3122
3554
  agentTarball: remoteTarball,
3123
3555
  ...options.modelBaseUrl === void 0 ? {} : { modelBaseUrl: options.modelBaseUrl },
3124
3556
  ...options.modelApiKey === void 0 ? {} : { modelApiKey: options.modelApiKey },
3557
+ ...options.agentPackageSpec === void 0 ? {} : { agentPackageSpec: options.agentPackageSpec },
3125
3558
  ...options.proxyUrl === void 0 ? {} : { proxyUrl: options.proxyUrl },
3126
3559
  ...options.noProxy === void 0 ? {} : { noProxy: options.noProxy }
3127
3560
  });
@@ -3283,6 +3716,8 @@ var RemoteProvisioner = class {
3283
3716
  */
3284
3717
  async ensureInstalled(target, options = {}) {
3285
3718
  const tarball = findAgentTarball();
3719
+ const version = /dsh-remote-agent-(.+)\.tgz$/u.exec(tarball)?.[1];
3720
+ const agentPackageSpec = version === void 0 ? void 0 : `@goldenpigeon/dsh-remote-agent@${version}`;
3286
3721
  options.onProgress?.("\u6B63\u5728\u51C6\u5907\u8FDC\u7AEF\u5B89\u88C5\u73AF\u5883\u2026");
3287
3722
  const proxyUrl = this.#config.remoteInstallProxy.trim() || void 0;
3288
3723
  if (proxyUrl !== void 0) options.onProgress?.("\u6B63\u5728\u914D\u7F6E\u8FDC\u7AEF\u5B89\u88C5\u4EE3\u7406\u2026");
@@ -3290,6 +3725,7 @@ var RemoteProvisioner = class {
3290
3725
  await provisionRemoteAgent({
3291
3726
  target: target.canonical,
3292
3727
  agentTarballPath: tarball,
3728
+ ...agentPackageSpec === void 0 ? {} : { agentPackageSpec },
3293
3729
  sshBinary: this.#config.sshBinary,
3294
3730
  strictHostKeyChecking: this.#config.strictHostKeyChecking,
3295
3731
  profile: "web",
@@ -3395,6 +3831,40 @@ function findAgentTarball() {
3395
3831
  );
3396
3832
  }
3397
3833
 
3834
+ // src/remote/restart.ts
3835
+ import { execFile as execFile4 } from "node:child_process";
3836
+ import { promisify as promisify3 } from "node:util";
3837
+ var execFileAsync3 = promisify3(execFile4);
3838
+ var RESTART_COMMAND = [
3839
+ "if command -v systemctl >/dev/null 2>&1 && systemctl --user cat dsh-remote-agent.service >/dev/null 2>&1; then",
3840
+ " systemctl --user restart dsh-remote-agent.service",
3841
+ "else",
3842
+ ' echo "dsh-remote: dsh-remote-agent.service was not found; use Reinstall or restart the remote DSH manually" >&2',
3843
+ " exit 4",
3844
+ "fi"
3845
+ ].join("\n");
3846
+ async function restartRemoteAgent(target, options) {
3847
+ try {
3848
+ await execFileAsync3(
3849
+ options.sshBinary,
3850
+ [
3851
+ ...baseSshArgs(target, { timeoutMs: 6e4, strictHostKeyChecking: options.strictHostKeyChecking }),
3852
+ target.destination,
3853
+ RESTART_COMMAND
3854
+ ],
3855
+ { timeout: 75e3, maxBuffer: 64 * 1024, encoding: "utf8", windowsHide: true }
3856
+ );
3857
+ } catch (error) {
3858
+ const failure = error;
3859
+ const detail = (failure.stderr ?? "").trim().split("\n")[0] ?? "";
3860
+ throw new LocalError(
3861
+ "agent/unavailable",
3862
+ failure.killed === true ? "remote DSH restart timed out" : detail !== "" ? `remote DSH restart failed: ${detail}` : `remote DSH restart failed: ${failure.message}`,
3863
+ { details: { host: target.canonical }, retryable: true, cause: error }
3864
+ );
3865
+ }
3866
+ }
3867
+
3398
3868
  // src/ssh/target.ts
3399
3869
  var USER_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$/u;
3400
3870
  var HOST_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,252}[A-Za-z0-9])?$/u;
@@ -3858,6 +4328,60 @@ var RemoteHostsService = class {
3858
4328
  }
3859
4329
  return this.connect(id);
3860
4330
  }
4331
+ /** Restart the remote DSH service and reconnect without reinstalling. */
4332
+ async restart(id) {
4333
+ this.#assertAlive();
4334
+ const record = this.#requireRecord(id);
4335
+ const target = parseSshTarget(record.sshTarget);
4336
+ this.#manualDisconnect.delete(id);
4337
+ this.#clearReconnect(id);
4338
+ await this.#stopTransport(id);
4339
+ this.#setRuntime(id, {
4340
+ ...this.#runtimeFor(id),
4341
+ phase: "connecting",
4342
+ localPort: null,
4343
+ localUrl: null,
4344
+ hostInfo: null,
4345
+ connectedAt: null,
4346
+ error: null,
4347
+ errorCode: null,
4348
+ detail: "\u6B63\u5728\u91CD\u542F\u8FDC\u7AEF DSH\u2026"
4349
+ });
4350
+ try {
4351
+ await restartRemoteAgent(target, {
4352
+ sshBinary: this.config.sshBinary,
4353
+ strictHostKeyChecking: this.config.strictHostKeyChecking
4354
+ });
4355
+ } catch (error) {
4356
+ const failure2 = this.#markError(id, error, "agent/unavailable");
4357
+ throw failure2;
4358
+ }
4359
+ const deadline = Date.now() + 9e4;
4360
+ let lastError;
4361
+ while (Date.now() < deadline) {
4362
+ try {
4363
+ const discovery = await this.#discover(target);
4364
+ if (isAgentLive(discovery.state)) {
4365
+ await this.#saveRecord(id, {
4366
+ remoteAgentPort: discovery.state.port,
4367
+ token: discovery.state.token
4368
+ });
4369
+ return this.connect(id);
4370
+ }
4371
+ } catch (error) {
4372
+ lastError = error;
4373
+ }
4374
+ await new Promise((resolve4) => setTimeout(resolve4, 1e3));
4375
+ }
4376
+ const failure = new LocalError("agent/unavailable", "\u8FDC\u7AEF DSH \u91CD\u542F\u540E\u672A\u80FD\u5728 90 \u79D2\u5185\u5C31\u7EEA", {
4377
+ details: {
4378
+ host: target.canonical,
4379
+ reason: lastError instanceof Error ? lastError.message : String(lastError ?? "state file not ready")
4380
+ },
4381
+ retryable: true
4382
+ });
4383
+ throw this.#markError(id, failure, "agent/unavailable");
4384
+ }
3861
4385
  /** List remote workspaces. */
3862
4386
  async workspaces(id) {
3863
4387
  const client = this.#requireClient(id);
@@ -3879,6 +4403,16 @@ var RemoteHostsService = class {
3879
4403
  throw this.#remoteFailure(id, error);
3880
4404
  }
3881
4405
  }
4406
+ /** Follow a live remote session from a cursor as an SSE-backed stream. */
4407
+ async *followSessionEvents(id, sessionId, cursor, signal) {
4408
+ const client = this.#requireClient(id);
4409
+ try {
4410
+ yield* client.followSession(sessionId, cursor, signal);
4411
+ } catch (error) {
4412
+ if (signal.aborted) return;
4413
+ throw this.#remoteFailure(id, error);
4414
+ }
4415
+ }
3882
4416
  /** Read one session snapshot. */
3883
4417
  async snapshot(id, sessionId, limit) {
3884
4418
  const client = this.#requireClient(id);
@@ -3888,6 +4422,16 @@ var RemoteHostsService = class {
3888
4422
  throw this.#remoteFailure(id, error);
3889
4423
  }
3890
4424
  }
4425
+ /** Follow dense remote Assistant frames for one live Session. */
4426
+ async *followAssistantFrames(id, sessionId, signal) {
4427
+ const client = this.#requireClient(id);
4428
+ try {
4429
+ yield* client.followAssistantFrames(sessionId, signal);
4430
+ } catch (error) {
4431
+ if (signal.aborted) return;
4432
+ throw this.#remoteFailure(id, error);
4433
+ }
4434
+ }
3891
4435
  /** Register a remote workspace. */
3892
4436
  async createWorkspace(id, request) {
3893
4437
  const client = this.#requireClient(id);
@@ -3908,6 +4452,15 @@ var RemoteHostsService = class {
3908
4452
  throw this.#remoteFailure(id, error);
3909
4453
  }
3910
4454
  }
4455
+ /** Install a model/reasoning-effort selection on a remote session. */
4456
+ async selectModel(id, sessionId, selection) {
4457
+ const client = this.#requireClient(id);
4458
+ try {
4459
+ await client.selectModel(sessionId, selection);
4460
+ } catch (error) {
4461
+ throw this.#remoteFailure(id, error);
4462
+ }
4463
+ }
3911
4464
  /** Send a prompt to a remote session. */
3912
4465
  async promptSession(id, sessionId, text, mode, selection) {
3913
4466
  const client = this.#requireClient(id);
@@ -4389,7 +4942,7 @@ var renderJson = (_args, value) => [
4389
4942
  function toJsonObject(value) {
4390
4943
  return JSON.parse(JSON.stringify(value));
4391
4944
  }
4392
- var HOST_WRITE_TOOLS = /* @__PURE__ */ new Set(["remote_host_add", "remote_host_connect", "remote_host_provision"]);
4945
+ var HOST_WRITE_TOOLS = /* @__PURE__ */ new Set(["remote_host_add", "remote_host_connect", "remote_host_provision", "remote_host_restart"]);
4393
4946
  function installRemoteToolGuards(ctx) {
4394
4947
  ctx.effect(() => ctx.on("tools/pre-execute", (exec, next) => {
4395
4948
  if (!HOST_WRITE_TOOLS.has(exec.name)) return next();
@@ -4490,6 +5043,24 @@ function registerRemoteTools(ctx, service) {
4490
5043
  return { host: toJsonObject(await service.provision(args.hostId)) };
4491
5044
  }
4492
5045
  }));
5046
+ ctx.tools.register(defineTool({
5047
+ name: "remote_host_restart",
5048
+ description: "Restart the remote DSH service without reinstalling it, preserving installed remote plugins, then reconnect. Requires the installer-owned systemd user unit; use remote_host_provision for nohup installs.",
5049
+ parameters: {
5050
+ hostId: { type: "string", required: true, description: "Registered host id." }
5051
+ },
5052
+ output: {
5053
+ schema: {
5054
+ type: "object",
5055
+ additionalProperties: false,
5056
+ properties: { host: { type: "object", additionalProperties: true, required: true } }
5057
+ },
5058
+ render: renderJson
5059
+ },
5060
+ async execute(args) {
5061
+ return { host: toJsonObject(await service.restart(args.hostId)) };
5062
+ }
5063
+ }));
4493
5064
  ctx.tools.register(defineTool({
4494
5065
  name: "remote_workspaces_list",
4495
5066
  description: "List workspaces registered on a connected remote DSH host. Use the returned workspaceId with remote_sessions_list.",