dsh-remote-vs 0.9.2 → 0.10.0

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
@@ -4006,7 +4006,7 @@ const Config = z.object({
4006
4006
  "off",
4007
4007
  "shadow",
4008
4008
  "enforce"
4009
- ]).default("off").volatile(),
4009
+ ]).default("enforce").volatile(),
4010
4010
  gitTimeoutMs: z.number().step(1).min(MIN_TIMEOUT_MS).max(MAX_TIMEOUT_MS).default(DEFAULT_GIT_TIMEOUT_MS).volatile(),
4011
4011
  readTimeoutMs: z.number().step(1).min(MIN_TIMEOUT_MS).max(MAX_TIMEOUT_MS).default(DEFAULT_READ_TIMEOUT_MS).volatile()
4012
4012
  });
@@ -4063,7 +4063,7 @@ function apply(ctx, config) {
4063
4063
  const worlds = new RemoteWorlds(ctx, {
4064
4064
  store,
4065
4065
  pool,
4066
- mode: () => readField(config?.routingMode, "off")
4066
+ mode: () => readField(config?.routingMode, "enforce")
4067
4067
  });
4068
4068
  ctx.provide("remoteWorlds", worlds);
4069
4069
  ctx.effect(() => () => {
@@ -95,7 +95,11 @@ interface InputSnapshot {
95
95
  draft: string;
96
96
  draftRev?: number;
97
97
  occurrences?: readonly OccurrenceLike[];
98
- phase?: 'plain' | 'adjudicating' | 'claimed' | 'submitting';
98
+ /**
99
+ * Host submission plane. `sending` exists since DSH 0.2.2-alpha.2: an ordinary
100
+ * message the host has not accepted yet — the editor is read-only there too.
101
+ */
102
+ phase?: 'plain' | 'adjudicating' | 'claimed' | 'submitting' | 'sending';
99
103
  claim?: {
100
104
  readonly token: string;
101
105
  };
@@ -7,6 +7,11 @@
7
7
  * repository shows the newest GIT_LOG_MAX commits and is filtered client-side.
8
8
  * The docstring here used to promise scroll-triggered paging that does not
9
9
  * exist, which is worse than no comment: a reader trusts it.
10
+ *
11
+ * Each repository's last answer is kept in memory (`./panel-cache.ts`), so a
12
+ * switch back to a workspace repaints its commit list at once and the log is
13
+ * re-read behind it — the tab used to blank on every switch, and worse, kept the
14
+ * PREVIOUS repository's rows until the new log arrived.
10
15
  */
11
16
  import { type ReactNode } from 'react';
12
17
  export declare function HistoryPanel(): ReactNode;
@@ -16,6 +16,31 @@ export declare class GitOpError extends Error {
16
16
  export declare function joinRemote(dir: string, rel: string): string;
17
17
  /** The repository that encloses a workspace directory (undefined when none). */
18
18
  export declare function resolveRepoRoot(alias: string, workspaceRoot: string): Promise<string | undefined>;
19
+ /**
20
+ * The same probe, telling "not a repository" apart from "the probe failed".
21
+ *
22
+ * `git rev-parse --show-toplevel` exits non-zero BOTH when the directory is not
23
+ * inside a work tree and for every way a remote command can fail (host gone,
24
+ * budget blown, sshd refusing, no answer at all). `resolveRepoRoot` folds the two
25
+ * into `undefined`, which is right for the workspace wizard — there "unknown" and
26
+ * "none" both mean "nothing to offer". The SCM panel cannot take that shortcut:
27
+ * treating a failed probe as "no repository" writes an EMPTY snapshot over the
28
+ * cached change list and tells the user their workspace has no git repository.
29
+ * That is exactly the blank state the snapshot cache exists to prevent, so this
30
+ * variant asks a command that KEEPS the answer (`repoProbeDetailCommand`: exit
31
+ * code + merged streams, `LC_ALL=C`) and reports the failure to the caller.
32
+ */
33
+ export type RepoRootProbe = {
34
+ kind: 'repo';
35
+ root: string;
36
+ } | {
37
+ kind: 'none';
38
+ } | {
39
+ kind: 'failed';
40
+ error: string;
41
+ };
42
+ /** The repository root, or WHY the probe could not answer. */
43
+ export declare function probeRepoRoot(alias: string, workspaceRoot: string): Promise<RepoRootProbe>;
19
44
  /** Repositories under a workspace root (the workspace itself sorts first). */
20
45
  export declare function findRepos(alias: string, workspaceRoot: string): Promise<string[]>;
21
46
  /** Every changed path plus the ignored half. */
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Last-read git state, remembered per workspace AND per repository directory.
3
+ *
4
+ * The complaint this module answers: switching between two remote workspaces
5
+ * blanked the source-control panel. The store dropped `status` / `head` /
6
+ * `repoRoot` on the switch and started a full `git status` again — minutes of
7
+ * "Loading…" on a large repository — even though BOTH workspaces had just been
8
+ * read, so the very data the panel needed was already in memory. It simply had
9
+ * no home: the store keeps one git slice, describing whatever is on screen.
10
+ *
11
+ * A snapshot is that home. It holds everything the panel paints from, keyed by
12
+ * the binding id AND the workspace-relative directory git ran in, because one
13
+ * workspace can carry several repositories and their states must never
14
+ * overwrite each other (the repo picker switches between them). The store
15
+ * hydrates a snapshot the moment a workspace is selected — in the same frame as
16
+ * the dropdown change — and refreshes it in the background; the read replaces
17
+ * the snapshot and repaints only when it is still the current binding's answer.
18
+ *
19
+ * MEMORY ONLY, on purpose: a `-u normal -z --ignored=matching` status of a
20
+ * monorepo is hundreds of kilobytes, and after a page reload it would describe
21
+ * a tree that may have moved on. The cache is an accelerator for switching, not
22
+ * a second source of truth — every value in it was read by a real command.
23
+ */
24
+ import type { GitStatusDetail } from '../../protocol.ts';
25
+ import type { DirExpansions } from './untracked-dirs.ts';
26
+ /**
27
+ * Branch header, structurally identical to the store's `HeadInfo`.
28
+ *
29
+ * Declared here instead of imported from `store.tsx` so this module stays a
30
+ * standalone leaf: `store` imports it, and a type-only back-import would pull
31
+ * the whole store into the module graph of every gate that touches the cache.
32
+ */
33
+ export interface GitHead {
34
+ branch: string;
35
+ detached: boolean;
36
+ ahead: number;
37
+ behind: number;
38
+ }
39
+ /** Everything one git read answered, as the panels need it. */
40
+ export interface GitSnapshot {
41
+ /** Enclosing repository root, or undefined when the directory is no repo. */
42
+ repoRoot: string | undefined;
43
+ /** Workspace-relative directory git ran in (the settled one). */
44
+ repoSubdir: string;
45
+ status: GitStatusDetail;
46
+ head: GitHead | undefined;
47
+ /** git answered "not a repository" — a fact, not a failure. */
48
+ repoMissing: boolean;
49
+ /** Expanded untracked directories, repository-relative. */
50
+ untrackedDirs: DirExpansions;
51
+ /** `Date.now()` when the read that produced this snapshot finished. */
52
+ at: number;
53
+ }
54
+ /** Cache key for one (workspace, repository directory) pair. */
55
+ export declare function gitCacheKey(bindingId: string, repoSubdir: string): string;
56
+ /** Split a cache key back into its two halves (undefined when it is not one). */
57
+ export declare function parseGitCacheKey(key: string): {
58
+ bindingId: string;
59
+ repoSubdir: string;
60
+ } | undefined;
61
+ /** The cached read for one (workspace, repository directory), if any. */
62
+ export declare function readGitSnapshot(key: string): GitSnapshot | undefined;
63
+ /**
64
+ * Remember one read.
65
+ *
66
+ * Called even when the read is no longer the binding on screen: the walk has
67
+ * already been paid for, and caching it is what makes the NEXT switch back
68
+ * instant instead of another minutes-long wait.
69
+ *
70
+ * An answer OLDER than the one already cached is dropped. Two reads of one
71
+ * workspace can overlap when the repo picker changes the directory mid-read (the
72
+ * settled directory is a second key, see the store's `readGit`), and the older
73
+ * walk may finish last — letting it win would hand the user a change list that
74
+ * predates the one already shown.
75
+ */
76
+ export declare function writeGitSnapshot(key: string, snapshot: GitSnapshot): void;
77
+ /** Drop every snapshot whose workspace left the binding table. */
78
+ export declare function retainGitSnapshots(bindingIds: ReadonlySet<string>): void;
79
+ /**
80
+ * Write a locally-known status into a cached snapshot.
81
+ *
82
+ * The plotted stage/unstage move is applied to the store's status WITHOUT a
83
+ * repository-wide read (see `applyLocalStage`). Without this write-through the
84
+ * cache would keep the pre-click list, so switching away and back would flip
85
+ * the row back — the panel would show a change the user already staged.
86
+ * A different `repoRoot` means the snapshot is not that repository: refusing is
87
+ * what keeps one workspace's rows out of another's cache.
88
+ */
89
+ export declare function patchCachedGitStatus(key: string, repoRoot: string | undefined, status: GitStatusDetail): void;
90
+ /** Same write-through for the expanded untracked directories. */
91
+ export declare function patchCachedUntrackedDirs(key: string, repoRoot: string | undefined, dirs: DirExpansions): void;
92
+ /** Test hook: forget every snapshot (the cache gate drives this module alone). */
93
+ export declare function clearGitSnapshots(): void;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Per-repository caches for the two views that live beside the change list:
3
+ * the source-control panel's side lists (stash / branches / remotes) and the
4
+ * history tab's commit log.
5
+ *
6
+ * Same reasoning as `git-cache.ts`, one level down: the git status snapshot
7
+ * already makes the change list paint instantly when a workspace is selected
8
+ * again, and these caches stop the second wave of blanks. The extras are three
9
+ * more git commands per repository switch; the log is up to 200 commits (`git
10
+ * log` on a deep monorepo is not free). Both are read-only views of state the
11
+ * host already answered — keeping the last answer costs nothing and is what the
12
+ * panels show while their background re-read runs.
13
+ *
14
+ * MEMORY ONLY, same contract as the git snapshots, and every entry is replaced
15
+ * by a real read; nothing is ever synthesised here.
16
+ */
17
+ import type { GitBranch, GitCommit, GitRemote, GitStash } from '../../protocol.ts';
18
+ /** Stash / branch / remote lists of one repository (one panel refresh). */
19
+ export interface ScmExtrasSnapshot {
20
+ stashes: GitStash[];
21
+ branches: GitBranch[];
22
+ remotes: GitRemote[];
23
+ /** `Date.now()` when the read finished. */
24
+ at: number;
25
+ }
26
+ /** The history tab's commit list of one repository. */
27
+ export interface HistorySnapshot {
28
+ commits: GitCommit[];
29
+ /** `Date.now()` when the read finished. */
30
+ at: number;
31
+ }
32
+ /** Cache key of one repository, as the git panel addresses it: alias + root. */
33
+ export declare function repoCacheKey(alias: string, repoRoot: string): string;
34
+ /** The cached side lists of one repository, if it was read before. */
35
+ export declare function readScmExtras(key: string): ScmExtrasSnapshot | undefined;
36
+ /** Remember one repository's side lists. */
37
+ export declare function writeScmExtras(key: string, snapshot: ScmExtrasSnapshot): void;
38
+ /** The cached commit log of one repository, if it was read before. */
39
+ export declare function readHistorySnapshot(key: string): HistorySnapshot | undefined;
40
+ /** Remember one repository's commit log. */
41
+ export declare function writeHistorySnapshot(key: string, snapshot: HistorySnapshot): void;
42
+ /** Test hook: forget both caches (the panel caches carry no other state). */
43
+ export declare function clearPanelCaches(): void;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * One remote read at a time, per key — with two honest contracts.
3
+ *
4
+ * Why this exists
5
+ * ---------------
6
+ * Git reads are expensive on a large repository: `loadStatusDetail` walks the
7
+ * whole index and worktree (minutes on the AOSP monorepo), and several surfaces
8
+ * can ask for one at the same moment — the workspace dropdown, the session
9
+ * follow, the panel's ⟳, and every write made through the plugin. Until now each
10
+ * caller simply started its own command and the newest one won the paint, which
11
+ * meant two concurrent walks of the same tree and a panel that could not tell
12
+ * which answer was current.
13
+ *
14
+ * The queue replaces that with:
15
+ *
16
+ * - `join` — "show me a read of this key". A read already in flight is shared:
17
+ * starting a second one would only duplicate the same walk, so a
18
+ * workspace switch costs nothing when the previous read of that
19
+ * workspace is still running.
20
+ * - `fresh` — "the answer must describe the state as of NOW" (the ⟳ button, and
21
+ * every write: a save, a commit, a stage verb). It never shares a
22
+ * read that started before the call: it waits for the running one to
23
+ * settle and then starts its own. Two `fresh` callers that arrive
24
+ * during the same read share the SINGLE follow-up read, because the
25
+ * second one can see that the follow-up started after its own call.
26
+ *
27
+ * Reads for DIFFERENT keys run in parallel: two workspaces are two repositories,
28
+ * and making one wait for the other would make a switch feel dead. Nothing here
29
+ * paints anything — the caller decides what its answer may overwrite — so the
30
+ * queue carries no store/React knowledge and is directly testable.
31
+ */
32
+ /** How a caller relates to a read that is already in flight. */
33
+ export type ReadMode = 'join' | 'fresh';
34
+ export declare class ReadQueue {
35
+ private readonly onChange?;
36
+ /** In-flight reads by key (insertion order is irrelevant here). */
37
+ private readonly flights;
38
+ private nextId;
39
+ /**
40
+ * @param onChange - called whenever a read starts or settles, so the owner can
41
+ * derive a "busy" flag from `inflightKeys()` without polling.
42
+ */
43
+ constructor(onChange?: (() => void) | undefined);
44
+ /** The keys with a read in flight (a caller derives its busy state from this). */
45
+ inflightKeys(): string[];
46
+ /** True while ONE key has a read in flight. */
47
+ isBusy(key: string): boolean;
48
+ /**
49
+ * Run `read` for `key`, honouring `mode`.
50
+ *
51
+ * The returned promise settles with this key's answer: a rejection comes from
52
+ * the read the caller ended up waiting on (its own, or the one it joined).
53
+ * `read` must be equivalent for every caller that passes the same key — the
54
+ * queue keeps only the FIRST caller's closure, because a joined read cannot
55
+ * carry a second closure.
56
+ *
57
+ * @param settleMs - hard settle deadline for a read THIS call starts (`0` =
58
+ * unbounded). A joined read keeps the deadline it was started with: its
59
+ * caller already decided how long that read may take.
60
+ */
61
+ run<T>(key: string, read: () => Promise<T>, mode?: ReadMode, settleMs?: number): Promise<T>;
62
+ /**
63
+ * `fresh` while a read is in flight: wait for it, then read once more.
64
+ *
65
+ * The follow-up is NOT started from here unconditionally, or two `fresh`
66
+ * callers that arrived during the same read would each start one (two
67
+ * concurrent walks, exactly what this class exists to prevent). The second
68
+ * caller instead finds a flight whose id is newer than the one it waited on —
69
+ * a read that started after its own call — and shares it.
70
+ */
71
+ private after;
72
+ /** Register a flight and run `read`; a synchronous throw becomes a rejection. */
73
+ private start;
74
+ }
@@ -149,6 +149,8 @@ export interface ExplorerValue {
149
149
  head: HeadInfo | undefined;
150
150
  gitBusy: boolean;
151
151
  gitError: string | undefined;
152
+ /** The painted git slice is a cached one; a background refresh is running. */
153
+ gitCached: boolean;
152
154
  /** Effective per-git-command budget (ms), for the loading hint. */
153
155
  gitDeadlineMs: number;
154
156
  tabs: EditorTab[];
@@ -199,7 +201,16 @@ export interface ExplorerValue {
199
201
  /** Repositories found under the workspace root, as workspace-relative dirs. */
200
202
  discoverRepos: () => Promise<string[]>;
201
203
  refreshAll: () => void;
202
- refreshGit: () => Promise<void>;
204
+ /**
205
+ * Re-read git for the binding on screen.
206
+ *
207
+ * `join` shares a read that is already in flight — the right contract for an
208
+ * automatic refresh, since a second walk of the same repository answers
209
+ * exactly the same question. `fresh` (the default) waits for a running read
210
+ * and then reads again: the answer must include whatever the caller just
211
+ * wrote.
212
+ */
213
+ refreshGit: (mode?: 'join' | 'fresh') => Promise<void>;
203
214
  afterWrite: () => Promise<void>;
204
215
  /**
205
216
  * Stage / unstage paths: the row moves at once, and ONE path-limited status
@@ -394,8 +405,25 @@ export declare function selectedWorkspaceId(): string;
394
405
  export declare function setRepoSubdir(subdir: string): Promise<void>;
395
406
  /** Repositories found under the workspace root (workspace-relative, root first). */
396
407
  export declare function discoverRepos(): Promise<string[]>;
397
- /** Re-read git status for the current binding. */
398
- export declare function refreshGit(): Promise<void>;
408
+ /**
409
+ * Re-read git for the binding on screen.
410
+ *
411
+ * Reads go through `scm/read-queue.ts`, keyed by (binding, repository directory):
412
+ * `join` shares a read that is already in flight — an automatic refresh has no
413
+ * reason to duplicate a walk — while `fresh` waits for it and then reads again,
414
+ * because the caller just wrote something and the answer must include it. Either
415
+ * mode never runs two walks of the same repository at once, which is what lets a
416
+ * background refresh and the ⟳ button coexist instead of racing.
417
+ *
418
+ * The answer is cached under the key the read STARTED with (even when the user
419
+ * has already switched away: the walk is paid for either way, and the cache is
420
+ * what makes the next visit instant) and repaints only while it is still that
421
+ * binding's latest read.
422
+ *
423
+ * @param mode - `fresh` (default) never reuses a read that started before the
424
+ * call; `join` shares a running one (workspace switches, bootstrap).
425
+ */
426
+ export declare function refreshGit(mode?: 'join' | 'fresh'): Promise<void>;
399
427
  /** Refresh everything a working-tree change can invalidate. */
400
428
  export declare function afterWrite(): Promise<void>;
401
429
  /** The repository the SCM panel is pointed at, or undefined when none. */
@@ -403,6 +431,25 @@ export declare function scmTarget(): {
403
431
  alias: string;
404
432
  root: string;
405
433
  } | undefined;
434
+ /**
435
+ * Read-only view of the git slice, for callers that do not render.
436
+ *
437
+ * The UI reads the same values through `useExplorer()`; this getter exists for
438
+ * the local gates, which drive this module in node with nothing mounted (they
439
+ * need to observe the switch, the cached flag and what a failed read left
440
+ * behind). It hands out primitives only, so no caller can mutate the store
441
+ * through it.
442
+ */
443
+ export declare function gitState(): {
444
+ workspaceId: string;
445
+ repoRoot: string | undefined;
446
+ repoSubdir: string;
447
+ changes: number;
448
+ gitBusy: boolean;
449
+ gitCached: boolean;
450
+ gitError: string | undefined;
451
+ repoMissing: boolean;
452
+ };
406
453
  /**
407
454
  * Move paths between the index and the worktree halves of the LOCAL status
408
455
  * model — the optimistic update, its rollback, and the correction the probe
@@ -446,6 +493,22 @@ export declare function stageOptimistic(paths: readonly string[], staged: boolea
446
493
  * @param dirPath - the directory row's repo-relative path (with its `/`).
447
494
  */
448
495
  export declare function toggleUntrackedDir(dirPath: string): Promise<void>;
496
+ /**
497
+ * Hard settle deadline for ONE queued git read (`scm/read-queue.ts`).
498
+ *
499
+ * A read that never settles would keep its key reserved forever: later `join`
500
+ * calls reuse the dead promise, `fresh` callers wait for it, and the busy flag
501
+ * stays up — one hung host call would freeze that key for the rest of the page
502
+ * (the pre-cache code could always re-read, so this is the one regression a
503
+ * shared flight introduces). The bound is generous on purpose: the status read
504
+ * issues its probe and then its two loaders SEQUENTIALLY, so twice the configured
505
+ * git budget plus slack is "stuck", not "slow".
506
+ *
507
+ * Exported because the panels own their own queues (SCM side lists, history log)
508
+ * and must pass the same bound — a queue without one is a permanent lock waiting
509
+ * for a host hiccup.
510
+ */
511
+ export declare function gitReadSettleMs(): number;
449
512
  /** Open (or focus) a remote file in the editor tab strip. */
450
513
  export declare function openFile(fullPath: string, options?: {
451
514
  line?: number;
@@ -15,6 +15,25 @@ export declare function assertRemoteName(name: string): string;
15
15
  export declare function assertRemoteUrl(url: string): string;
16
16
  /** Resolve the enclosing repository root (fails outside a work tree). */
17
17
  export declare function repoProbeCommand(workdir: string): string;
18
+ /**
19
+ * Repository probe that keeps the ANSWER (exit code + both streams) on stdout.
20
+ *
21
+ * `repoProbeCommand` throws the exit code and stderr away — everything that is
22
+ * not a root line reads as "nothing". That is fine for the workspace wizard
23
+ * ("nothing to offer") but not for the SCM panel, where "this is not a
24
+ * repository" and "git could not answer" are DIFFERENT states: the first is a
25
+ * fact that may be cached, the second must keep the last known change list and
26
+ * report the reason. So this command always exits 0 and prints
27
+ *
28
+ * RVS_RC=<git exit code>
29
+ * <everything git wrote, stderr merged>
30
+ *
31
+ * `LC_ALL=C` pins git's wording (the client matches `not a git repository`; a
32
+ * localized host would otherwise read as a failure), and merging the streams is
33
+ * what makes that message survive — git writes it to stderr, which the plain
34
+ * probe discards.
35
+ */
36
+ export declare function repoProbeDetailCommand(workdir: string): string;
18
37
  /** Discover repositories under a workspace root (depth-capped). */
19
38
  export declare function repoDiscoveryCommand(root: string): string;
20
39
  /**
@@ -143,6 +162,25 @@ export declare function initCommand(workdir: string): string;
143
162
  export declare function applyPatchCommand(workdir: string, patchPath: string, mode: 'stage' | 'unstage' | 'revert'): string;
144
163
  /** Enclosing repository root from `rev-parse --show-toplevel`. */
145
164
  export declare function parseRepoRoot(stdout: string): string | undefined;
165
+ /** One strict probe's answer (see `repoProbeDetailCommand`). */
166
+ export type RepoProbeAnswer = {
167
+ kind: 'repo';
168
+ root: string;
169
+ } | {
170
+ kind: 'none';
171
+ } | {
172
+ kind: 'failed';
173
+ error: string;
174
+ };
175
+ /**
176
+ * Parse `repoProbeDetailCommand`'s output into the three states the panel needs.
177
+ *
178
+ * "not a git repository" is matched on git's own message rather than on the exit
179
+ * code: `rev-parse` exits 128 for that AND for a directory it cannot enter, so
180
+ * the code alone cannot separate the two. The message is guaranteed English by
181
+ * the command's `LC_ALL=C`.
182
+ */
183
+ export declare function parseRepoProbe(stdout: string): RepoProbeAnswer;
146
184
  /** Repository dirs found under a workspace root. */
147
185
  export declare function parseRepoDiscovery(stdout: string): string[];
148
186
  /**
@@ -39,6 +39,11 @@ export interface Config {
39
39
  /**
40
40
  * P2 transparent-routing kill-switch: off = pure local passthrough;
41
41
  * shadow = log remote decisions but execute locally; enforce = route.
42
+ *
43
+ * The DEFAULT is `enforce`: the plugin exists to execute remote workspaces
44
+ * remotely, and a fresh profile that silently kept executing locally was the
45
+ * most common "why is nothing remote" report. `off`/`shadow` stay available as
46
+ * explicit opt-outs for debugging and for hosts that must stay local.
42
47
  */
43
48
  routingMode?: RoutingMode;
44
49
  /** Per-command timeout for git work on a remote host (ms). */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-remote-vs",
3
3
  "description": "VSCode-style remote development for DSH: SSH host manager (key/password), helper deploy pipeline with version check, remote file explorer + editor + terminal in dsh-better-sidebar.",
4
- "version": "0.9.2",
4
+ "version": "0.10.0",
5
5
  "icon": "icon.svg",
6
6
  "keywords": [
7
7
  "dsh",
@@ -59,7 +59,7 @@
59
59
  "./locale/*.json": "./locale/*.json"
60
60
  },
61
61
  "engines": {
62
- "dsh": ">=0.2.1-alpha.3 <0.3.0"
62
+ "dsh": ">=0.2.2-alpha.2 <0.3.0"
63
63
  },
64
64
  "dsh": {
65
65
  "bundle": {
@@ -122,7 +122,7 @@
122
122
  "@codemirror/view": "^6.43.14",
123
123
  "@deepseek-ai/dsh-fs-ssh": "0.2.1-rc.2",
124
124
  "@deepseek-ai/dsh-ssh": "0.2.1-rc.2",
125
- "@deepseek-ai/schemastery": "3.18.5",
125
+ "@deepseek-ai/schemastery": "3.18.6-alpha.1",
126
126
  "@lezer/highlight": "1.2.3",
127
127
  "@xterm/addon-fit": "^0.11.0",
128
128
  "@xterm/xterm": "^6.0.0",
@@ -130,44 +130,44 @@
130
130
  "zod": "4.4.3"
131
131
  },
132
132
  "peerDependencies": {
133
- "@deepseek-ai/cordis": "^4.0.5-alpha.2",
134
- "@deepseek-ai/dsh-agent-preset": "^0.2.1-rc.2",
135
- "@deepseek-ai/dsh-bash-local": "^0.2.1-rc.2",
136
- "@deepseek-ai/dsh-file-reference": "^0.2.1-rc.2",
137
- "@deepseek-ai/dsh-fs": "^0.2.1-rc.2",
138
- "@deepseek-ai/dsh-shell": "^0.2.1-rc.2",
139
- "@deepseek-ai/dsh-spill": "^0.2.1-rc.2",
140
- "@deepseek-ai/dsh-subprocess": "^0.2.1-rc.2",
141
- "@deepseek-ai/dsh-tools": "^0.2.1-rc.2",
133
+ "@deepseek-ai/cordis": "^4.0.6-alpha.1",
134
+ "@deepseek-ai/dsh-agent-preset": "^0.2.2-alpha.2",
135
+ "@deepseek-ai/dsh-bash-local": "^0.2.2-alpha.2",
136
+ "@deepseek-ai/dsh-file-reference": "^0.2.2-alpha.2",
137
+ "@deepseek-ai/dsh-fs": "^0.2.2-alpha.2",
138
+ "@deepseek-ai/dsh-shell": "^0.2.2-alpha.2",
139
+ "@deepseek-ai/dsh-spill": "^0.2.2-alpha.2",
140
+ "@deepseek-ai/dsh-subprocess": "^0.2.2-alpha.2",
141
+ "@deepseek-ai/dsh-tools": "^0.2.2-alpha.2",
142
142
  "react": "^18.2.0",
143
143
  "react-dom": "^18.2.0"
144
144
  },
145
145
  "devDependencies": {
146
- "@deepseek-ai/cordis": "^4.0.4",
147
- "@deepseek-ai/dsh-agent": "0.2.1-rc.2",
148
- "@deepseek-ai/dsh-agent-preset-registry": "0.2.1-rc.2",
149
- "@deepseek-ai/dsh-api-remotes": "0.2.1-rc.2",
150
- "@deepseek-ai/dsh-bash-local": "0.2.1-rc.2",
151
- "@deepseek-ai/dsh-client-locale": "0.2.1-rc.2",
152
- "@deepseek-ai/dsh-client-ui-renderer": "0.2.1-rc.2",
153
- "@deepseek-ai/dsh-client-ui-settings": "0.2.1-rc.2",
154
- "@deepseek-ai/dsh-client-ui-sidebar-right": "0.2.1-rc.2",
155
- "@deepseek-ai/dsh-client-ui-slots": "0.2.1-rc.2",
156
- "@deepseek-ai/dsh-client-ui-workspace": "0.2.1-rc.2",
157
- "@deepseek-ai/dsh-file-reference": "0.2.1-rc.2",
158
- "@deepseek-ai/dsh-fs": "0.2.1-rc.2",
159
- "@deepseek-ai/dsh-host-directory-picker": "0.2.1-rc.2",
160
- "@deepseek-ai/dsh-host-webserver": "0.2.1-rc.2",
161
- "@deepseek-ai/dsh-llm": "0.2.1-rc.2",
162
- "@deepseek-ai/dsh-sandbox": "0.2.1-rc.2",
163
- "@deepseek-ai/dsh-session": "0.2.1-rc.2",
164
- "@deepseek-ai/dsh-shell": "0.2.1-rc.2",
165
- "@deepseek-ai/dsh-spill": "0.2.1-rc.2",
166
- "@deepseek-ai/dsh-subprocess": "0.2.1-rc.2",
167
- "@deepseek-ai/dsh-system-prompt": "0.2.1-rc.2",
168
- "@deepseek-ai/dsh-tools": "0.2.1-rc.2",
169
- "@deepseek-ai/dsh-working-directory": "0.2.1-rc.2",
170
- "@deepseek-ai/dsh-workspace": "0.2.1-rc.2",
146
+ "@deepseek-ai/cordis": "^4.0.6-alpha.1",
147
+ "@deepseek-ai/dsh-agent": "0.2.2-alpha.2",
148
+ "@deepseek-ai/dsh-agent-preset-registry": "0.2.2-alpha.2",
149
+ "@deepseek-ai/dsh-api-remotes": "0.2.2-alpha.2",
150
+ "@deepseek-ai/dsh-bash-local": "0.2.2-alpha.2",
151
+ "@deepseek-ai/dsh-client-locale": "0.2.2-alpha.2",
152
+ "@deepseek-ai/dsh-client-ui-renderer": "0.2.2-alpha.2",
153
+ "@deepseek-ai/dsh-client-ui-settings": "0.2.2-alpha.2",
154
+ "@deepseek-ai/dsh-client-ui-sidebar-right": "0.2.2-alpha.2",
155
+ "@deepseek-ai/dsh-client-ui-slots": "0.2.2-alpha.2",
156
+ "@deepseek-ai/dsh-client-ui-workspace": "0.2.2-alpha.2",
157
+ "@deepseek-ai/dsh-file-reference": "0.2.2-alpha.2",
158
+ "@deepseek-ai/dsh-fs": "0.2.2-alpha.2",
159
+ "@deepseek-ai/dsh-host-directory-picker": "0.2.2-alpha.2",
160
+ "@deepseek-ai/dsh-host-webserver": "0.2.2-alpha.2",
161
+ "@deepseek-ai/dsh-llm": "0.2.2-alpha.2",
162
+ "@deepseek-ai/dsh-sandbox": "0.2.2-alpha.2",
163
+ "@deepseek-ai/dsh-session": "0.2.2-alpha.2",
164
+ "@deepseek-ai/dsh-shell": "0.2.2-alpha.2",
165
+ "@deepseek-ai/dsh-spill": "0.2.2-alpha.2",
166
+ "@deepseek-ai/dsh-subprocess": "0.2.2-alpha.2",
167
+ "@deepseek-ai/dsh-system-prompt": "0.2.2-alpha.2",
168
+ "@deepseek-ai/dsh-tools": "0.2.2-alpha.2",
169
+ "@deepseek-ai/dsh-working-directory": "0.2.2-alpha.2",
170
+ "@deepseek-ai/dsh-workspace": "0.2.2-alpha.2",
171
171
  "@types/node": "^22.20.0",
172
172
  "@types/react": "~18.3.1",
173
173
  "@types/react-dom": "^18.3.5",
@@ -22,7 +22,8 @@ import { Notice, Row, hairline, optionStyle, selectStyle } from './ui.tsx'
22
22
  export function SettingsHostsSection(_props: SettingsSectionOwnerProps): ReactNode {
23
23
  const [t] = useState(() => makeT(detectLang()))
24
24
  const [hosts, setHosts] = useState<RemoteHostSummary[]>([])
25
- const [mode, setMode] = useState<'off' | 'shadow' | 'enforce'>('off')
25
+ /** Pre-load placeholder; matches the plugin's default (nforce) until the host answers. */
26
+ const [mode, setMode] = useState<'off' | 'shadow' | 'enforce'>('enforce')
26
27
  const [version, setVersion] = useState('')
27
28
  const [saving, setSaving] = useState(false)
28
29
  const [error, setError] = useState<string | undefined>(undefined)
@@ -100,7 +100,11 @@ interface InputSnapshot {
100
100
  draft: string
101
101
  draftRev?: number
102
102
  occurrences?: readonly OccurrenceLike[]
103
- phase?: 'plain' | 'adjudicating' | 'claimed' | 'submitting'
103
+ /**
104
+ * Host submission plane. `sending` exists since DSH 0.2.2-alpha.2: an ordinary
105
+ * message the host has not accepted yet — the editor is read-only there too.
106
+ */
107
+ phase?: 'plain' | 'adjudicating' | 'claimed' | 'submitting' | 'sending'
104
108
  claim?: { readonly token: string }
105
109
  }
106
110
 
@@ -295,7 +299,7 @@ export function insertDraftReference(
295
299
  const { actx, input } = resolved
296
300
  const snapshot = input.state.getSnapshot()
297
301
  if (input.insertReference === undefined || snapshot.draftRev === undefined) return false
298
- if (snapshot.phase === 'adjudicating' || snapshot.phase === 'submitting') return false
302
+ if (snapshot.phase === 'adjudicating' || snapshot.phase === 'submitting' || snapshot.phase === 'sending') return false
299
303
  before = snapshot.draftRev
300
304
  const at = appendOffset(snapshot)
301
305
  if (at === undefined) return false
@@ -346,6 +350,11 @@ export function appendToDraft(ctx: Context, sessionId: string, text: string): bo
346
350
  const { actx, input } = resolved
347
351
  const snapshot = input.state.getSnapshot()
348
352
  const draft = snapshot.draft
353
+ // The host makes the editor read-only on every submission plane: a slash
354
+ // command being checked, a claimed command running, and (since DSH
355
+ // 0.2.2-alpha.2) an ordinary message waiting for the host to accept it.
356
+ // Writing there would either be refused or land after the send snapshot.
357
+ if (snapshot.phase === 'adjudicating' || snapshot.phase === 'submitting' || snapshot.phase === 'sending') return false
349
358
  if ((snapshot.occurrences ?? []).length > 0 || snapshot.phase === 'claimed') {
350
359
  const at = appendOffset(snapshot)
351
360
  if (snapshot.draftRev === undefined || at === undefined) {